Mercurial > qqw
changeset 76:e37662876b51
Update hatta.py and add documentation and license.
| author | Radomir Dopieralski <sheep-devel@sheep.art.pl> |
|---|---|
| date | Sun, 24 Feb 2013 16:54:54 +0100 |
| parents | d44dd192eb12 |
| children | e2865a7e11aa |
| files | LICENSE.txt README.txt hatta.py requirements.txt |
| diffstat | 4 files changed, 320 insertions(+), 287 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/LICENSE.txt Sun Feb 24 16:54:54 2013 +0100 @@ -0,0 +1,1 @@ +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/3.0/.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README.txt Sun Feb 24 16:54:54 2013 +0100 @@ -0,0 +1,45 @@ +What is this? +============= + +QQW is an experiment at merging an adventure video game with a wiki website. +The players can walk around the maps and interact with them like in a game, +but at the same time they can edit the maps and create new ones, extending the +game's world and adding their own pieces of the story. + + +How is it made? +=============== + +There are two parts to the experiment. The server-side is just a wiki engine +-- I have chosen to use my own wiki engine, Hatta, because I'm familiar with +it and it has some nice features. In theory, any wiki engine could be used, as +long as you could make it generate the right kind of HTML output for the map +pages. + +For simplicity, the maps are just text files in format based in Windows INI +files. This format is relatively easy to edit by hand while giving quite a bit +of flexibility to the author. The exact syntax and options available are +documented at the sample wiki at http://qqw.sheep.art.pl. + +The client-side part is just a couple of hundreds of lines of JavaScript code, +to render the map graphically using browser's DOM and to animate the sprites. +It was one of my assumptions that there should be nothing technically +sophisticated in there, only very basic stuff is used -- this way the whole +thing should even work on Microsoft Internet Explorer 6 (although I didn't +test it on that browser). The position of the player (the entrance to the map) +is stored as an URL fragment, and the list of items in the player character's +inventory is stored in a cookie -- so that the items can be used outside the +map on which they were acquired. No other state is kept. + + +How to run your own? +==================== + +First you will need to have Python and the required Python modules, Werkzeug +and Mercurial, installed -- you can get Python from http://python.org and +install the requirements with ``pip install -r requirements.txt``. Once you do +that, you can run the server with ``python qqw.py`` -- then just visit +http://localhost:8080/ and make some map pages. It's also possible to run +``qqw.py`` as a WSGI application -- that's how it is deployed on +http://qqw.sheep.art.pl. +
--- a/hatta.py Sun Feb 17 20:53:59 2013 +0100 +++ b/hatta.py Sun Feb 24 16:54:54 2013 +0100 @@ -39,6 +39,7 @@ import werkzeug import werkzeug.exceptions import werkzeug.routing +import werkzeug.contrib.atom try: import Image @@ -64,15 +65,16 @@ import mercurial.util import mercurial.hgweb -__version__ = '1.3.3dev' -name = 'Hatta' -url = 'http://hatta-wiki.org/' -description = 'Wiki engine that lives in Mercurial repository.' +__version__ = '1.4.5' +project_name = 'Hatta' +project_url = 'http://hatta-wiki.org/' +project_description = 'Wiki engine that lives in Mercurial repository.' mimetypes.add_type('application/x-python', '.wsgi') mimetypes.add_type('application/x-javascript', '.js') mimetypes.add_type('text/x-rst', '.rst') +_ = lambda x: x # Later replaced with gettext initialization def external_link(addr): """ @@ -238,6 +240,8 @@ add('-D', '--subdirectories', dest='subdirectories', action="store_true", help='Store subpages as subdirectories in the filesystem') + add('-U', '--unix-eol', dest='unix_eol', action="store_true", + help='Convert all text pages to UNIX-style CR newlines') options, args = parser.parse_args() for option, value in options.__dict__.iteritems(): @@ -347,7 +351,7 @@ change history, using Mercurial repository as the storage method. """ - def __init__(self, path, charset=None): + def __init__(self, path, charset=None, unix_eol=False): """ Takes the path to the directory where the pages are to be kept. If the directory doen't exist, it will be created. If it's inside @@ -356,6 +360,7 @@ """ self.charset = charset or 'utf-8' + self.unix_eol = unix_eol self.path = os.path.abspath(path) if not os.path.exists(self.path): os.makedirs(self.path) @@ -464,18 +469,31 @@ partial = lambda filename: repo_file == filename try: - unresolved = mercurial.merge.update(self.repo, tip_node, - True, True, partial) + mercurial.merge.update(self.repo, tip_node, True, True, partial) msg = _(u'merge of edit conflict') except mercurial.util.Abort: - unresolved = 1, 1, 1, 1 msg = _(u'failed merge of edit conflict') self.repo.dirstate.setparents(tip_node, node) # Mercurial 1.1 and later need updating the merge state try: - mercurial.merge.mergestate(self.repo).mark(repo_file, "r") - except (AttributeError, KeyError): + mergestate = mercurial.merge.mergestate + except AttributeError: pass + else: + state = mergestate(self.repo) + try: + state.mark(repo_file, "r") + except KeyError: + # There were no conflicts to mark + pass + else: + # Mercurial 1.7+ needs a commit + try: + commit = state.commit + except AttributeError: + pass + else: + commit() return msg @locked_repo @@ -490,10 +508,16 @@ mercurial.util.rename(file_name, file_path) changectx = self._changectx() try: + # Mercurial 1.5 and earlier have .add() on the repo + add = self.repo.add + except AttributeError: + # Mercurial 1.6 + add = self.repo[None].add + try: filectx_tip = changectx[repo_file] current_page_rev = filectx_tip.filerev() except mercurial.revlog.LookupError: - self.repo.add([repo_file]) + add([repo_file]) current_page_rev = -1 if parent is not None and current_page_rev != parent: msg = self.merge_changes(changectx, repo_file, text, user, parent) @@ -537,6 +561,8 @@ """Save text as specified page, encoded to charset.""" data = text.encode(self.charset) + if self.unix_eol: + data = data.replace('\r\n', '\n') self.save_data(title, data, author, comment, parent) def page_text(self, title): @@ -558,10 +584,20 @@ file_path = self._file_path(title) self._check_path(file_path) try: + # Mercurial 1.5 and earlier have .remove() on the repo + remove = self.repo.remove + except AttributeError: + # Mercurial 1.6 and later + try: + remove = self.repo[None].remove + except AttributeError: + # Mercurial 1.9 and later + remove = self.repo[None].forget + try: os.unlink(file_path) except OSError: pass - self.repo.remove([repo_file]) + remove([repo_file]) self._commit([repo_file], text, user) def open_page(self, title): @@ -619,16 +655,17 @@ """Find the last revision in which the file existed.""" repo_file = self._title_to_file(title) - changectx = self._changectx() - stack = [changectx] - while repo_file not in changectx: - if not stack: + stack = [self._changectx()] + while stack: + changectx = stack.pop() + if repo_file in changectx: + return changectx[repo_file] + if changectx.rev() == 0: return None - changectx = stack.pop() for parent in changectx.parents(): if parent != changectx: stack.append(parent) - return changectx[repo_file] + return None def page_history(self, title): """Iterate over the page's history.""" @@ -690,7 +727,9 @@ """Iterate over the titles of all pages in the wiki.""" for filename in os.listdir(self.path): - if (os.path.isfile(os.path.join(self.path, filename)) + file_path = os.path.join(self.path, filename) + if (os.path.isfile(file_path) + and not os.path.islink(file_path) and not filename.startswith('.')): yield werkzeug.url_unquote(filename) @@ -731,6 +770,7 @@ path = os.path.join(self.repo_prefix, escaped) return path + @locked_repo def save_file(self, title, file_name, author=u'', comment=u'', parent=None): """ Save the file and make the subdirectories if needed. @@ -750,6 +790,7 @@ super(WikiSubdirectoryStorage, self).save_file(title, file_name, author, comment, parent) + @locked_repo def delete_page(self, title, author=u'', comment=u''): """ Remove empty directories after deleting a page. @@ -762,7 +803,8 @@ file_path = self._file_path(title) self._check_path(file_path) dir_path = os.path.dirname(file_path) - os.removedirs(dir_path) + if dir_path != self.repo_path: + os.removedirs(dir_path) def all_pages(self): """Iterate over the titles of all pages in the wiki. Include subdirectories.""" @@ -802,6 +844,7 @@ # "name": (priority, ur"pattern"), "bullets": (10, bullets_pat), "code": (20, ur"^[{][{][{]+\s*$"), + "math": (25, ur"^[$][$]\s*$"), "conflict": (30, ur"^<<<<<<< local\s*$"), "empty": (40, ur"^\s*$"), "heading": (50, heading_pat), @@ -886,6 +929,7 @@ for pat in patterns), re.U) self.code_close_re = re.compile(ur"^\}\}\}\s*$", re.U) self.macro_close_re = re.compile(ur"^>>\s*$", re.U) + self.math_close_re = re.compile(ur"^[$][$]\s*$", re.U) self.conflict_close_re = re.compile(ur"^>>>>>>> other\s*$", re.U) self.conflict_sep_re = re.compile(ur"^=======\s*$", re.U) self.image_re = re.compile(self.image_pat, re.U) @@ -1075,6 +1119,12 @@ yield werkzeug.html.pre(werkzeug.html(inside), class_="code", id="line_%d" % self.line_no) + def _block_math(self, block): + for self.line_no, part in block: + inside = u"\n".join(self.lines_until(self.math_close_re)) + yield werkzeug.html.pre(werkzeug.html(inside), class_="math", + id="line_%d" % self.line_no) + def _block_syntax(self, block): for self.line_no, part in block: syntax = part.lstrip('{#!').strip() @@ -1285,9 +1335,9 @@ ur"""┠┯┨┷┿┝┰┥┸╂""" ur"""ヲ-゚ぁ-ん~ーァ-ヶ""" ur"""0-9A-Za-z0-9A-Za-zΑ-Ωα-ωА-я]+""", re.UNICODE) - _con = {} def __init__(self, cache_path, lang, storage): + self._con = {} self.path = cache_path self.storage = storage self.lang = lang @@ -1302,17 +1352,17 @@ else: self.empty = False con = self.con # sqlite3.connect(self.filename) - self.con.execute('CREATE TABLE IF NOT EXISTS titles ' + con.execute('CREATE TABLE IF NOT EXISTS titles ' '(id INTEGER PRIMARY KEY, title VARCHAR);') - self.con.execute('CREATE TABLE IF NOT EXISTS words ' + con.execute('CREATE TABLE IF NOT EXISTS words ' '(word VARCHAR, page INTEGER, count INTEGER);') - self.con.execute('CREATE INDEX IF NOT EXISTS index1 ' + con.execute('CREATE INDEX IF NOT EXISTS index1 ' 'ON words (page);') - self.con.execute('CREATE INDEX IF NOT EXISTS index2 ' + con.execute('CREATE INDEX IF NOT EXISTS index2 ' 'ON words (word);') - self.con.execute('CREATE TABLE IF NOT EXISTS links ' + con.execute('CREATE TABLE IF NOT EXISTS links ' '(src INTEGER, target INTEGER, label VARCHAR, number INTEGER);') - self.con.commit() + con.commit() self.stop_words_re = re.compile(u'^('+u'|'.join(re.escape(_( u"""am ii iii per po re a about above across after afterwards again against all almost alone along already also @@ -1353,7 +1403,6 @@ return self._con[thread_id] except KeyError: connection = sqlite3.connect(self.filename) - connection.isolation_level = None self._con[thread_id] = connection return connection @@ -1396,11 +1445,13 @@ def update_words(self, title, text, cursor): title_id = self.title_id(title, cursor) + cursor.execute('DELETE FROM words WHERE page=?;', (title_id,)) + if not text: + return words = self.count_words(self.split_text(text)) title_words = self.count_words(self.split_text(title)) for word, count in title_words.iteritems(): words[word] = words.get(word, 0) + count - cursor.execute('DELETE FROM words WHERE page=?;', (title_id,)) for word, count in words.iteritems(): cursor.execute('INSERT INTO words VALUES (?, ?, ?);', (word, title_id, count)) @@ -1530,11 +1581,17 @@ try: text = get_text() except NotFoundErr: - text = u'' - extract_links = getattr(page, 'extract_links', lambda x: []) - if text: - self.update_links(title, extract_links(text), cursor=cursor) - self.update_words(title, text, cursor=cursor) + text = None + title_id = self.title_id(title, cursor) + if not list(self.page_backlinks(title)): + cursor.execute("DELETE FROM titles WHERE id=?;", (title_id,)) + extract_links = getattr(page, 'extract_links', None) + if extract_links and text: + links = extract_links(text) + else: + links = [] + self.update_links(title, links, cursor=cursor) + self.update_words(title, text or u'', cursor=cursor) def update_page(self, page, title, data=None, text=None): """Updates the index with new page content, for a single page.""" @@ -1542,28 +1599,26 @@ if text is None and data is not None: text = unicode(data, self.storage.charset, 'replace') cursor = self.con.cursor() - cursor.execute('BEGIN IMMEDIATE TRANSACTION;') try: self.set_last_revision(self.storage.repo_revision()) self.reindex_page(page, title, cursor, text) - cursor.execute('COMMIT TRANSACTION;') + self.con.commit() except: - cursor.execute('ROLLBACK;') + self.con.rollback() raise def reindex(self, wiki, request, pages): """Updates specified pages in bulk.""" cursor = self.con.cursor() - cursor.execute('BEGIN IMMEDIATE TRANSACTION;') try: for title in pages: page = wiki.get_page(request, title) self.reindex_page(page, title, cursor) - cursor.execute('COMMIT TRANSACTION;') + self.con.commit() self.empty = False except: - cursor.execute('ROLLBACK;') + self.con.rollback() raise def set_last_revision(self, rev): @@ -1606,19 +1661,19 @@ self.response = [] try: del self.content_type - except AttributeError: + except (AttributeError, KeyError, IndexError): pass try: del self.content_length - except AttributeError: + except (AttributeError, KeyError, IndexError): pass try: del self.headers['Content-length'] - except (KeyError, IndexError): + except (AttributeError, KeyError, IndexError): pass try: del self.headers['Content-type'] - except (KeyError, IndexError): + except (AttributeError, KeyError, IndexError): pass return ret @@ -1756,32 +1811,36 @@ else: classes = [] if external_link(addr): + classes.append('external') if addr.startswith('mailto:'): - class_ = 'external email' + classes.append('email') text = text.replace('@', '@').replace('.', '.') - href = addr.replace('@', '%40').replace('.', '%2E') + href = werkzeug.escape(addr, + True).replace('@', + '%40').replace('.', '%2E') else: - classes.append('external') - href = werkzeug.escape(addr, quote=True) + href = werkzeug.escape(werkzeug.url_fix(addr), quote=True) else: if '#' in addr: addr, chunk = addr.split('#', 1) - chunk = '#'+chunk + chunk = '#' + werkzeug.url_fix(chunk) if addr.startswith('+'): href = '/'.join([self.request.script_root, '+'+werkzeug.escape(addr[1:], quote=True)]) classes.append('special') elif addr == u'': - href = chunk + href = werkzeug.escape(chunk, True) classes.append('anchor') else: classes.append('wiki') - href = self.get_url(addr) + chunk + href = werkzeug.escape(self.get_url(addr) + chunk, True) if addr not in self.storage: classes.append('nonexistent') - class_ = ' '.join(classes) or None - return werkzeug.html.a(image or text, href=href, class_=class_, - title=addr+chunk) + class_ = werkzeug.escape(' '.join(classes) or '', True) + # We need to output HTML on our own to prevent escaping of href + return '<a href="%s" class="%s" title="%s">%s</a>' % ( + href, class_, werkzeug.escape(addr + chunk, True), + image or text) def wiki_image(self, addr, alt, class_='wiki', lineno=0): """Create HTML for a wiki image.""" @@ -1822,7 +1881,6 @@ def menu(self): """Generate the menu items""" - html = werkzeug.html if self.wiki.menu_page in self.storage: items = self.index.page_links_and_labels(self.wiki.menu_page) else: @@ -1862,9 +1920,6 @@ yield h.link(rel="shortcut icon", type_="image/x-icon", href=self.get_url(None, self.wiki.favicon_ico)) yield h.link(rel="alternate", type_="application/rss+xml", - title=e("%s (RSS)" % self.wiki.site_name), - href=self.get_url(None, self.wiki.rss)) - yield h.link(rel="alternate", type_="application/rss+xml", title=e("%s (ATOM)" % self.wiki.site_name), href=self.get_url(None, self.wiki.atom)) yield h.script(type_="text/javascript", @@ -1966,7 +2021,8 @@ button = h.input(type_="submit", name=str(rev), value=h(_(u'Undo'))) yield h.li(h.a(self.date_html(date), href=date_url), - button, ' . . . . ', h.i(self.wiki_link(author)), + button, ' . . . . ', + h.i(self.wiki_link("~%s" % author, author)), h.div(h(comment), class_="comment")) yield u'</ul>' yield h.input(type_="hidden", name="parent", value=max_rev) @@ -2070,7 +2126,6 @@ yield None diff = difflib._mdiff(from_text.split('\n'), to_text.split('\n')) - stack = [] mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])') yield message yield u'<pre class="diff">' @@ -2132,7 +2187,7 @@ formatter = pygments.formatters.HtmlFormatter() formatter.line_no = line_no - def wrapper(source, outfile): + def wrapper(source, unused_outfile): """Wrap each line of formatted output.""" yield 0, '<div class="highlight"><pre>' @@ -2310,7 +2365,7 @@ for cell in row)) except csv.Error, e: yield u'</table>' - yield werkzeug.p(werkzeug.html( + yield werkzeug.html.p(werkzeug.html( _(u'Error parsing CSV file %{file}s on line %{line}d: %{error}s') % {'file': html_title, 'line': reader.line_num, 'error': e})) finally: @@ -2364,9 +2419,9 @@ if in_header: if in_bug: yield '</div>' - tags = [tag.strip() for tag in - attributes.get('tags', '').split() - if tag.strip()] + #tags = [tag.strip() for tag in + # attributes.get('tags', '').split() + # if tag.strip()] yield '<div id="line_%d">' % (line_no) in_bug = True if title: @@ -2401,19 +2456,48 @@ class WikiTitleConverter(werkzeug.routing.PathConverter): - """Behaves like the path converter, except that it escapes slashes.""" + """Behaves like the path converter, but doesn't match the "+ pages".""" def to_url(self, value): - return werkzeug.url_quote(value.strip(), self.map.charset, safe="") + return werkzeug.url_quote(value.strip(), self.map.charset, safe="/") regex='([^+%]|%[^2]|%2[^Bb]).*' + class WikiAllConverter(werkzeug.routing.BaseConverter): """Matches everything.""" regex='.*' +class URL(object): + """A decorator for marking methods as endpoints for URLs.""" + + urls = [] + + def __init__(self, url, methods=None): + """Create a decorator with specified parameters.""" + + self.url = url + self.methods = methods or ['GET', 'HEAD'] + + def __call__(self, func): + """The actual decorator only records the data.""" + + self.urls.append((func.__name__, self.url, self.methods)) + return func + + @classmethod + def rules(cls, app): + """Returns the routing rules, using app's bound methods.""" + + for name, url, methods in cls.urls: + func = getattr(app, name, None) + if not callable(func): + continue + yield werkzeug.routing.Rule(url, endpoint=func, methods=methods) + + class Wiki(object): """ The main class of the wiki, handling initialization of the whole @@ -2564,65 +2648,37 @@ self.icon_page = self.config.get('icon_page', None) self.pygments_style = self.config.get('pygments_style', 'tango') self.subdirectories = self.config.get_bool('subdirectories', False) + self.unix_eol = self.config.get_bool('unix_eol', False) if self.subdirectories: - self.storage = WikiSubdirectoryStorage(self.path, self.page_charset) + self.storage = WikiSubdirectoryStorage(self.path, self.page_charset, + unix_eol=self.unix_eol) else: - self.storage = self.storage_class(self.path, self.page_charset) + self.storage = self.storage_class(self.path, self.page_charset, + unix_eol=self.unix_eol) self.cache = config.get('cache_path', None) if self.cache is None: self.cache = os.path.join(self.storage.repo_path, '.hg', 'hatta', 'cache') self.cache = os.path.abspath(self.cache) if not os.path.isdir(self.cache): os.makedirs(self.cache) - reindex = True - else: - reindex = False +# reindex = True +# else: +# reindex = False self.index = self.index_class(self.cache, self.language, self.storage) - R = werkzeug.routing.Rule - self.url_map = werkzeug.routing.Map([ - R('/', defaults={'title': self.front_page}, - endpoint=self.view, methods=['GET', 'HEAD']), - R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']), - R('/+edit/<title:title>', endpoint=self.save, methods=['POST']), - R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']), - R('/+history/', endpoint=self.recent_changes, - methods=['GET', 'HEAD']), - R('/+history/<title:title>/<int:from_rev>:<int:to_rev>', - endpoint=self.diff, methods=['GET', 'HEAD']), - R('/+history/<title:title>/<int:rev>', endpoint=self.revision, - methods=['GET', 'HEAD']), - R('/+history/<title:title>', endpoint=self.history, - methods=['GET', 'HEAD']), - R('/+version/', endpoint=self.version, methods=['GET', 'HEAD']), - R('/+version/<title:title>', endpoint=self.version, - methods=['GET', 'HEAD']), - R('/+download/<title:title>', endpoint=self.download, - methods=['GET', 'HEAD']), - R('/+render/<title:title>', endpoint=self.render, - methods=['GET', 'HEAD']), - R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']), - R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']), - R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']), - R('/+index', endpoint=self.all_pages, methods=['GET', 'HEAD']), - R('/+orphaned', endpoint=self.orphaned, methods=['GET', 'HEAD']), - R('/+wanted', endpoint=self.wanted, methods=['GET', 'HEAD']), - R('/+search', endpoint=self.search, methods=['GET', 'POST']), - R('/+search/<title:title>', endpoint=self.backlinks, - methods=['GET', 'POST']), - R('/off-with-his-head', endpoint=self.die, methods=['GET']), - R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False, - methods=['GET', 'POST', 'HEAD']), - # Pages with default content - R('/favicon.ico', endpoint=self.favicon_ico, - methods=['GET', 'HEAD']), - R('/robots.txt', endpoint=self.robots_txt, methods=['GET', 'HEAD']), - R('/+download/style.css', endpoint=self.style_css, - methods=['GET', 'HEAD']), - R('/+download/pygments.css', endpoint=self.pygments_css, - methods=['GET', 'HEAD']), - R('/+download/scripts.js', endpoint=self.scripts_js, - methods=['GET', 'HEAD']), - ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter}) + self.url_rules = URL.rules(self) + self.url_map = werkzeug.routing.Map(self.url_rules, converters={ + 'title':WikiTitleConverter, + 'all':WikiAllConverter + }) + + def add_url_rule(self, rule): + """Let plugins add additional url rules.""" + + self.url_rules.append(rule) + self.url_map = werkzeug.routing.Map(self.url_rules, converters={ + 'title':WikiTitleConverter, + 'all':WikiAllConverter + }) def get_page(self, request, title): """Creates a page object based on page's mime type""" @@ -2654,7 +2710,53 @@ mime = '' return page_class(self, request, title, mime) - def view(self, request, title): + def response(self, request, title, content, etag='', mime='text/html', + rev=None, size=None): + """Create a WikiResponse for a page.""" + + response = WikiResponse(content, mimetype=mime) + if rev is None: + inode, _size, mtime = self.storage.page_file_meta(title) + response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title), + inode, mtime)) + if size == -1: + size = _size + else: + response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title), + rev)) + if size: + response.content_length = size + response.make_conditional(request) + return response + + def _check_lock(self, title): + restricted_pages = [ + 'scripts.js', + 'robots.txt', + ] + if self.read_only: + raise ForbiddenErr(_(u"This site is read-only.")) + if title in restricted_pages: + raise ForbiddenErr(_(u"""Can't edit this page. +It can only be edited by the site admin directly on the disk.""")) + if title in self.index.page_links(self.locked_page): + raise ForbiddenErr(_(u"This page is locked.")) + + def _serve_default(self, request, title, content, mime): + """Some pages have their default content.""" + + if title in self.storage: + return self.download(request, title) + response = WikiResponse(content, mimetype=mime) + response.set_etag('/%s/-1' % title) + response.make_conditional(request) + return response + + @URL('/<title:title>') + @URL('/') + def view(self, request, title=None): + if title is None: + title = self.front_page page = self.get_page(request, title) try: content = page.view_content() @@ -2666,6 +2768,7 @@ etag = '/(%s)' % u','.join(dependencies) return self.response(request, title, html, etag=etag) + @URL('/+history/<title:title>/<int:rev>') def revision(self, request, title, rev): text = self.storage.revision_text(title, rev) link = werkzeug.html.a(werkzeug.html(title), @@ -2683,6 +2786,8 @@ response = self.response(request, title, html, rev=rev, etag='/old') return response + @URL('/+version/') + @URL('/+version/<title:title>') def version(self, request, title=None): if title is None: version = self.storage.repo_revision() @@ -2693,19 +2798,7 @@ version = 0 return werkzeug.Response('%d' % version, mimetype="text/plain") - def _check_lock(self, title): - restricted_pages = [ - 'scripts.js', - 'robots.txt', - ] - if self.read_only: - raise ForbiddenErr(_(u"This site is read-only.")) - if title in restricted_pages: - raise ForbiddenErr(_(u"""Can't edit this page. -It can only be edited by the site admin directly on the disk.""")) - if title in self.index.page_links(self.locked_page): - raise ForbiddenErr(_(u"This page is locked.")) - + @URL('/+edit/<title:title>', methods=['POST']) def save(self, request, title): self._check_lock(title) url = request.get_url(title) @@ -2765,6 +2858,7 @@ max_age=604800) return response + @URL('/+edit/<title:title>', methods=['GET']) def edit(self, request, title, preview=None): self._check_lock(title) exists = title in self.storage @@ -2785,156 +2879,40 @@ response.headers.add('Cache-Control', 'no-cache') return response + @URL('/+feed/atom') + @URL('/+feed/rss') def atom(self, request): - date_format = "%Y-%m-%dT%H:%M:%SZ" - first_date = datetime.datetime.now() - now = first_date.strftime(date_format) - body = [] - first_title = u'' - count = 0 - unique_titles = {} - for title, rev, date, author, comment in self.storage.history(): + feed = werkzeug.contrib.atom.AtomFeed(self.site_name, + feed_url=request.url, + url=request.adapter.build(self.view, force_external=True), + subtitle=_(u'Track the most recent changes to the wiki ' + u'in this feed.')) + history = itertools.islice(self.storage.history(), None, 10, None) + unique_titles = set() + for title, rev, date, author, comment in history: if title in unique_titles: continue - unique_titles[title] = True - count += 1 - if count > 10: - break - if not first_title: - first_title = title - first_rev = rev - first_date = date - item = u"""<entry> - <title>%(title)s</title> - <link href="%(page_url)s" /> - <content>%(comment)s</content> - <updated>%(date)s</updated> - <author> - <name>%(author)s</name> - <uri>%(author_url)s</uri> - </author> - <id>%(url)s</id> -</entry>""" % { - 'title': werkzeug.escape(title), - 'page_url': request.adapter.build(self.view, {'title': title}, - force_external=True), - 'comment': werkzeug.escape(comment), - 'date': date.strftime(date_format), - 'author': werkzeug.escape(author), - 'author_url': request.adapter.build(self.view, - {'title': author}, - force_external=True), - 'url': request.adapter.build(self.revision, - {'title': title, 'rev': rev}, - force_external=True), - } - body.append(item) - content = u"""<?xml version="1.0" encoding="utf-8"?> -<feed xmlns="http://www.w3.org/2005/Atom"> - <title>%(title)s</title> - <link rel="self" href="%(atom)s"/> - <link href="%(home)s"/> - <id>%(home)s</id> - <updated>%(date)s</updated> - <logo>%(logo)s</logo> -%(body)s -</feed>""" % { - 'title': self.site_name, - 'home': request.adapter.build(self.view, force_external=True), - 'atom': request.adapter.build(self.atom, force_external=True), - 'date': first_date.strftime(date_format), - 'logo': request.adapter.build(self.download, - {'title': self.logo_page}, - force_external=True), - 'body': u''.join(body), - } - response = self.response(request, 'atom', content, '/atom', - 'application/xml', first_rev, first_date) - response.set_etag('/atom/%d' % self.storage.repo_revision()) + unique_titles.add(title) + if rev>0: + url = request.adapter.build(self.diff, { + 'title': title, + 'from_rev': rev-1, + 'to_rev': rev + }, force_external=True) + else: + url = request.adapter.build(self.revision, { + 'title': title, + 'rev': rev, + }, force_external=True) + feed.add(title, comment, content_type="text", author=author, + url=url, updated=date) + rev = self.storage.repo_revision() + response = self.response(request, 'atom', feed.generate(), '/+feed', + 'application/xml', rev) response.make_conditional(request) return response - def rss(self, request): - """Serve an RSS feed of recent changes.""" - - first_date = datetime.datetime.now() - now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT") - rss_body = [] - first_title = u'' - count = 0 - unique_titles = {} - for title, rev, date, author, comment in self.storage.history(): - if title in unique_titles: - continue - unique_titles[title] = True - count += 1 - if count > 10: - break - if not first_title: - first_title = title - first_rev = rev - first_date = date - item = (u'<item><title>%s</title><link>%s</link>' - u'<description>%s</description><pubDate>%s</pubDate>' - u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % ( - werkzeug.escape(title), - request.adapter.build(self.view, {'title': title}, - force_external=True), - werkzeug.escape(comment), - date.strftime("%a, %d %b %Y %H:%M:%S GMT"), - werkzeug.escape(author), - request.adapter.build(self.revision, - {'title': title, 'rev': rev}) - )) - rss_body.append(item) - rss_head = u"""<?xml version="1.0" encoding="utf-8"?> -<rss version="2.0" -xmlns:dc="http://purl.org/dc/elements/1.1/" -xmlns:atom="http://www.w3.org/2005/Atom" -> -<channel> - <title>%s</title> - <atom:link href="%s" rel="self" type="application/rss+xml" /> - <link>%s</link> - <description>%s</description> - <generator>Hatta Wiki</generator> - <language>en</language> - <lastBuildDate>%s</lastBuildDate> - -""" % ( - werkzeug.escape(self.site_name), - request.adapter.build(self.rss), - request.adapter.build(self.recent_changes), - werkzeug.escape(_(u'Track the most recent changes to the wiki ' - u'in this feed.')), - first_date, - ) - content = [rss_head]+rss_body+[u'</channel></rss>'] - response = self.response(request, 'rss', content, '/rss', - 'application/xml', first_rev, first_date) - response.set_etag('/rss/%d' % self.storage.repo_revision()) - response.make_conditional(request) - return response - - def response(self, request, title, content, etag='', mime='text/html', - rev=None, date=None, size=None): - """Create a WikiResponse for a page.""" - - response = WikiResponse(content, mimetype=mime) - if rev is None: - inode, _size, mtime = self.storage.page_file_meta(title) - response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title), - inode, mtime)) - if size == -1: - size = _size - else: - response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title), - rev)) - if size: - response.content_length = size - response.make_conditional(request) - return response - + @URL('/+download/<title:title>') def download(self, request, title): """Serve the raw content of a page directly from disk.""" @@ -2950,6 +2928,7 @@ response.direct_passthrough = True return response + @URL('/+render/<title:title>') def render(self, request, title): """Serve a thumbnail or otherwise rendered content.""" @@ -3014,6 +2993,7 @@ response.direct_passthrough = True return response + @URL('/+undo/<title:title>', methods=['POST']) def undo(self, request, title): """Revert a change to a page.""" @@ -3047,6 +3027,7 @@ method='GET', force_external=True) return werkzeug.redirect(url, 303) + @URL('/+history/<title:title>') def history(self, request, title): """Display history of changes of a page.""" @@ -3056,7 +3037,7 @@ response = self.response(request, title, content, '/history') return response - + @URL('/+history/') def recent_changes(self, request): """Serve the recent changes page.""" @@ -3091,7 +3072,7 @@ yield h.li(h.a(page.date_html(date), href=date_url), ' ', h.b(page.wiki_link(title)), u' . . . . ', - h.i(page.wiki_link(author)), + h.i(page.wiki_link('~%s' % author, author)), h.div(h(comment), class_="comment") ) yield u'</ul>' @@ -3103,6 +3084,7 @@ response.make_conditional(request) return response + @URL('/+history/<title:title>/<int:from_rev>:<int:to_rev>') def diff(self, request, title, from_rev, to_rev): """Show the differences between specified revisions.""" @@ -3133,6 +3115,7 @@ return response + @URL('/+index') def all_pages(self, request): """Show index of all pages in the wiki.""" @@ -3145,6 +3128,7 @@ response.make_conditional(request) return response + @URL('/+orphaned') def orphaned(self, request): """Show all pages that don't have backlinks.""" @@ -3158,6 +3142,7 @@ response.make_conditional(request) return response + @URL('/+wanted') def wanted(self, request): """Show all pages that don't exist yet, but are linked.""" @@ -3183,6 +3168,7 @@ response.make_conditional(request) return response + @URL('/+search', methods=['GET', 'POST']) def search(self, request): """Serve the search results page.""" @@ -3236,6 +3222,7 @@ html = page.render_content(content, title) return WikiResponse(html, mimetype='text/html') + @URL('/+search/<title:title>', methods=['GET', 'POST']) def backlinks(self, request, title): """Serve the page with backlinks.""" @@ -3252,27 +3239,21 @@ response.make_conditional(request) return response - def _serve_default(self, request, title, content, mime): - """Some pages have their default content.""" - - if title in self.storage: - return self.download(request, title) - response = werkzeug.Response(content, mimetype=mime) - response.set_etag('/%s/-1' % title) - response.make_conditional(request) - return response - + @URL('/+download/scripts.js') def scripts_js(self, request): """Server the default scripts""" return self._serve_default(request, 'scripts.js', self.scripts, 'text/javascript') + @URL('/+download/style.css') def style_css(self, request): """Serve the default style""" return self._serve_default(request, 'style.css', self.style, 'text/css') + + @URL('/+download/pygments.css') def pygments_css(self, request): """Serve the default pygments style""" @@ -3287,12 +3268,14 @@ return self._serve_default(request, 'pygments.css', style_defs, 'text/css') + @URL('/favicon.ico') def favicon_ico(self, request): """Serve the default favicon.""" return self._serve_default(request, 'favicon.ico', self.icon, 'image/x-icon') + @URL('/robots.txt') def robots_txt(self, request): """Serve the robots directives.""" @@ -3308,6 +3291,7 @@ return self._serve_default(request, 'robots.txt', robots, 'text/plain') + @URL('/+hg<all:path>', methods=['GET', 'POST', 'HEAD']) def hgweb(self, request, path=None): """Serve the pages repository on the web like a normal hg repository.""" @@ -3324,6 +3308,7 @@ return app(env, start) return hg_app + @URL('/off-with-his-head', methods=['GET']) def die(self, request): """Terminate the standalone server if invoked from localhost."""
