changeset 11:aa6951805e1a

New features and bug fixes: - Extracted navigation and footer parts into their own model/view. - Added search. - Better typography styles. - Fixed some bugs in the Handlebars helpers.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 29 Dec 2012 18:21:44 -0800
parents 6ac0b74a57f7
children ff0058feccf5
files wikked/indexer.py wikked/static/css/wikked.less wikked/static/js/wikked.js wikked/static/tpl/diff-page.html wikked/static/tpl/edit-page.html wikked/static/tpl/footer.html wikked/static/tpl/history-page.html wikked/static/tpl/inlinks-page.html wikked/static/tpl/nav.html wikked/static/tpl/read-page.html wikked/static/tpl/revision-page.html wikked/static/tpl/search-results.html wikked/templates/index.html wikked/views.py wikked/wiki.py
diffstat 15 files changed, 652 insertions(+), 379 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/indexer.py	Sat Dec 29 18:21:44 2012 -0800
@@ -0,0 +1,98 @@
+import os
+import os.path
+import logging
+from whoosh.index import create_in, open_dir
+from whoosh.fields import Schema, ID, KEYWORD, TEXT, STORED
+from whoosh.qparser import QueryParser
+
+
+class WikiIndex(object):
+    def __init__(self, store_dir, logger=None):
+        self.store_dir = store_dir
+        self.logger = logger
+        if logger is None:
+            self.logger = logging.getLogger('wikked.index')
+
+    def update(self, pages):
+        raise NotImplementedError()
+
+    def search(self, query):
+        raise NotImplementedError()
+
+
+class WhooshWikiIndex(WikiIndex):
+    def __init__(self, store_dir, logger=None):
+        WikiIndex.__init__(self, store_dir, logger)
+        if not os.path.isdir(store_dir):
+            os.makedirs(store_dir)
+            schema = Schema(
+                    url=ID(stored=True), 
+                    title=TEXT(stored=True), 
+                    content=TEXT,
+                    path=STORED,
+                    time=STORED
+                    )
+            self.ix = create_in(store_dir, schema)
+        else:
+            self.ix = open_dir(store_dir)
+
+    def update(self, pages):
+        to_reindex = set()
+        already_indexed = set()
+
+        with self.ix.searcher() as searcher:
+            writer = self.ix.writer()
+
+            for fields in searcher.all_stored_fields():
+                indexed_url = fields['url']
+                indexed_path = fields['path']
+                indexed_time = fields['time']
+
+                if not os.path.isfile(indexed_path):
+                    # File was deleted.
+                    writer.delete_by_term('url', indexed_url)
+                else:
+                    already_indexed.add(indexed_path)
+                    if os.path.getmtime(indexed_path) > fields['time']:
+                        # File as changed since last index.
+                        writer.delete_by_term('url', indexed_url)
+                        to_reindex.add(indexed_path)
+
+            for page in pages:
+                page._ensureMeta()
+                page_path = page._meta['path']
+                if page_path in to_reindex or page_path not in already_indexed:
+                    self._indexPage(writer, page)
+
+            writer.commit()
+
+    def search(self, query):
+        with self.ix.searcher() as searcher:
+            title_qp = QueryParser("title", self.ix.schema).parse(query)
+            content_qp = QueryParser("content", self.ix.schema).parse(query)
+            comp_query = title_qp | content_qp
+            results = searcher.search(comp_query)
+
+            page_infos = []
+            for hit in results:
+                page_info = {
+                        'title': hit['title'],
+                        'url': hit['url']
+                        }
+                page_info['title_highlights'] = hit.highlights('title')
+                with open(hit['path']) as f:
+                    content = unicode(f.read())
+                page_info['content_highlights'] = hit.highlights('content', text=content)
+                page_infos.append(page_info)
+            return page_infos
+
+    def _indexPage(self, writer, page):
+        self.logger.debug("Indexing: %s" % page.url)
+        writer.add_document(
+            url=unicode(page.url),
+            title=unicode(page.title),
+            content=unicode(page.raw_text),
+            path=page._meta['path'],
+            time=os.path.getmtime(page._meta['path'])
+            )
+
--- a/wikked/static/css/wikked.less	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/css/wikked.less	Sat Dec 29 18:21:44 2012 -0800
@@ -83,6 +83,9 @@
 sup, sub {
     line-height: 0;
 }
+li {
+    line-height: @baseLineHeight;
+}
 
 // Global classes
 a.wiki-link {
@@ -99,17 +102,16 @@
 .decorator {
     text-transform: uppercase;
     font-weight: lighter;
-    font-size: 0.8em;
+    font-size: 0.7em;
     letter-spacing: 0.1em;
     color: @colorBlueDark;
-}
-.rev_id {
-    font-family: monospace;
-    font-size: 0.8em;
-    color: @colorCode;
+    .rev_id {
+        font-family: monospace;
+        color: @colorCode;
+    }
 }
 
-.wrapper {
+.container {
     nav, div.meta {
         color: @colorNavLink;
         font-size: @smallFontSize;
@@ -141,6 +143,10 @@
     }
 }
 
+form.search {
+    display: inline-block;
+    margin: 0;
+}
 form.page-edit {
     textarea {
         height: 10em;
--- a/wikked/static/js/wikked.js	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/js/wikked.js	Sat Dec 29 18:21:44 2012 -0800
@@ -18,6 +18,7 @@
     loadedTemplates: {},
     get: function(name, callback) {
         if (name in this.loadedTemplates) {
+            console.log('Returning cached template "{0}".'.format(name));
             callback(this.loadedTemplates[name]);
         } else {
             var $loader = this;
@@ -37,6 +38,9 @@
  * Handlebars helper: reverse iterator.
  */
 Handlebars.registerHelper('eachr', function(context, options) {
+    if (context === undefined) {
+        return '';
+    }
     data = undefined;
     if (options.data) {
         data = Handlebars.createFrame(options.data);
@@ -62,6 +66,12 @@
     }
 	return options.inverse(this);
 });
+Handlebars.registerHelper('ifneq', function(context, options) {
+    if (context != options.hash.to) {
+        return options.fn(this);
+    }
+    return options.inverse(this);
+});
 
 //-------------------------------------------------------------//
 
@@ -103,8 +113,46 @@
     /**
      * Wiki page models.
      */
+    var NavigationModel = Backbone.Model.extend({
+        idAttribute: 'path',
+        defaults: function() {
+            return {
+                path: "main-page",
+                action: "read"
+            };
+        },
+        initialize: function() {
+            this.on('change:path', function(model, path) {
+                model._onChangePath(path);
+            });
+            this._onChangePath(this.get('path'));
+            return this;
+        },
+        _onChangePath: function(path) {
+            this.set('url_home', '/#/read/main-page');
+            this.set('url_read', '/#/read/' + path);
+            this.set('url_edit', '/#/edit/' + path);
+            this.set('url_hist', '/#/changes/' + path);
+            this.set('url_search', '/search');
+        }
+    });
+
+    var FooterModel = Backbone.Model.extend({
+        defaults: function() {
+            return {
+                url_extras: [ { name: 'Home', url: '/' } ]
+            };
+        },
+        addExtraUrl: function(name, url, index) {
+            if (index === undefined) {
+                this.get('url_extras').push({ name: name, url: url });
+            } else {
+                this.get('url_extras').splice(index, 0, { name: name, url: url });
+            }
+        }
+    });
+
     var PageModel = Backbone.Model.extend({
-        urlRoot: '/api/read/',
         idAttribute: 'path',
         defaults: function() {
             return {
@@ -120,6 +168,7 @@
             });
             this._onChangePath(this.get('path'));
             this._onChangeText('');
+            return this;
         },
         url: function() {
             var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError();
@@ -137,14 +186,6 @@
             return meta[key];
         },
         _onChangePath: function(path) {
-            this.set('url_home', '/#/read/main-page');
-            this.set('url_read', '/#/read/' + path);
-            this.set('url_edit', '/#/edit/' + path);
-            this.set('url_hist', '/#/changes/' + path);
-            this.set('url_rev', '/#/revision/' + path);
-            this.set('url_diffc', '/#/diff/c/' + path);
-            this.set('url_diffr', '/#/diff/r/' + path);
-            this.set('url_inlinks', '/#/inlinks/' + path);
         },
         _onChangeText: function(text) {
             this.set('content', new Handlebars.SafeString(text));
@@ -154,22 +195,59 @@
     var PageStateModel = PageModel.extend({
         urlRoot: '/api/state/'
     });
-
-    var PageSourceModel = PageModel.extend({
-        urlRoot: '/api/raw/'
+    
+    var MasterPageModel = PageModel.extend({
+        initialize: function() {
+            this.nav = new NavigationModel({ id: this.id });
+            this.footer = new FooterModel();
+            MasterPageModel.__super__.initialize.apply(this, arguments);
+            if (this.action !== undefined) {
+                this.nav.set('action', this.action);
+                this.footer.set('action', this.action);
+            }
+            return this;
+        },
+        _onChangePath: function(path) {
+            MasterPageModel.__super__._onChangePath.apply(this, arguments);
+            this.nav.set('path', path);
+        }
     });
 
-    var PageEditModel = PageModel.extend({
-        urlRoot: '/api/edit/'
+    var PageReadModel = MasterPageModel.extend({
+        urlRoot: '/api/read/',
+        action: 'read',
+        _onChangePath: function(path) {
+            PageReadModel.__super__._onChangePath.apply(this, arguments);
+            this.footer.addExtraUrl('Pages Linking Here', '/#/inlinks/' + path, 1);
+            this.footer.addExtraUrl('JSON', '/api/read/' + path);
+        }
+    });
+
+    var PageSourceModel = MasterPageModel.extend({
+        urlRoot: '/api/raw/',
+        action: 'source'
     });
 
-    var PageHistoryModel = PageModel.extend({
-        urlRoot: '/api/history/'
+    var PageEditModel = MasterPageModel.extend({
+        urlRoot: '/api/edit/',
+        action: 'edit'
     });
 
-    var PageRevisionModel = PageModel.extend({
+    var PageHistoryModel = MasterPageModel.extend({
+        urlRoot: '/api/history/',
+        action: 'history',
+        _onChangePath: function(path) {
+            PageHistoryModel.__super__._onChangePath.apply(this, arguments);
+            this.set('url_rev', '/#/revision/' + path);
+            this.set('url_diffc', '/#/diff/c/' + path);
+            this.set('url_diffr', '/#/diff/r/' + path);
+        }
+    });
+
+    var PageRevisionModel = MasterPageModel.extend({
         urlRoot: '/api/revision/',
         idAttribute: 'path_and_rev',
+        action: 'revision',
         defaults: function() {
             return {
                 path: "main-page",
@@ -177,6 +255,7 @@
             };
         },
         initialize: function() {
+            PageRevisionModel.__super__.initialize.apply(this, arguments);
             this.on('change:path', function(model, path) {
                 model._onChangePathOrRev(path, model.get('rev'));
             });
@@ -184,7 +263,7 @@
                 model._onChangePathOrRev(model.get('path'), rev);
             });
             this._onChangePathOrRev(this.get('path'), this.get('rev'));
-            PageRevisionModel.__super__.initialize.call(this);
+            return this;
         },
         _onChangePathOrRev: function(path, rev) {
             this.set('path_and_rev', path + '/' + rev);
@@ -195,9 +274,10 @@
         }
     });
 
-    var PageDiffModel = PageModel.extend({
+    var PageDiffModel = MasterPageModel.extend({
         urlRoot: '/api/diff/',
         idAttribute: 'path_and_revs',
+        action: 'diff',
         defaults: function() {
             return {
                 path: "main-page",
@@ -206,6 +286,7 @@
             };
         },
         initialize: function() {
+            PageDiffModel.__super__.initialize.apply(this, arguments);
             this.on('change:path', function(model, path) {
                 model._onChangePathOrRevs(path, model.get('rev'));
             });
@@ -216,7 +297,7 @@
                 model._onChangePathOrRevs(model.get('path'), model.get('rev1'), rev2);
             });
             this._onChangePathOrRevs(this.get('path'), this.get('rev1'), this.get('rev2'));
-            PageRevisionModel.__super__.initialize.call(this);
+            return this;
         },
         _onChangePathOrRevs: function(path, rev1, rev2) {
             this.set('path_and_revs', path + '/' + rev1 + '/' + rev2);
@@ -234,52 +315,130 @@
         }
     });
 
-    var IncomingLinksModel = PageModel.extend({
-        urlRoot: '/api/inlinks/'
+    var IncomingLinksModel = MasterPageModel.extend({
+        urlRoot: '/api/inlinks/',
+        action: 'inlinks'
+    });
+
+    var WikiSearchModel = MasterPageModel.extend({
+        urlRoot: '/api/search/',
+        action: 'search',
+        title: function() {
+            return 'Search';
+        },
+        execute: function(query) {
+            var $model = this;
+            $.getJSON('/api/search', { q: query })
+                .success(function (data) {
+                    $model.set('hits', data.hits);
+                })
+                .error(function() {
+                    alert("Error searching...");
+                });
+        }
     });
 
     /**
      * Wiki page views.
      */
-    var PageReadView = Backbone.View.extend({
-        tagName: "div",
+    var PageView = Backbone.View.extend({
+        tagName: 'div',
+        className: 'wrapper',
         initialize: function() {
+            PageView.__super__.initialize.apply(this, arguments);
             var $view = this;
-            var model = new PageModel({ path: this.id });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('read-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        $('a.wiki-link[data-wiki-url]').each(function(i, el) {
-                            var jel = $(el);
-                            if (jel.hasClass('missing'))
-                                jel.attr('href', '/#/edit/' + jel.attr('data-wiki-url'));
-                            else
-                                jel.attr('href', '/#/read/' + jel.attr('data-wiki-url'));
-                        });
-                        document.title = model.title();
-                    });
-                },
-                error: function(model, xhr, options) {
-                    TemplateLoader.get('404', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                    });
+            this.model.on("change", function() { $view.render(); });
+            return this;
+        },
+        render: function(view) {
+            if (this.templateName !== undefined) {
+                this.renderTemplate(this.templateName, this.renderCallback);
+            }
+            this.renderTitle(this.titleFormat);
+            return this;
+        },
+        renderTemplate: function(tpl_name, callback) {
+            var $view = this;
+            TemplateLoader.get(tpl_name, function(src) {
+                var template = Handlebars.compile(src);
+                $view.$el.html(template($view.model.toJSON()));
+                if (callback !== undefined) {
+                    callback.call($view, $view, $view.model);
                 }
             });
+        },
+        renderTitle: function(formatter) {
+            var title = this.model.title();
+            if (formatter !== undefined) {
+                title = formatter.call(this, title);
+            }
+            document.title = title;
+        }
+    });
+    _.extend(PageView, Backbone.Events);
+
+    var NavigationView = PageView.extend({
+        templateName: 'nav',
+        initialize: function() {
+            NavigationView.__super__.initialize.apply(this, arguments);
+            this.render();
+            return this;
+        },
+        render: function() {
+            this.renderTemplate('nav');
+        },
+        postRender: function() {
+            var $view = this;
+            this.$('#search').submit(function() {
+                app.navigate('/search/' + $(this.q).val(), { trigger: true });
+                return false;
+            });
+        }
+    });
+
+    var FooterView = PageView.extend({
+        templateName:  'footer',
+        initialize: function() {
+            FooterView.__super__.initialize.apply(this, arguments);
+            this.render();
+            return this;
+        },
+        render: function() {
+            this.renderTemplate('footer');
+        },
+        postRender: function() {
+        }
+    });
+
+    var MasterPageView = PageView.extend({
+        initialize: function() {
+            MasterPageView.__super__.initialize.apply(this, arguments);
+            this.nav = new NavigationView({ model: this.model.nav });
+            this.footer = new FooterView({ model: this.model.footer });
+            this.render();
+            return this;
+        },
+        renderCallback: function(view, model) {
+            this.nav.$el.prependTo(this.$el);
+            this.nav.postRender();
+            this.footer.$el.appendTo(this.$el);
+            this.footer.postRender();
+        }
+    });
+
+    var PageReadView = MasterPageView.extend({
+        templateName: 'read-page',
+        initialize: function() {
+            PageReadView.__super__.initialize.apply(this, arguments);
             // Also get the current state, and show a warning
             // if the page is new or modified.
-            var stateModel = new PageStateModel({ id: this.id });
+            var stateModel = new PageStateModel({ path: this.model.get('path') });
             stateModel.fetch({
                 success: function(model, response, options) {
                     if (model.get('state') == 'new' || model.get('state') == 'modified') {
                         TemplateLoader.get('state-warning', function(src) {
-                            var template_data = model.toJSON();
                             var template = Handlebars.compile(src);
-                            var warning = $(template(template_data));
+                            var warning = $(template(model.toJSON()));
                             warning.css('display', 'none');
                             warning.prependTo($('#app'));
                             warning.slideDown();
@@ -292,34 +451,35 @@
                 }
             });
             return this;
+        },
+        renderCallback: function(view, model) {
+            PageReadView.__super__.renderCallback.apply(this, arguments);
+            // Replace all wiki links with proper hyperlinks using the JS app's
+            // URL scheme.
+            this.$('a.wiki-link[data-wiki-url]').each(function(i) {
+                var jel = $(this);
+                if (jel.hasClass('missing'))
+                    jel.attr('href', '/#/edit/' + jel.attr('data-wiki-url'));
+                else
+                    jel.attr('href', '/#/read/' + jel.attr('data-wiki-url'));
+            });
         }
     });
 
-    var PageEditView = Backbone.View.extend({
-        initialize: function() {
-            var $view = this;
-            var model = new PageEditModel({ path: this.id });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('edit-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        document.title = 'Editing: ' + model.title();
-
-                        $('#page-edit').submit(function() {
-                            $view._submitText(this, model.get('path'));
-                            return false;
-                        });
-                    });
-                },
-                error: function(model, xhr, options) {
-                }
+    var PageEditView = MasterPageView.extend({
+        templateName: 'edit-page',
+        renderCallback: function(view, model) {
+            PageEditView.__super__.renderCallback.apply(this, arguments);
+            this.$('#page-edit').submit(function() {
+                view._submitText(this, model.get('path'));
+                return false;
             });
-            return this;
+        },
+        titleFormat: function(title) {
+            return 'Editing: ' + title;
         },
         _submitText: function(form, path) {
-            $.post('/api/edit/' + path, $(form).serialize())
+            $.post('/api/edit/' + path, this.$(form).serialize())
                 .success(function(data) {
                     app.navigate('/read/' + path, { trigger: true });
                 })
@@ -329,28 +489,17 @@
         }
     });
 
-    var PageHistoryView = Backbone.View.extend({
-        initialize: function() {
-            var $view = this;
-            var model = new PageHistoryModel({ path: this.id });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('history-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        document.title = 'Changes: ' + model.title();
-
-                        $('#diff-page').submit(function() {
-                            $view._triggerDiff(this, model.get('path'));
-                            return false;
-                        });
-                    });
-                },
-                error: function() {
-                }
+    var PageHistoryView = MasterPageView.extend({
+        templateName: 'history-page',
+        renderCallback: function(view, model) {
+            PageHistoryView.__super__.renderCallback.apply(this, arguments);
+            this.$('#diff-page').submit(function() {
+                view._triggerDiff(this, model.get('path'));
+                return false;
             });
-            return this;
+        },
+        titleFormat: function(title) {
+            return 'History: ' + title;
         },
         _triggerDiff: function(form, path) {
             var rev1 = $('input[name=rev1]:checked', form).val();
@@ -359,60 +508,31 @@
         }
     });
 
-    var PageRevisionView = Backbone.View.extend({
-        initialize: function() {
-            var $view = this;
-            var model = new PageRevisionModel({ path: this.id, rev: this.options.rev });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('revision-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        document.title = model.title() + ' [' + model.get('rev') + ']';
-                    });
-                }
-            });
+    var PageRevisionView = MasterPageView.extend({
+        templateName: 'revision-page',
+        titleFormat: function(title) {
+            return title + ' [' + this.model.get('rev') + ']';
         }
     });
 
-    var PageDiffView = Backbone.View.extend({
-        initialize: function() {
-            var $view = this;
-            var model = new PageDiffModel({ path: this.id, rev1: this.options.rev1, rev2: this.options.rev2 });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('diff-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        document.title = model.title() + ' [' + model.get('rev1') + '-' + model.get('rev2') + ']';
-                    });
-                }
-            });
+    var PageDiffView = MasterPageView.extend({
+        templateName: 'diff-page',
+        titleFormat: function(title) {
+            return title + ' [' + this.model.get('rev1') + '-' + this.model.get('rev2') + ']';
         }
     });
 
-    var IncomingLinksView = Backbone.View.extend({
-        initialize: function() {
-            var $view = this;
-            var model = new IncomingLinksModel({ path: this.id });
-            model.fetch({
-                success: function(model, response, options) {
-                    TemplateLoader.get('inlinks-page', function(src) {
-                        var template_data = model.toJSON();
-                        var template = Handlebars.compile(src);
-                        $view.$el.html(template(template_data));
-                        document.title = 'Incoming Links: ' + model.title();
-                    });
-                },
-                error: function() {
-                }
-            });
-            return this;
+    var IncomingLinksView = MasterPageView.extend({
+        templateName: 'inlinks-page',
+        titleFormat: function(title) {
+            return 'Incoming Links: ' + title;
         }
     });
 
+    var WikiSearchView = MasterPageView.extend({
+        templateName: 'search-results'
+    });
+
     /**
      * Main URL router.
      */
@@ -425,38 +545,93 @@
             'inlinks/*path':        "showIncomingLinks",
             'revision/*path/:rev':  "readPageRevision",
             'diff/c/*path/:rev':    "showDiffWithPrevious",
-            'diff/r/*path/:rev1/:rev2':"showDiff"
+            'diff/r/*path/:rev1/:rev2':"showDiff",
+            'search/:query':         "showSearchResults"
         },
         readPage: function(path) {
-            var page_view = new PageReadView({ id: path, el: $('#app') });
+            var view = new PageReadView({ 
+                el: $('#app'), 
+                model: new PageReadModel({ path: path })
+            });
+            view.model.fetch();
             this.navigate('/read/' + path);
         },
         readMainPage: function() {
             this.readPage('main-page');
         },
         editPage: function(path) {
-            var edit_view = new PageEditView({ id: path, el: $('#app') });
+            var view = new PageEditView({ 
+                el: $('#app'),
+                model: new PageEditModel({ path: path })
+            });
+            view.model.fetch();
             this.navigate('/edit/' + path);
         },
         showPageHistory: function(path) {
-            var changes_view = new PageHistoryView({ id: path, el: $('#app') });
+            var view = new PageHistoryView({
+                el: $('#app'),
+                model: new PageHistoryModel({ path: path })
+            });
+            view.model.fetch();
             this.navigate('/changes/' + path);
         },
         showIncomingLinks: function(path) {
-            var in_view = new IncomingLinksView({ id: path, el: $('#app') });
+            var view = new IncomingLinksView({
+                el: $('#app'),
+                model: new IncomingLinksModel({ path: path })
+            });
+            view.model.fetch();
             this.navigate('/inlinks/' + path);
         },
         readPageRevision: function(path, rev) {
-            var rev_view = new PageRevisionView({ id: path, rev: rev, el: $('#app') });
+            var view = new PageRevisionView({
+                el: $('#app'),
+                rev: rev, 
+                model: new PageRevisionModel({ path: path, rev: rev })
+            });
+            view.model.fetch();
             this.navigate('/revision/' + path + '/' + rev);
         },
         showDiffWithPrevious: function(path, rev) {
-            var diff_view = new PageDiffView({ id: path, rev1: rev, el: $('#app') });
+            var view = new PageDiffView({
+                el: $('#app'),
+                rev1: rev,
+                model: new PageDiffModel({ path: path, rev1: rev })
+            });
+            view.model.fetch();
             this.navigate('/diff/c/' + path + '/' + rev);
         },
         showDiff: function(path, rev1, rev2) {
-            var diff_view = new PageDiffView({ id: path, rev1: rev1, rev2: rev2, el: $('#app') });
+            var view = new PageDiffView({
+                el: $('#app'),
+                rev1: rev1,
+                rev2: rev2,
+                model: new PageDiffModel({ path: path, rev1: rev1, rev2: rev2 })
+            });
+            view.model.fetch();
             this.navigate('/diff/r/' + path + '/' + rev1 + '/' + rev2);
+        },
+        showSearchResults: function(query) {
+            if (query === '') {
+                query = this.getQueryVariable('q');
+            }
+            var view = new WikiSearchView({
+                el: $('#app'),
+                model: new WikiSearchModel()
+            });
+            view.model.execute(query);
+            this.navigate('/search/' + query);
+        },
+        getQueryVariable: function(variable) {
+            var query = window.location.search.substring(1);
+            var vars = query.split("&");
+            for (var i = 0; i < vars.length; i++) {
+                var pair = vars[i].split("=");
+                if (pair[0] == variable) {
+                    return unescape(pair[1]);
+                }
+            }
+            return false;
         }
     });
 
--- a/wikked/static/tpl/diff-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/diff-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,29 +1,15 @@
-<div class="wrapper">
-    <nav class="row">
-        <div class="span12">
-            <a href="{{url_read}}">Read</a>
-            <a href="{{url_hist}}">History</a>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>
-                {{meta.title}}
-                <span class="decorator">Diff 
-                {{#if disp_rev2}}
-                <span class="rev_id">{{disp_rev1}}</span> to <span class="rev_id">{{disp_rev2}}</span>
-                {{else}}
-                change <span class="rev_id">{{disp_rev1}}</span>
-                {{/if}}
-                </span>
-            </h1>
-            <pre><code>{{{diff}}}</code></pre>
-        </div>
-    </article>
-    <div class="row meta">
-        <div class="span12">
-            <a href="{{url_inlinks}}">Pages Linking Here</a>
-            <!-- TODO: last modified, etc. -->
-        </div>
+<article class="row">
+    <div class="page span12">
+        <h1>
+            {{meta.title}}
+            <span class="decorator">Diff:
+            {{#if disp_rev2}}
+            <span class="rev_id">{{disp_rev1}}</span> to <span class="rev_id">{{disp_rev2}}</span>
+            {{else}}
+            change <span class="rev_id">{{disp_rev1}}</span>
+            {{/if}}
+            </span>
+        </h1>
+        <pre><code>{{{diff}}}</code></pre>
     </div>
-</div>
+</article>
--- a/wikked/static/tpl/edit-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/edit-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,94 +1,87 @@
-<div class="wrapper">
-    <nav class="row">
-        <div>
-            <a href="{{url_read}}">Read</a>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>{{meta.title}} <span class="decorator">Editing</span></h1>
-            <form id="page-edit" class="page-edit row">
-                <div class="span12">
-                    <div id="wmd-button-bar"></div>
-                </div>
-                <div class="span12">
-                    <div class="wmd-input-wrapper">
-                        <textarea id="wmd-input" name="text" placeholder="Your page's contents go here...">{{content}}</textarea>
-                        <div class="wmd-input-grip"></div>
-                    </div>
+<article class="row">
+    <div class="page span12">
+        <h1>{{meta.title}} <span class="decorator">Editing</span></h1>
+        <form id="page-edit" class="page-edit row">
+            <div class="span12">
+                <div id="wmd-button-bar"></div>
+            </div>
+            <div class="span12">
+                <div class="wmd-input-wrapper">
+                    <textarea id="wmd-input" name="text" placeholder="Your page's contents go here...">{{content}}</textarea>
+                    <div class="wmd-input-grip"></div>
                 </div>
-                <div class="span12">
-                    <div class="wmd-preview-wrapper">
-                        <h3><a class="btn"><i class="icon-minus"></i></a> Preview</h3>
-                        <div id="wmd-preview" class="wmd-preview"></div>
-                    </div>
-                </div>
-                <div class="span12">
-                        <div class="controls commit-meta">
-                            <div class="control-group input-prepend">
-                                <label for="author" class="control-label add-on">Author: </label>
-                                <input type="text" name="author" class="" placeholder="{{commit_meta.author}}"></input>
-                            </div>
-                            <div class="control-group input-prepend">
-                                <label for="message" class="control-label add-on">Change Description: </label>
-                                <input type="text" name="message" class="input-xxlarge" placeholder="{{commit_meta.desc}}"></input>
-                            </div>
-                        </div>
-                </div>
-                <div class="span12">
-                    <button type="submit" class="btn btn-primary"><i class="icon-ok icon-white"></i> Save</button>
-                    <a href="{{url_read}}" class="btn">Cancel</a>
+            </div>
+            <div class="span12">
+                <div class="wmd-preview-wrapper">
+                    <h3><a class="btn"><i class="icon-minus"></i></a> Preview</h3>
+                    <div id="wmd-preview" class="wmd-preview"></div>
                 </div>
-            </form>
-        </div>
-    </article>
-    <script type="text/javascript" src="/js/pagedown/Markdown.Converter.js"></script>
-    <script type="text/javascript" src="/js/pagedown/Markdown.Sanitizer.js"></script>
-    <script type="text/javascript" src="/js/pagedown/Markdown.Editor.js"></script>
-    <script type="text/javascript">
-        (function() {
-                var formatter = _.extend(this.Wikked.PageFormatter, {});
-                var converter = new Markdown.Converter();
-                converter.hooks.chain("preConversion", function(text) {
-                    return formatter.formatText(text);
-                });
-                
-                //var help = function () { alert("Do you need help?"); }
-                //var options = {
-                //    helpButton: { handler: help },
-                //    strings: { quoteexample: "whatever you're quoting, put it right here" }
-                //};
-                var editor = new Markdown.Editor(converter); //TODO: pass options
-                editor.run();
-
-                var editor_control = $('textarea#wmd-input');
-                editor_control.outerWidth($('.wmd-input-wrapper').innerWidth());
+            </div>
+            <div class="span12">
+                    <div class="controls commit-meta">
+                        <div class="control-group input-prepend">
+                            <label for="author" class="control-label add-on">Author: </label>
+                            <input type="text" name="author" class="" placeholder="{{commit_meta.author}}"></input>
+                        </div>
+                        <div class="control-group input-prepend">
+                            <label for="message" class="control-label add-on">Change Description: </label>
+                            <input type="text" name="message" class="input-xxlarge" placeholder="{{commit_meta.desc}}"></input>
+                        </div>
+                    </div>
+            </div>
+            <div class="span12">
+                <button type="submit" class="btn btn-primary"><i class="icon-ok icon-white"></i> Save</button>
+                <a href="{{url_read}}" class="btn">Cancel</a>
+            </div>
+        </form>
+    </div>
+</article>
+<script type="text/javascript" src="/js/pagedown/Markdown.Converter.js"></script>
+<script type="text/javascript" src="/js/pagedown/Markdown.Sanitizer.js"></script>
+<script type="text/javascript" src="/js/pagedown/Markdown.Editor.js"></script>
+<script type="text/javascript">
+    (function() {
+            var formatter = _.extend(this.Wikked.PageFormatter, {});
+            var converter = new Markdown.Converter();
+            converter.hooks.chain("preConversion", function(text) {
+                return formatter.formatText(text);
+            });
+            
+            //var help = function () { alert("Do you need help?"); }
+            //var options = {
+            //    helpButton: { handler: help },
+            //    strings: { quoteexample: "whatever you're quoting, put it right here" }
+            //};
+            var editor = new Markdown.Editor(converter); //TODO: pass options
+            editor.run();
 
-                var last_pageY;
-                $(".wmd-input-grip")
-                    .mousedown(function(e) {
+            var editor_control = $('textarea#wmd-input');
+            editor_control.outerWidth($('.wmd-input-wrapper').innerWidth());
+
+            var last_pageY;
+            $(".wmd-input-grip")
+                .mousedown(function(e) {
+                    last_pageY = e.pageY;
+                    $('body')
+                        .on('mousemove.wikked.editor_resize', function(e) {
+                            editor_control.height(editor_control.height() + e.pageY - last_pageY);
                             last_pageY = e.pageY;
-                            $('body')
-                                .on('mousemove.wikked.editor_resize', function(e) {
-                                        editor_control.height(editor_control.height() + e.pageY - last_pageY);
-                                        last_pageY = e.pageY;
-                                    })
-                                .on('mouseup.wikked.editor_resize mouseleave.wikked.editor_resize', function(e) { 
-                                        $('body').off('.wikked.editor_resize');
-                                    });
+                        })
+                        .on('mouseup.wikked.editor_resize mouseleave.wikked.editor_resize', function(e) { 
+                            $('body').off('.wikked.editor_resize');
                         });
-                $('.wmd-preview-wrapper>h3>a').on('click', function(e) {
-                        $('#wmd-preview').fadeToggle(function() {
-                                var icon = $('.wmd-preview-wrapper>h3>a i');
-                                if (icon.hasClass('icon-minus')) {
-                                    icon.removeClass('icon-minus');
-                                    icon.addClass('icon-plus');
-                                } else {
-                                    icon.removeClass('icon-plus');
-                                    icon.addClass('icon-minus');
-                                }
-                            });
-                    });
-            })();
-    </script>
-</div>
+                });
+            $('.wmd-preview-wrapper>h3>a').on('click', function(e) {
+                $('#wmd-preview').fadeToggle(function() {
+                    var icon = $('.wmd-preview-wrapper>h3>a i');
+                    if (icon.hasClass('icon-minus')) {
+                        icon.removeClass('icon-minus');
+                        icon.addClass('icon-plus');
+                    } else {
+                        icon.removeClass('icon-plus');
+                        icon.addClass('icon-minus');
+                    }
+                });
+            });
+        })();
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/static/tpl/footer.html	Sat Dec 29 18:21:44 2012 -0800
@@ -0,0 +1,8 @@
+<div class="row meta">
+    <div class="span12">
+        {{#each url_extras}}
+            <a href="{{url}}">{{name}}</a>
+        {{/each}}
+        <!-- TODO: last modified, etc. -->
+    </div>
+</div>
--- a/wikked/static/tpl/history-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/history-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,43 +1,34 @@
-<div class="wrapper">
-    <nav class="row">
-        <div class="span12">
-            <a href="{{url_read}}">Read</a>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>{{meta.title}} <span class="decorator">History</span></h1>
-            <p>Here's the revision log for <a href="{{url_read}}">{{meta.title}}</a>.</p>
-            <form id="diff-page">
-                <table class="table table-hover">
-                    <thead>
-                        <tr>
-                            <th>Rev.</th>
-                            <th>Date</th>
-                            <th>Author</th>
-                            <th>Comment</th>
-                            <th><button id="diff-revs" class="btn btn-primary">Show Diff.</button></th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {{#eachr history}}
-                        <tr>
-                            <td><a href="{{../url_rev}}/{{rev_hash}}">{{index}}</a></td>
-                            <td>{{timestamp}}</td>
-                            <td>{{author}}</td>
-                            <td>{{description}}</td>
-                            <td>
-                                <input type="radio" name="rev1" value="{{rev_hash}}" {{#ifeq @index to=0 }}checked="true" {{/ifeq}}/>
-                                <input type="radio" name="rev2" value="{{rev_hash}}" {{#ifeq @index to=1 }}checked="true" {{/ifeq}}/>
-                                <small><a href="{{../url_diffc}}/{{rev_hash}}">with previous</a></small>
-                            </td>
-                        </tr>
-                        {{/eachr}}
-                    </tbody>
-                </table>
-            </form>
-        </div>
-    </article>
-    <div class="row meta">
+<article class="row">
+    <div class="page span12">
+        <h1>{{meta.title}} <span class="decorator">History</span></h1>
+        <p>Here's the revision log for <a href="{{url_read}}">{{meta.title}}</a>.</p>
+        <form id="diff-page">
+            <table class="table table-hover">
+                <thead>
+                    <tr>
+                        <th>Rev.</th>
+                        <th>Date</th>
+                        <th>Author</th>
+                        <th>Comment</th>
+                        <th><button id="diff-revs" class="btn btn-primary">Show Diff.</button></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {{#eachr history}}
+                    <tr>
+                        <td><a href="{{../url_rev}}/{{rev_hash}}">{{index}}</a></td>
+                        <td>{{timestamp}}</td>
+                        <td>{{author}}</td>
+                        <td>{{description}}</td>
+                        <td>
+                            <input type="radio" name="rev1" value="{{rev_hash}}" {{#ifeq @index to=0 }}checked="true" {{/ifeq}}/>
+                            <input type="radio" name="rev2" value="{{rev_hash}}" {{#ifeq @index to=1 }}checked="true" {{/ifeq}}/>
+                            <small><a href="{{../url_diffc}}/{{rev_hash}}">with previous</a></small>
+                        </td>
+                    </tr>
+                    {{/eachr}}
+                </tbody>
+            </table>
+        </form>
     </div>
-</div>
+</article>
--- a/wikked/static/tpl/inlinks-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/inlinks-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,24 +1,17 @@
-<div class="wrapper">
-    <nav class="row">
-        <div class="span12">
-            <a href="{{url_read}}">Read</a>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>{{meta.title}} <span class="decorator">Incoming Links</span></h1>
-            <p>The following pages link to <a href="{{url_read}}">{{meta.title}}</a>:</p>
-            <ul>
-            {{#each in_links}}
-                <li>
-                    {{#if missing}}
-                    <a class="wiki-link missing" href="/#/edit/{{url}}">{{url}}</a>
-                    {{else}}
-                    <a class="wiki-link" href="/#/read/{{url}}">{{meta.title}}</a>
-                    {{/if}}
-                </li>
-            {{/each}}
-            </ul>
-        </div>
-    </article>
-</div>
+<article class="row">
+    <div class="page span12">
+        <h1>{{meta.title}} <span class="decorator">Incoming Links</span></h1>
+        <p>The following pages link to <a href="{{url_read}}">{{meta.title}}</a>:</p>
+        <ul>
+        {{#each in_links}}
+            <li>
+                {{#if missing}}
+                <a class="wiki-link missing" href="/#/edit/{{url}}">{{url}}</a>
+                {{else}}
+                <a class="wiki-link" href="/#/read/{{url}}">{{meta.title}}</a>
+                {{/if}}
+            </li>
+        {{/each}}
+        </ul>
+    </div>
+</article>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/static/tpl/nav.html	Sat Dec 29 18:21:44 2012 -0800
@@ -0,0 +1,11 @@
+<nav class="row">
+    <div class="span12">
+        {{#ifneq action to='read'}}<a href="{{url_read}}">Read</a>{{/ifneq}}
+        {{#ifneq action to='edit'}}<a href="{{url_edit}}">Edit</a>{{/ifneq}}
+        {{#ifneq action to='history'}}<a href="{{url_hist}}">History</a>{{/ifneq}}
+        <form id="search" class="search">
+            <input type="text" name="q" class="input-medium search-query" placeholder="Search...">
+            <button type="submit" class="btn">Search</button>
+        </form>
+    </div>
+</nav>
--- a/wikked/static/tpl/read-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/read-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,22 +1,6 @@
-<div class="wrapper">
-    <nav class="row">
-        <div class="span12">
-            <a href="{{url_home}}">Home</a>
-            <a href="{{url_edit}}">Edit</a>
-            <a href="{{url_hist}}">History</a>
-            <span>Search</span>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>{{meta.title}}</h1>
-            {{content}}
-        </div>
-    </article>
-    <div class="row meta">
-        <div class="span12">
-            <a href="{{url_inlinks}}">Pages Linking Here</a>
-            <!-- TODO: last modified, etc. -->
-        </div>
+<article class="row">
+    <div class="page span12">
+        <h1>{{meta.title}}</h1>
+        {{content}}
     </div>
-</div>
+</article>
--- a/wikked/static/tpl/revision-page.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/static/tpl/revision-page.html	Sat Dec 29 18:21:44 2012 -0800
@@ -1,20 +1,6 @@
-<div class="wrapper">
-    <nav class="row">
-        <div class="span12">
-            <a href="{{url_read}}">Read</a>
-            <a href="{{url_hist}}">History</a>
-        </div>
-    </nav>
-    <article class="row">
-        <div class="page span12">
-            <h1>{{meta.title}} <span class="decorator">Revision <span class="rev_id">{{disp_rev}}</span></span></h1>
-            <pre><code>{{text}}</code></pre>
-        </div>
-    </article>
-    <div class="meta row">
-        <div class="span12">
-            <a href="{{url_inlinks}}">Pages Linking Here</a>
-            <!-- TODO: last modified, etc. -->
-        </div>
+<article class="row">
+    <div class="page span12">
+        <h1>{{meta.title}} <span class="decorator">Revision: <span class="rev_id">{{disp_rev}}</span></span></h1>
+        <pre><code>{{text}}</code></pre>
     </div>
-</div>
+</article>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/static/tpl/search-results.html	Sat Dec 29 18:21:44 2012 -0800
@@ -0,0 +1,10 @@
+<article class="row">
+    <div class="page span12">
+        <h1>Search Results</h1>
+        <ul class="search-results">
+            {{#each hits}}
+            <li><a href="/#/read/{{url}}">{{title}}</a></li>
+            {{/each}}
+        </ul>
+    </div>
+</article>
--- a/wikked/templates/index.html	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/templates/index.html	Sat Dec 29 18:21:44 2012 -0800
@@ -8,7 +8,8 @@
         <link rel="stylesheet" type="text/css" href="/jquery-ui/css/smoothness/jquery-ui-1.9.2.min.css" />
     </head>
     <body>
-        <div id="app" class="container">{% block app %}{% endblock %}</div>
+        <div id="app" class="container">
+        </div>
         <script src="/js/jquery-1.8.3.min.js"></script>
         <script src="/js/underscore-min.js"></script>
         <script src="/js/backbone-min.js"></script>
--- a/wikked/views.py	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/views.py	Sat Dec 29 18:21:44 2012 -0800
@@ -35,6 +35,16 @@
     return render_template('index.html', cache_bust=('?%d' % time.time()))
 
 
+@app.route('/read/<path:url>')
+def read():
+    return render_template('index.html', cache_bust=('?%d' % time.time()))
+
+
+@app.route('/search')
+def search():
+    return render_template('index.html', cache_bust=('?%d' % time.time()))
+
+
 @app.route('/api/list')
 def api_list_all_pages():
     return list_pages(None)
@@ -84,7 +94,7 @@
         formatter = get_formatter_by_name('html')
         diff = highlight(diff, lexer, formatter)
     if rev2 is None:
-        meta = dict(page.all_meta, change=rev)
+        meta = dict(page.all_meta, change=rev1)
     else:
         meta = dict(page.all_meta, rev1=rev1, rev2=rev2)
     result = { 'path': url, 'meta': meta, 'diff': diff }
@@ -207,3 +217,10 @@
     result = { 'url': url, 'meta': page.all_meta, 'history': hist_data }
     return jsonify(result)
 
+@app.route('/api/search')
+def api_search():
+    query = request.args.get('q')
+    hits = wiki.index.search(query)
+    result = { 'query': query, 'hits': hits }
+    return jsonify(result)
+
--- a/wikked/wiki.py	Sat Dec 22 22:33:11 2012 -0800
+++ b/wikked/wiki.py	Sat Dec 29 18:21:44 2012 -0800
@@ -8,6 +8,7 @@
 from fs import FileSystem
 from cache import Cache
 from scm import MercurialSourceControl
+from indexer import WhooshWikiIndex
 
 
 class FormatterNotFound(Exception):
@@ -73,7 +74,7 @@
         return text
 
     def _formatWikiLink(self, ctx, display, url):
-        slug = re.sub(r'[^A-Za-z0-9_\.\-\(\)/]+', '-', url.lower())
+        slug = Page.title_to_url(url)
         ctx.out_links.append(slug)
 
         css_class = 'wiki-link'
@@ -188,6 +189,10 @@
             self.wiki.logger.debug("Updated cached %s for page '%s'." % (cache_key, self.url))
             self.wiki.cache.write(cache_key, data)
 
+    @staticmethod
+    def title_to_url(title):
+        return re.sub(r'[^A-Za-z0-9_\.\-\(\)/]+', '-', title.lower())
+
 
 class Wiki(object):
     def __init__(self, root=None, logger=None):
@@ -202,17 +207,23 @@
         self.fs = FileSystem(root)
         self.scm = MercurialSourceControl(root, self.logger)
         self.cache = None #Cache(os.path.join(root, '.cache'))
+        self.index = WhooshWikiIndex(os.path.join(root, '.index'), logger=self.logger)
 
         if self.cache is not None:
             self.fs.excluded.append(self.cache.cache_dir)
         if self.scm is not None:
             self.fs.excluded += self.scm.getSpecialDirs()
+        if self.index is not None:
+            self.fs.excluded.append(self.index.store_dir)
 
         self.formatters = {
                 markdown.markdown: [ 'md', 'mdown', 'markdown' ],
                 self._passthrough: [ 'txt', 'text', 'html' ]
                 }
 
+        if self.index is not None:
+            self.index.update(self.getPages())
+
     @property
     def root(self):
         return self.fs.root
@@ -249,6 +260,9 @@
                     }
             self.scm.commit([ path ], commit_meta)
 
+        if self.index is not None:
+            self.index.update([ self.getPage(url) ])
+
     def pageExists(self, url):
         return self.fs.pageExists(url)