Hatta File Tree Branch

view hatta.py @ 632:607a934cc6bb

Add support for docs/path/pages (docs tree)
author Randy Selzler <RS-merc@Data-Warp.com>
date Mon Dec 21 11:08:18 2009 -0600 (2009-12-21)
parents d973b363d32e
children 9c8347beb368
line source
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
4 # @copyright: 2008-2009 Radomir Dopieralski <hatta@sheep.art.pl>
5 # @license: GNU GPL, see COPYING for details.
7 """
8 Hatta Wiki is a wiki engine designed to be used with Mercurial repositories.
9 It requires Mercurial and Werkzeug python modules.
11 Hatta's pages are just plain text files (and also images, binaries, etc.) in
12 some directory in your repository. For example, you can put it in your
13 project's "docs" directory to keep documentation. The files can be edited both
14 from the wiki or with a text editor -- in either case the changes committed to
15 the repository will appear in the recent changes and in page's history.
17 See hatta.py --help for usage.
18 """
20 import base64
21 import datetime
22 import difflib
23 import gettext
24 import itertools
25 import mimetypes
26 import os
27 import re
28 import sqlite3
29 import sys
30 import tempfile
31 import thread
32 import unicodedata
34 # Avoid WSGI errors, see http://mercurial.selenic.com/bts/issue1095
35 sys.stdout = sys.__stdout__
36 sys.stderr = sys.__stderr__
38 import werkzeug
39 import werkzeug.exceptions, werkzeug.routing
41 try:
42 import jinja2
43 # Make jinja2 roughly compatible with werkzeug.Template
44 Template = jinja2.Environment(
45 block_start_string='<%',
46 block_end_string='%>',
47 variable_start_string='${',
48 variable_end_string='}',
49 ).from_string
50 except ImportError:
51 jinja2 = None
52 import werkzeug.templates
53 Template = werkzeug.Template
55 try:
56 import Image
57 except ImportError:
58 Image = None
60 # Note: we have to set these before importing Mercurial
61 os.environ['HGENCODING'] = 'utf-8'
62 os.environ['HGMERGE'] = "internal:merge"
63 import mercurial.hg
64 import mercurial.ui
65 import mercurial.revlog
66 import mercurial.util
67 import mercurial.hgweb
69 __version__ = '1.3.3dev'
70 name = 'Hatta'
71 url = 'http://hatta-wiki.org/'
72 description = 'Wiki engine that lives in Mercurial repository.'
74 def external_link(addr):
75 """
76 Decide whether a link is absolute or internal.
78 >>> external_link('http://example.com')
79 True
80 >>> external_link('https://example.com')
81 True
82 >>> external_link('ftp://example.com')
83 True
84 >>> external_link('mailto:user@example.com')
85 True
86 >>> external_link('PageTitle')
87 False
88 >>> external_link(u'ąęśćUnicodePage')
89 False
91 """
93 return (addr.startswith('http://')
94 or addr.startswith('https://')
95 or addr.startswith('ftp://')
96 or addr.startswith('mailto:'))
99 class WikiConfig(object):
100 """
101 Responsible for reading and storing site configuration. Contains the
102 default settings.
104 >>> config = WikiConfig(port='2080')
105 >>> config.sanitize()
106 >>> config.get('port')
107 2080
108 """
110 default_filename = u'hatta.conf'
112 # Please see the bottom of the script for modifying these values.
114 def __init__(self, **kw):
115 self.config = dict(kw)
116 self.parse_environ()
118 def sanitize(self):
119 """
120 Convert options to their required types.
121 """
123 try:
124 self.config['port'] = int(self.get('port', 0))
125 except ValueError:
126 self.config['port'] = 8080
128 def parse_environ(self):
129 """Check the environment variables for options."""
131 prefix = 'HATTA_'
132 for key, value in os.environ.iteritems():
133 if key.startswith(prefix):
134 name = key[len(prefix):].lower()
135 self.config[name] = value
137 def parse_args(self):
138 """Check the commandline arguments for options."""
140 import optparse
142 self.options = []
143 parser = optparse.OptionParser()
145 def add(*args, **kw):
146 self.options.append(kw['dest'])
147 parser.add_option(*args, **kw)
149 add('-d', '--pages-dir', dest='pages_path',
150 help='Store pages in DIR', metavar='DIR')
151 add('-t', '--cache-dir', dest='cache_path',
152 help='Store cache in DIR', metavar='DIR')
153 add('-i', '--interface', dest='interface',
154 help='Listen on interface INT', metavar='INT')
155 add('-p', '--port', dest='port', type='int',
156 help='Listen on port PORT', metavar='PORT')
157 add('-s', '--script-name', dest='script_name',
158 help='Override SCRIPT_NAME to NAME', metavar='NAME')
159 add('-n', '--site-name', dest='site_name',
160 help='Set the name of the site to NAME', metavar='NAME')
161 add('-m', '--front-page', dest='front_page',
162 help='Use PAGE as the front page', metavar='PAGE')
163 add('-e', '--encoding', dest='page_charset',
164 help='Use encoding ENC to read and write pages', metavar='ENC')
165 add('-c', '--config-file', dest='config_file',
166 help='Read configuration from FILE', metavar='FILE')
167 add('-l', '--language', dest='language',
168 help='Translate interface to LANG', metavar='LANG')
169 add('-r', '--read-only', dest='read_only', default=False,
170 help='Whether the wiki should be read-only', action="store_true")
171 add('-j', '--script-page', dest='script_page', metavar="PAGE",
172 help='Include JavaScript from page PAGE.')
173 add('-g', '--icon-page', dest='icon_page', metavar="PAGE",
174 help='Read icons graphics from PAGE.')
175 add('-w', '--hgweb', dest='hgweb', default=False,
176 help='Enable hgweb access to the repository', action="store_true")
178 options, args = parser.parse_args()
179 for option, value in options.__dict__.iteritems():
180 if option in self.options:
181 if value is not None:
182 self.config[option] = value
184 def parse_files(self, files=None):
185 """Check the config files for options."""
187 import ConfigParser
189 if files is None:
190 files = [self.get('config_file', self.default_filename)]
191 parser = ConfigParser.SafeConfigParser()
192 parser.read(files)
193 for section in parser.sections():
194 for option, value in parser.items(section):
195 self.config[option] = value
197 def save_config(self, filename=None):
198 """Saves configuration to a given file."""
199 if filename is None:
200 filename = self.default_filename
202 import ConfigParser
203 parser = ConfigParser.RawConfigParser()
204 section = self.config['site_name']
205 parser.add_section(section)
206 for key, value in self.config.iteritems():
207 parser.set(section, str(key), str(value))
209 configfile = open(filename, 'wb')
210 try:
211 parser.write(configfile)
212 finally:
213 configfile.close()
215 def get(self, option, default_value=None):
216 """
217 Get the value of a config option or default if not set.
219 >>> config = WikiConfig(option=4)
220 >>> config.get("ziew", 3)
221 3
222 >>> config.get("ziew")
223 >>> config.get("ziew", "ziew")
224 'ziew'
225 >>> config.get("option")
226 4
227 """
229 return self.config.get(option, default_value)
231 def get_bool(self, option, default_value=False):
232 """
233 Like get, only convert the value to True or False.
234 """
236 value = self.get(option, default_value)
237 if value in (
238 1, True,
239 'True', 'true', 'TRUE',
240 '1',
241 'on', 'On', 'ON',
242 'yes', 'Yes', 'YES',
243 'enable', 'Enable', 'ENABLE',
244 'enabled', 'Enabled', 'ENABLED',
245 ):
246 return True
247 elif value in (
248 None, 0, False,
249 'False', 'false', 'FALSE',
250 '0',
251 'off', 'Off', 'OFF',
252 'no', 'No', 'NO',
253 'disable', 'Disable', 'DISABLE',
254 'disabled', 'Disabled', 'DISABLED',
255 ):
256 return False
257 else:
258 raise ValueError("expected boolean value")
260 def set(self, key, value):
261 self.config[key] = value
264 def locked_repo(func):
265 """A decorator for locking the repository when calling a method."""
267 def new_func(self, *args, **kwargs):
268 """Wrap the original function in locks."""
270 wlock = self.repo.wlock()
271 lock = self.repo.lock()
272 try:
273 func(self, *args, **kwargs)
274 finally:
275 lock.release()
276 wlock.release()
278 return new_func
280 class WikiStorage(object):
281 """
282 Provides means of storing wiki pages and keeping track of their
283 change history, using Mercurial repository as the storage method.
284 """
286 def __init__(self, path, charset=None):
287 """
288 Takes the path to the directory where the pages are to be kept.
289 If the directory doen't exist, it will be created. If it's inside
290 a Mercurial repository, that repository will be used, otherwise
291 a new repository will be created in it.
292 """
294 self.charset = charset or 'utf-8'
295 # RegEx matching path components that contain ".."
296 self.dot_dot_re = re.compile('(^|/)\.\.(?=(/|$))')
297 self.path = path
298 if not os.path.exists(self.path):
299 os.makedirs(self.path)
300 self.repo_path = self._find_repo_path(self.path)
301 try:
302 self.ui = mercurial.ui.ui(report_untrusted=False,
303 interactive=False, quiet=True)
304 except TypeError:
305 # Mercurial 1.3 changed the way we setup the ui object.
306 self.ui = mercurial.ui.ui()
307 self.ui.quiet = True
308 self.ui._report_untrusted = False
309 self.ui.setconfig('ui', 'interactive', False)
310 if self.repo_path is None:
311 self.repo_path = self.path
312 create = True
313 else:
314 create = False
315 self.repo_prefix = self.path[len(self.repo_path):].strip('/')
316 self.repo = mercurial.hg.repository(self.ui, self.repo_path,
317 create=create)
319 def reopen(self):
320 """Close and reopen the repo, to make sure we are up to date."""
322 self.repo = mercurial.hg.repository(self.ui, self.repo_path)
325 def _find_repo_path(self, path):
326 """Go up the directory tree looking for a repository."""
328 while not os.path.isdir(os.path.join(path, ".hg")):
329 old_path, path = path, os.path.dirname(path)
330 if path == old_path:
331 return None
332 return path
334 def _file_path(self, title):
335 if self.dot_dot_re.search(title):
336 raise werkzeug.exceptions.Forbidden(
337 u'_file_path: Link contains ".." components: %s' % title)
338 return os.path.join(self.path, werkzeug.url_quote(title, safe='/'))
340 def _title_to_file(self, title):
341 if self.dot_dot_re.search(title):
342 raise werkzeug.exceptions.Forbidden(
343 u'_title_to_file: Link contains "..": %s' % title)
344 return os.path.join(self.repo_prefix,
345 werkzeug.url_quote(title, safe='/'))
347 def _file_to_title(self, filename):
348 assert filename.startswith(self.repo_prefix)
349 name = filename[len(self.repo_prefix):].strip('/')
350 return werkzeug.url_unquote(name)
352 def __contains__(self, title):
353 if title:
354 return os.path.exists(self._file_path(title))
356 def __iter__(self):
357 return self.all_pages()
359 def merge_changes(self, changectx, repo_file, text, user, parent):
360 """Commits and merges conflicting changes in the repository."""
362 tip_node = changectx.node()
363 filectx = changectx[repo_file].filectx(parent)
364 parent_node = filectx.changectx().node()
366 self.repo.dirstate.setparents(parent_node)
367 node = self._commit([repo_file], text, user)
369 partial = lambda filename: repo_file == filename
370 try:
371 unresolved = mercurial.merge.update(self.repo, tip_node,
372 True, False, partial)
373 except mercurial.util.Abort:
374 unresolved = 1, 1, 1, 1
375 msg = _(u'merge of edit conflict')
376 if unresolved[3]:
377 msg = _(u'forced merge of edit conflict')
378 try:
379 mercurial.merge.update(self.repo, tip_node, True, True,
380 partial)
381 except mercurial.util.Abort:
382 msg = _(u'failed merge of edit conflict')
383 self.repo.dirstate.setparents(tip_node, node)
384 # Mercurial 1.1 and later need updating the merge state
385 try:
386 mercurial.merge.mergestate(self.repo).mark(repo_file, "r")
387 except (AttributeError, KeyError):
388 pass
389 return msg
391 @locked_repo
392 def save_file(self, title, file_name, author=u'', comment=u'', parent=None):
393 """Save an existing file as specified page."""
394 import errno
396 user = author.encode('utf-8') or _(u'anon').encode('utf-8')
397 text = comment.encode('utf-8') or _(u'comment').encode('utf-8')
398 repo_file = self._title_to_file(title)
399 file_path = self._file_path(title)
400 path_dir = os.path.dirname(file_path)
402 if os.path.isdir(file_path):
403 raise werkzeug.exceptions.Forbidden(
404 u'save_file: Page name is a directory: %s' % file_path)
406 try:
407 if not os.path.isdir(path_dir):
408 os.makedirs(path_dir)
409 mercurial.util.rename(file_name, file_path)
410 except OSError, err:
411 # Path component is an existing page?
412 msg = u'save_file: [Errno %s] %s: in path %s' % (err.errno,
413 os.strerror(err.errno), file_path)
414 raise werkzeug.exceptions.Forbidden(msg)
416 changectx = self._changectx()
417 try:
418 filectx_tip = changectx[repo_file]
419 current_page_rev = filectx_tip.filerev()
420 except mercurial.revlog.LookupError:
421 self.repo.add([repo_file])
422 current_page_rev = -1
423 if parent is not None and current_page_rev != parent:
424 msg = self.merge_changes(changectx, repo_file, text, user, parent)
425 user = '<wiki>'
426 text = msg.encode('utf-8')
427 self._commit([repo_file], text, user)
430 def _commit(self, files, text, user):
431 try:
432 return self.repo.commit(files=files, text=text, user=user,
433 force=True, empty_ok=True)
434 except TypeError:
435 # Mercurial 1.3 doesn't accept empty_ok or files parameter
436 match = mercurial.match.exact(self.repo_path, '', list(files))
437 return self.repo.commit(match=match, text=text, user=user,
438 force=True)
441 def save_data(self, title, data, author=u'', comment=u'', parent=None):
442 """Save data as specified page."""
444 try:
445 temp_path = tempfile.mkdtemp(dir=self.path)
446 file_path = os.path.join(temp_path, 'saved')
447 f = open(file_path, "wb")
448 f.write(data)
449 f.close()
450 self.save_file(title, file_path, author, comment, parent)
451 finally:
452 try:
453 os.unlink(file_path)
454 except OSError:
455 pass
456 try:
457 os.rmdir(temp_path)
458 except OSError:
459 pass
461 def save_text(self, title, text, author=u'', comment=u'', parent=None):
462 """Save text as specified page, encoded to charset."""
464 data = text.encode(self.charset)
465 self.save_data(title, data, author, comment, parent)
467 def page_text(self, title):
468 """Read unicode text of a page."""
470 data = self.open_page(title).read()
471 text = unicode(data, self.charset, 'replace')
472 return text
474 def page_lines(self, page):
475 for data in page:
476 yield unicode(data, self.charset, 'replace')
478 @locked_repo
479 def delete_page(self, title, author=u'', comment=u''):
480 user = author.encode('utf-8') or 'anon'
481 text = comment.encode('utf-8') or 'deleted'
482 repo_file = self._title_to_file(title)
483 file_path = self._file_path(title)
484 try:
485 os.unlink(file_path)
486 except OSError:
487 pass
488 self.repo.remove([repo_file])
489 self._commit([repo_file], text, user)
491 def open_page(self, title):
492 try:
493 return open(self._file_path(title), "rb")
494 except IOError:
495 raise werkzeug.exceptions.NotFound()
497 def page_file_meta(self, title):
498 """Get page's inode number, size and last modification time."""
500 try:
501 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
502 st_atime, st_mtime, st_ctime) = os.stat(self._file_path(title))
503 except OSError:
504 return 0, 0, 0
505 return st_ino, st_size, st_mtime
507 def page_meta(self, title):
508 """Get page's revision, date, last editor and his edit comment."""
510 filectx_tip = self._find_filectx(title)
511 if filectx_tip is None:
512 raise werkzeug.exceptions.NotFound()
513 #return -1, None, u'', u''
514 rev = filectx_tip.filerev()
515 filectx = filectx_tip.filectx(rev)
516 date = datetime.datetime.fromtimestamp(filectx.date()[0])
517 author = unicode(filectx.user(), "utf-8",
518 'replace').split('<')[0].strip()
519 comment = unicode(filectx.description(), "utf-8", 'replace')
520 return rev, date, author, comment
522 def repo_revision(self):
523 return self._changectx().rev()
525 def page_mime(self, title):
526 """
527 Guess page's mime type ased on corresponding file name.
528 Default ot text/x-wiki for files without an extension.
530 >>> page_mime('something.txt')
531 'text/plain'
532 >>> page_mime('SomePage')
533 'text/x-wiki'
534 >>> page_mime(u'ąęśUnicodePage')
535 'text/x-wiki'
536 >>> page_mime('image.png')
537 'image/png'
538 >>> page_mime('style.css')
539 'text/css'
540 >>> page_mime('archive.tar.gz')
541 'archive/gzip'
542 """
544 addr = self._file_path(title)
545 mime, encoding = mimetypes.guess_type(addr, strict=False)
546 if encoding:
547 mime = 'archive/%s' % encoding
548 if mime is None:
549 mime = 'text/x-wiki'
550 return mime
552 def _changectx(self):
553 """Get the changectx of the tip."""
554 try:
555 # This is for Mercurial 1.0
556 return self.repo.changectx()
557 except TypeError:
558 # Mercurial 1.3 (and possibly earlier) needs an argument
559 return self.repo.changectx('tip')
561 def _find_filectx(self, title):
562 """Find the last revision in which the file existed."""
564 repo_file = self._title_to_file(title)
565 changectx = self._changectx()
566 stack = [changectx]
567 while repo_file not in changectx:
568 if not stack:
569 return None
570 changectx = stack.pop()
571 for parent in changectx.parents():
572 if parent != changectx:
573 stack.append(parent)
574 return changectx[repo_file]
576 def page_history(self, title):
577 """Iterate over the page's history."""
579 filectx_tip = self._find_filectx(title)
580 if filectx_tip is None:
581 return
582 maxrev = filectx_tip.filerev()
583 minrev = 0
584 for rev in range(maxrev, minrev-1, -1):
585 filectx = filectx_tip.filectx(rev)
586 date = datetime.datetime.fromtimestamp(filectx.date()[0])
587 author = unicode(filectx.user(), "utf-8",
588 'replace').split('<')[0].strip()
589 comment = unicode(filectx.description(), "utf-8", 'replace')
590 yield rev, date, author, comment
592 def page_revision(self, title, rev):
593 """Get unicode contents of specified revision of the page."""
595 filectx_tip = self._find_filectx(title)
596 if filectx_tip is None:
597 raise werkzeug.exceptions.NotFound()
598 try:
599 data = filectx_tip.filectx(rev).data()
600 except IndexError:
601 raise werkzeug.exceptions.NotFound()
602 return data
604 def revision_text(self, title, rev):
605 data = self.page_revision(title, rev)
606 text = unicode(data, self.charset, 'replace')
607 return text
609 def history(self):
610 """Iterate over the history of entire wiki."""
612 changectx = self._changectx()
613 maxrev = changectx.rev()
614 minrev = 0
615 for wiki_rev in range(maxrev, minrev-1, -1):
616 change = self.repo.changectx(wiki_rev)
617 date = datetime.datetime.fromtimestamp(change.date()[0])
618 author = unicode(change.user(), "utf-8",
619 'replace').split('<')[0].strip()
620 comment = unicode(change.description(), "utf-8", 'replace')
621 for repo_file in change.files():
622 if repo_file.startswith(self.repo_prefix):
623 title = self._file_to_title(repo_file)
624 try:
625 rev = change[repo_file].filerev()
626 except mercurial.revlog.LookupError:
627 rev = -1
628 yield title, rev, date, author, comment
630 def all_pages(self):
631 """Iterate over the titles of all pages in the wiki tree."""
633 for dirpath, dirnames, filenames in os.walk(self.path):
634 dirnames[:] = [d for d in dirnames if not d.startswith('.')]
635 relpath = dirpath[len(self.path)+1:]
636 for name in filenames:
637 if (os.path.isfile(os.path.join(dirpath, name))
638 and not name.startswith('.')):
639 yield werkzeug.url_unquote(os.path.join(relpath, name))
641 def changed_since(self, rev):
642 """Return all pages that changed since specified repository revision."""
644 try:
645 last = self.repo.lookup(int(rev))
646 except IndexError:
647 for page in self.all_pages():
648 yield page
649 return
650 current = self.repo.lookup('tip')
651 status = self.repo.status(current, last)
652 modified, added, removed, deleted, unknown, ignored, clean = status
653 for filename in modified+added+removed+deleted:
654 if filename.startswith(self.repo_prefix):
655 yield self._file_to_title(filename)
657 class WikiParser(object):
658 r"""
659 Responsible for generating HTML markup from the wiki markup.
661 The parser works on two levels. On the block level, it analyzes lines
662 of text and decides what kind of block element they belong to (block
663 elements include paragraphs, lists, headings, preformatted blocks).
664 Lines belonging to the same block are joined together, and a second
665 pass is made using regular expressions to parse line-level elements,
666 such as links, bold and italic text and smileys.
668 Some block-level elements, such as preformatted blocks, consume additional
669 lines from the input until they encounter the end-of-block marker, using
670 lines_until. Most block-level elements are just runs of marked up lines
671 though.
674 """
676 bullets_pat = ur"^\s*[*]+\s+"
677 heading_pat = ur"^\s*=+"
678 quote_pat = ur"^[>]+\s+"
679 block = {
680 "bullets": bullets_pat,
681 "code": ur"^[{][{][{]+\s*$",
682 "conflict": ur"^<<<<<<< local\s*$",
683 "empty": ur"^\s*$",
684 "heading": heading_pat,
685 "indent": ur"^[ \t]+",
686 "macro": ur"^<<\w+\s*$",
687 "quote": quote_pat,
688 "rule": ur"^\s*---+\s*$",
689 "syntax": ur"^\{\{\{\#![\w+#.-]+\s*$",
690 "table": ur"^\|",
691 } # note that the priority is alphabetical
692 image_pat = (ur"\{\{(?P<image_target>([^|}]|}[^|}])*)"
693 ur"(\|(?P<image_text>([^}]|}[^}])*))?}}")
694 smilies = {
695 r':)': "smile.png",
696 r':(': "frown.png",
697 r':P': "tongue.png",
698 r':D': "grin.png",
699 r';)': "wink.png",
700 }
701 punct = {
702 r'...': "&hellip;",
703 r'--': "&ndash;",
704 r'---': "&mdash;",
705 r'~': "&nbsp;",
706 r'\~': "~",
707 r'~~': "&sim;",
708 r'(C)': "&copy;",
709 r'-->': "&rarr;",
710 r'<--': "&larr;",
711 r'(R)': "&reg;",
712 r'(TM)': "&trade;",
713 r'%%': "&permil;",
714 r'``': "&ldquo;",
715 r"''": "&rdquo;",
716 r",,": "&bdquo;",
717 }
718 camel_link = ur"\w+[%s]\w+" % re.escape(
719 u''.join(unichr(i) for i in xrange(sys.maxunicode)
720 if unicodedata.category(unichr(i))=='Lu'))
721 markup = {
722 "bold": ur"[*][*]",
723 "camel_link": camel_link,
724 "camel_nolink": ur"[!~](?P<camel_text>%s)" % camel_link,
725 "code": ur"[{][{][{](?P<code_text>([^}]|[^}][}]|[^}][}][}])"
726 ur"*[}]*)[}][}][}]",
727 "free_link": ur"""(http|https|ftp)://\S+[^\s.,:;!?()'"=+<>-]""",
728 "italic": ur"//",
729 "link": ur"\[\[(?P<link_target>([^|\]]|\][^|\]])+)"
730 ur"(\|(?P<link_text>([^\]]|\][^\]])+))?\]\]",
731 "image": image_pat,
732 "linebreak": ur"\\\\",
733 "macro": ur"[<][<](?P<macro_name>\w+)\s+"
734 ur"(?P<macro_text>([^>]|[^>][>])+)[>][>]",
735 "mail": ur"""(mailto:)?\S+@\S+(\.[^\s.,:;!?()'"/=+<>-]+)+""",
736 "math": ur"\$\$(?P<math_text>[^$]+)\$\$",
737 "newline": ur"\n",
738 "punct": (ur'(^|\b|(?<=\s))(%s)((?=[\s.,:;!?)/&=+])|\b|$)' %
739 ur"|".join(re.escape(k) for k in punct)),
740 "table": ur"=?\|=?",
741 "text": ur".+?",
742 } # note that the priority is alphabetical
745 def __init__(self, lines, wiki_link, wiki_image,
746 wiki_syntax=None, wiki_math=None, smilies=None):
747 self.wiki_link = wiki_link
748 self.wiki_image = wiki_image
749 self.wiki_syntax = wiki_syntax
750 self.wiki_math = wiki_math
751 self.enumerated_lines = enumerate(lines)
752 if smilies is not None:
753 self.smilies = smilies
754 self.compile_patterns()
755 self.headings = {}
756 self.stack = []
757 self.line_no = 0
759 def compile_patterns(self):
760 self.quote_re = re.compile(self.quote_pat, re.U)
761 self.heading_re = re.compile(self.heading_pat, re.U)
762 self.bullets_re = re.compile(self.bullets_pat, re.U)
763 self.block_re = re.compile(ur"|".join("(?P<%s>%s)" % kv
764 for kv in sorted(self.block.iteritems())))
765 self.code_close_re = re.compile(ur"^\}\}\}\s*$", re.U)
766 self.macro_close_re = re.compile(ur"^>>\s*$", re.U)
767 self.conflict_close_re = re.compile(ur"^>>>>>>> other\s*$", re.U)
768 self.conflict_sep_re = re.compile(ur"^=======\s*$", re.U)
769 self.image_re = re.compile(self.image_pat, re.U)
770 self.markup['smiley'] = (ur"(^|\b|(?<=\s))"
771 ur"(?P<smiley_face>%s)"
772 ur"((?=[\s.,:;!?)/&=+-])|$)"
773 % ur"|".join(re.escape(k)
774 for k in self.smilies))
775 self.markup_re = re.compile(ur"|".join("(?P<%s>%s)" % kv
776 for kv in sorted(self.markup.iteritems())))
778 def __iter__(self):
779 return self.parse()
781 @classmethod
782 def extract_links(cls, text):
783 links = []
784 def link(addr, label=None, class_=None, image=None, alt=None, lineno=0):
785 if external_link(addr):
786 return u''
787 if '#' in addr:
788 addr, chunk = addr.split('#', 1)
789 if addr == u'':
790 return u''
791 links.append((addr, label))
792 return u''
793 lines = text.split('\n')
794 for part in cls(lines, link, link):
795 for ret in links:
796 yield ret
797 links[:] = []
799 def parse(self):
800 """Parse a list of lines of wiki markup, yielding HTML for it."""
802 self.headings = {}
803 self.stack = []
804 self.line_no = 0
806 def key(enumerated_line):
807 line_no, line = enumerated_line
808 match = self.block_re.match(line)
809 if match:
810 return match.lastgroup
811 return "paragraph"
813 for kind, block in itertools.groupby(self.enumerated_lines, key):
814 func = getattr(self, "_block_%s" % kind)
815 for part in func(block):
816 yield part
818 def parse_line(self, line):
819 """
820 Find all the line-level markup and return HTML for it.
822 """
824 for match in self.markup_re.finditer(line):
825 func = getattr(self, "_line_%s" % match.lastgroup)
826 yield func(match.groupdict())
828 def pop_to(self, stop):
829 """
830 Pop from the stack until the specified tag is encoutered.
831 Return string containing closing tags of everything popped.
832 """
833 tags = []
834 tag = None
835 try:
836 while tag != stop:
837 tag = self.stack.pop()
838 tags.append(tag)
839 except IndexError:
840 pass
841 return u"".join(u"</%s>" % tag for tag in tags)
843 def lines_until(self, close_re):
844 """Get lines from input until the closing markup is encountered."""
846 self.line_no, line = self.enumerated_lines.next()
847 while not close_re.match(line):
848 yield line.rstrip()
849 line_no, line = self.enumerated_lines.next()
851 # methods for the markup inside lines:
853 def _line_table(self, groups):
854 return groups["table"]
856 def _line_linebreak(self, groups):
857 return u'<br>'
859 def _line_smiley(self, groups):
860 smiley = groups["smiley_face"]
861 return self.wiki_image(self.smilies[smiley], alt=smiley,
862 class_="smiley")
864 def _line_bold(self, groups):
865 if 'b' in self.stack:
866 return self.pop_to('b')
867 else:
868 self.stack.append('b')
869 return u"<b>"
871 def _line_italic(self, groups):
872 if 'i' in self.stack:
873 return self.pop_to('i')
874 else:
875 self.stack.append('i')
876 return u"<i>"
878 def _line_punct(self, groups):
879 text = groups["punct"]
880 return self.punct.get(text, text)
882 def _line_newline(self, groups):
883 return "\n"
885 def _line_text(self, groups):
886 return werkzeug.escape(groups["text"])
888 def _line_math(self, groups):
889 if self.wiki_math:
890 return self.wiki_math(groups["math_text"])
891 else:
892 return "<var>%s</var>" % werkzeug.escape(groups["math_text"])
894 def _line_code(self, groups):
895 return u'<code>%s</code>' % werkzeug.escape(groups["code_text"])
897 def _line_free_link(self, groups):
898 groups['link_target'] = groups['free_link']
899 return self._line_link(groups)
901 def _line_camel_link(self, groups):
902 groups['link_target'] = groups['camel_link']
903 return self._line_link(groups)
905 def _line_camel_nolink(self, groups):
906 return werkzeug.escape(groups["camel_text"])
908 def _line_mail(self, groups):
909 addr = groups['mail']
910 groups['link_text'] = addr
911 if not addr.startswith(u'mailto:'):
912 addr = u'mailto:%s' % addr
913 groups['link_target'] = addr
914 return self._line_link(groups)
916 def _line_link(self, groups):
917 target = groups['link_target']
918 text = groups.get('link_text')
919 if not text:
920 text = target
921 if '#' in text:
922 text, chunk = text.split('#', 1)
923 match = self.image_re.match(text)
924 if match:
925 image = self._line_image(match.groupdict())
926 return self.wiki_link(target, text, image=image)
927 return self.wiki_link(target, text)
929 def _line_image(self, groups):
930 target = groups['image_target']
931 alt = groups.get('image_text')
932 if alt is None:
933 alt = target
934 return self.wiki_image(target, alt)
936 def _line_macro(self, groups):
937 name = groups['macro_name']
938 text = groups['macro_text'].strip()
939 return u'<span class="%s">%s</span>' % (
940 werkzeug.escape(name, quote=True),
941 werkzeug.escape(text))
943 # methods for the block (multiline) markup:
945 def _block_code(self, block):
946 for self.line_no, part in block:
947 inside = u"\n".join(self.lines_until(self.code_close_re))
948 yield werkzeug.html.pre(werkzeug.html(inside), class_="code",
949 id="line_%d" % self.line_no)
951 def _block_syntax(self, block):
952 for self.line_no, part in block:
953 syntax = part.lstrip('{#!').strip()
954 inside = u"\n".join(self.lines_until(self.code_close_re))
955 if self.wiki_syntax:
956 return self.wiki_syntax(inside, syntax=syntax,
957 line_no=self.line_no)
958 else:
959 return [werkzeug.html.div(werkzeug.html.pre(
960 werkzeug.html(inside), id="line_%d" % self.line_no),
961 class_="highlight")]
963 def _block_macro(self, block):
964 for self.line_no, part in block:
965 name = part.lstrip('<').strip()
966 inside = u"\n".join(self.lines_until(self.macro_close_re))
967 yield u'<div class="%s">%s</div>' % (
968 werkzeug.escape(name, quote=True),
969 werkzeug.escape(inside))
971 def _block_paragraph(self, block):
972 parts = []
973 first_line = None
974 for self.line_no, part in block:
975 if first_line is None:
976 first_line = self.line_no
977 parts.append(part)
978 text = u"".join(self.parse_line(u"".join(parts)))
979 yield werkzeug.html.p(text, self.pop_to(""), id="line_%d" % first_line)
981 def _block_indent(self, block):
982 parts = []
983 first_line = None
984 for self.line_no, part in block:
985 if first_line is None:
986 first_line = self.line_no
987 parts.append(part.rstrip())
988 text = u"\n".join(parts)
989 yield werkzeug.html.pre(werkzeug.html(text), id="line_%d" % first_line)
991 def _block_table(self, block):
992 first_line = None
993 in_head = False
994 for self.line_no, line in block:
995 if first_line is None:
996 first_line = self.line_no
997 yield u'<table id="line_%d">' % first_line
998 table_row = line.strip()
999 is_header = table_row.startswith('|=') and table_row.endswith('=|')
1000 if not in_head and is_header:
1001 in_head = True
1002 yield '<thead>'
1003 elif in_head and not is_header:
1004 in_head = False
1005 yield '</thead>'
1006 yield '<tr>'
1007 in_cell = False
1008 in_th = False
1010 for part in self.parse_line(table_row):
1011 if part in ('=|', '|', '=|=', '|='):
1012 if in_cell:
1013 if in_th:
1014 yield '</th>'
1015 else:
1016 yield '</td>'
1017 in_cell = False
1018 if part in ('=|=', '|='):
1019 in_th = True
1020 else:
1021 in_th = False
1022 else:
1023 if not in_cell:
1024 if in_th:
1025 yield '<th>'
1026 else:
1027 yield '<td>'
1028 in_cell = True
1029 yield part
1030 if in_cell:
1031 if in_th:
1032 yield '</th>'
1033 else:
1034 yield '</td>'
1035 yield '</tr>'
1036 yield u'</table>'
1038 def _block_empty(self, block):
1039 yield u''
1041 def _block_rule(self, block):
1042 for self.line_no, line in block:
1043 yield werkzeug.html.hr()
1045 def _block_heading(self, block):
1046 for self.line_no, line in block:
1047 level = min(len(self.heading_re.match(line).group(0).strip()), 5)
1048 self.headings[level-1] = self.headings.get(level-1, 0)+1
1049 label = u"-".join(str(self.headings.get(i, 0))
1050 for i in range(level))
1051 yield werkzeug.html.a(name="head-%s" % label)
1052 yield u'<h%d id="line_%d">%s</h%d>' % (level, self.line_no,
1053 werkzeug.escape(line.strip("= \t\n\r\v")), level)
1055 def _block_bullets(self, block):
1056 level = 0
1057 in_ul = False
1058 for self.line_no, line in block:
1059 nest = len(self.bullets_re.match(line).group(0).strip())
1060 while nest > level:
1061 if in_ul:
1062 yield '<li>'
1063 yield '<ul id="line_%d">' % self.line_no
1064 in_ul = True
1065 level += 1
1066 while nest < level:
1067 yield '</li></ul>'
1068 in_ul = False
1069 level -= 1
1070 if nest == level and not in_ul:
1071 yield '</li>'
1072 content = line.lstrip().lstrip('*').strip()
1073 yield '<li>%s%s' % (u"".join(self.parse_line(content)),
1074 self.pop_to(""))
1075 in_ul = False
1076 yield '</li></ul>'*level
1078 def _block_quote(self, block):
1079 level = 0
1080 in_p = False
1081 for self.line_no, line in block:
1082 nest = len(self.quote_re.match(line).group(0).strip())
1083 if nest == level:
1084 yield u'\n'
1085 while nest > level:
1086 if in_p:
1087 yield '%s</p>' % self.pop_to("")
1088 in_p = False
1089 yield '<blockquote>'
1090 level += 1
1091 while nest < level:
1092 if in_p:
1093 yield '%s</p>' % self.pop_to("")
1094 in_p = False
1095 yield '</blockquote>'
1096 level -= 1
1097 content = line.lstrip().lstrip('>').strip()
1098 if not in_p:
1099 yield '<p id="line_%d">' % self.line_no
1100 in_p = True
1101 yield u"".join(self.parse_line(content))
1102 if in_p:
1103 yield '%s</p>' % self.pop_to("")
1104 yield '</blockquote>'*level
1106 def _block_conflict(self, block):
1107 for self.line_no, part in block:
1108 yield u'<div class="conflict">'
1109 local = u"\n".join(self.lines_until(self.conflict_sep_re))
1110 yield werkzeug.html.pre(werkzeug.html(local),
1111 class_="local",
1112 id="line_%d" % self.line_no)
1113 other = u"\n".join(self.lines_until(self.conflict_close_re))
1114 yield werkzeug.html.pre(werkzeug.html(other),
1115 class_="other",
1116 id="line_%d" % self.line_no)
1117 yield u'</div>'
1119 class WikiSearch(object):
1120 """
1121 Responsible for indexing words and links, for fast searching and
1122 backlinks. Uses a cache directory to store the index files.
1123 """
1125 word_pattern = re.compile(ur"""\w[-~&\w]+\w""", re.UNICODE)
1126 jword_pattern = re.compile(
1127 ur"""[ヲ-゚]+|[ぁ-ん~ー]+|[ァ-ヶ~ー]+|[0-9A-Za-z]+|"""
1128 ur"""[0-9A-Za-zΑ-Ωα-ωА-я]+|"""
1129 ur"""[^- !"#$%&'()*+,./:;<=>?@\[\\\]^_`{|}"""
1130 ur"""‾。「」、・ 、。,.・:;?!゛゜´`¨"""
1131 ur"""^ ̄_/〜‖|…‥‘’“”"""
1132 ur"""()〔〕[]{}〈〉《》「」『』【】+−±×÷"""
1133 ur"""=≠<>≦≧∞∴♂♀°′″℃¥$¢£"""
1134 ur"""%#&*@§☆★○●◎◇◆□■△▲▽▼※〒"""
1135 ur"""→←↑↓〓∈∋⊆⊇⊂⊃∪∩∧∨¬⇒⇔∠∃∠⊥"""
1136 ur"""⌒∂∇≡≒≪≫√∽∝∵∫∬ʼn♯♭♪†‡¶◾"""
1137 ur"""─│┌┐┘└├┬┤┴┼"""
1138 ur"""━┃┏┓┛┗┣┫┻╋"""
1139 ur"""┠┯┨┷┿┝┰┥┸╂"""
1140 ur"""ヲ-゚ぁ-ん~ーァ-ヶ"""
1141 ur"""0-9A-Za-z0-9A-Za-zΑ-Ωα-ωА-я]+""", re.UNICODE)
1142 _con = {}
1144 def __init__(self, cache_path, lang, storage):
1145 self.path = cache_path
1146 self.storage = storage
1147 self.lang = lang
1148 if lang == "ja":
1149 self.split_text = self.split_japanese_text
1150 self.filename = os.path.join(cache_path, 'index.sqlite3')
1151 if not os.path.isdir(self.path):
1152 self.empty = True
1153 os.makedirs(self.path)
1154 elif not os.path.exists(self.filename):
1155 self.empty = True
1156 else:
1157 self.empty = False
1158 con = self.con # sqlite3.connect(self.filename)
1159 self.con.execute('CREATE TABLE IF NOT EXISTS titles '
1160 '(id INTEGER PRIMARY KEY, title VARCHAR);')
1161 self.con.execute('CREATE TABLE IF NOT EXISTS words '
1162 '(word VARCHAR, page INTEGER, count INTEGER);')
1163 self.con.execute('CREATE INDEX IF NOT EXISTS index1 '
1164 'ON words (page);')
1165 self.con.execute('CREATE INDEX IF NOT EXISTS index2 '
1166 'ON words (word);')
1167 self.con.execute('CREATE TABLE IF NOT EXISTS links '
1168 '(src INTEGER, target INTEGER, label VARCHAR, number INTEGER);')
1169 self.con.commit()
1170 self.stop_words_re = re.compile(u'^('+u'|'.join(re.escape(_(
1171 u"""am ii iii per po re a about above
1172 across after afterwards again against all almost alone along already also
1173 although always am among ain amongst amoungst amount an and another any aren
1174 anyhow anyone anything anyway anywhere are around as at back be became because
1175 become becomes becoming been before beforehand behind being below beside
1176 besides between beyond bill both but by can cannot cant con could couldnt
1177 describe detail do done down due during each eg eight either eleven else etc
1178 elsewhere empty enough even ever every everyone everything everywhere except
1179 few fifteen fifty fill find fire first five for former formerly forty found
1180 four from front full further get give go had has hasnt have he hence her here
1181 hereafter hereby herein hereupon hers herself him himself his how however
1182 hundred i ie if in inc indeed interest into is it its itself keep last latter
1183 latterly least isn less made many may me meanwhile might mill mine more
1184 moreover most mostly move much must my myself name namely neither never
1185 nevertheless next nine no nobody none noone nor not nothing now nowhere of off
1186 often on once one only onto or other others otherwise our ours ourselves out
1187 over own per perhaps please pre put rather re same see seem seemed seeming
1188 seems serious several she should show side since sincere six sixty so some
1189 somehow someone something sometime sometimes somewhere still such take ten than
1190 that the their theirs them themselves then thence there thereafter thereby
1191 therefore therein thereupon these they thick thin third this those though three
1192 through throughout thru thus to together too toward towards twelve twenty two
1193 un under ve until up upon us very via was wasn we well were what whatever when
1194 whence whenever where whereafter whereas whereby wherein whereupon wherever
1195 whether which while whither who whoever whole whom whose why will with within
1196 without would yet you your yours yourself yourselves""")).split())
1197 +ur')$|.*\d.*', re.U|re.I|re.X)
1198 self.update()
1202 @property
1203 def con(self):
1204 """Keep one connection per thread."""
1206 thread_id = thread.get_ident()
1207 try:
1208 return self._con[thread_id]
1209 except KeyError:
1210 connection = sqlite3.connect(self.filename)
1211 connection.isolation_level = None
1212 self._con[thread_id] = connection
1213 return connection
1215 def split_text(self, text, stop=True):
1216 """Splits text into words, removing stop words"""
1218 for match in self.word_pattern.finditer(text):
1219 word = match.group(0)
1220 if not (stop and self.stop_words_re.match(word)):
1221 yield word.lower()
1223 def split_japanese_text(self, text, stop=True):
1224 """Splits text into words, including rules for Japanese"""
1226 for match in self.word_pattern.finditer(text):
1227 word = match.group(0)
1228 got_japanese = False
1229 for m in self.jword_pattern.finditer(word):
1230 w = m.group(0)
1231 got_japanese = True
1232 if not (stop and self.stop_words_re.match(w)):
1233 yield w.lower()
1234 if not (got_japanese or stop and self.stop_words_re.match(word)):
1235 yield word.lower()
1237 def count_words(self, words):
1238 count = {}
1239 for word in words:
1240 count[word] = count.get(word, 0)+1
1241 return count
1243 def title_id(self, title, con):
1244 c = con.execute('SELECT id FROM titles WHERE title=?;', (title,))
1245 idents = c.fetchone()
1246 if idents is None:
1247 con.execute('INSERT INTO titles (title) VALUES (?);', (title,))
1248 c = con.execute('SELECT LAST_INSERT_ROWID();')
1249 idents = c.fetchone()
1250 return idents[0]
1252 def update_words(self, title, text, cursor):
1253 title_id = self.title_id(title, cursor)
1254 words = self.count_words(self.split_text(text))
1255 title_words = self.count_words(self.split_text(title))
1256 for word, count in title_words.iteritems():
1257 words[word] = words.get(word, 0) + count
1258 cursor.execute('DELETE FROM words WHERE page=?;', (title_id,))
1259 for word, count in words.iteritems():
1260 cursor.execute('INSERT INTO words VALUES (?, ?, ?);',
1261 (word, title_id, count))
1263 def update_links(self, title, links_and_labels, cursor):
1264 title_id = self.title_id(title, cursor)
1265 cursor.execute('DELETE FROM links WHERE src=?;', (title_id,))
1266 for number, (link, label) in enumerate(links_and_labels):
1267 cursor.execute('INSERT INTO links VALUES (?, ?, ?, ?);',
1268 (title_id, link, label, number))
1270 def page_backlinks(self, title):
1271 con = self.con # sqlite3.connect(self.filename)
1272 try:
1273 sql = ('SELECT DISTINCT(titles.title) '
1274 'FROM links, titles '
1275 'WHERE links.target=? AND titles.id=links.src '
1276 'ORDER BY titles.title;')
1277 for (backlink,) in con.execute(sql, (title,)):
1278 yield backlink
1279 finally:
1280 con.commit()
1282 def page_links(self, title):
1283 con = self.con # sqlite3.connect(self.filename)
1284 try:
1285 title_id = self.title_id(title, con)
1286 sql = 'SELECT TARGET from links where src=? ORDER BY number;'
1287 for (link,) in con.execute(sql, (title_id,)):
1288 yield link
1289 finally:
1290 con.commit()
1292 def page_links_and_labels (self, title):
1293 con = self.con # sqlite3.connect(self.filename)
1294 try:
1295 title_id = self.title_id(title, con)
1296 sql = 'SELECT target, label FROM links WHERE src=? ORDER BY number;'
1297 for link_and_label in con.execute(sql, (title_id,)):
1298 yield link_and_label
1299 finally:
1300 con.commit()
1302 def find(self, words):
1303 """Iterator of all pages containing the words, and their scores."""
1305 con = self.con
1306 try:
1307 ranks = []
1308 for word in words:
1309 # Calculate popularity of each word.
1310 sql = 'SELECT SUM(words.count) FROM words WHERE word LIKE ?;'
1311 rank = con.execute(sql, ('%%%s%%' % word,)).fetchone()[0]
1312 # If any rank is 0, there will be no results anyways
1313 if not rank:
1314 return
1315 ranks.append((rank, word))
1316 ranks.sort()
1317 # Start with the least popular word. Get all pages that contain it.
1318 first_rank, first = ranks[0]
1319 rest = ranks[1:]
1320 sql = ('SELECT words.page, titles.title, SUM(words.count) '
1321 'FROM words, titles '
1322 'WHERE word LIKE ? AND titles.id=words.page '
1323 'GROUP BY words.page;')
1324 first_counts = con.execute(sql, ('%%%s%%' % first,))
1325 # Check for the rest of words
1326 for title_id, title, first_count in first_counts:
1327 # Score for the first word
1328 score = float(first_count)/first_rank
1329 for rank, word in rest:
1330 sql = ('SELECT SUM(count) FROM words '
1331 'WHERE page=? AND word LIKE ?;')
1332 count = con.execute(sql,
1333 (title_id, '%%%s%%' % word)).fetchone()[0]
1334 if not count:
1335 # If page misses any of the words, its score is 0
1336 score = 0
1337 break
1338 score += float(count)/rank
1339 if score > 0:
1340 yield int(100*score), title
1341 finally:
1342 con.commit()
1344 def reindex_page(self, title, cursor, text=None):
1345 """Updates the content of the database, needs locks around."""
1347 mime = self.storage.page_mime(title)
1348 if not mime.startswith('text/'):
1349 self.update_words(title, '', cursor=cursor)
1350 return
1351 if text is None:
1352 try:
1353 text = self.storage.page_text(title)
1354 except werkzeug.exceptions.NotFound:
1355 text = u''
1356 if mime == 'text/x-wiki':
1357 links = WikiParser.extract_links(text)
1358 self.update_links(title, links, cursor=cursor)
1359 self.update_words(title, text, cursor=cursor)
1361 def update_page(self, title, data=None, text=None):
1362 """Updates the index with new page content, for a single page."""
1364 if text is None and data is not None:
1365 text = unicode(data, self.storage.charset, 'replace')
1366 cursor = self.con.cursor()
1367 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1368 try:
1369 self.set_last_revision(self.storage.repo_revision())
1370 self.reindex_page(title, cursor, text)
1371 cursor.execute('COMMIT TRANSACTION;')
1372 except:
1373 cursor.execute('ROLLBACK;')
1374 raise
1376 def reindex(self, pages):
1377 """Updates specified pages in bulk."""
1379 cursor = self.con.cursor()
1380 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1381 try:
1382 for title in pages:
1383 self.reindex_page(title, cursor)
1384 cursor.execute('COMMIT TRANSACTION;')
1385 self.empty = False
1386 except:
1387 cursor.execute('ROLLBACK;')
1388 raise
1390 def set_last_revision(self, rev):
1391 """Store the last indexed repository revision."""
1393 # We use % here because the sqlite3's substitiution doesn't work
1394 # We store revision 0 as 1, 1 as 2, etc. because 0 means "no revision"
1395 self.con.execute('PRAGMA USER_VERSION=%d;' % (int(rev+1),))
1397 def get_last_revision(self):
1398 """Retrieve the last indexed repository revision."""
1400 con = self.con
1401 c = con.execute('PRAGMA USER_VERSION;')
1402 rev = c.fetchone()[0]
1403 # -1 means "no revision", 1 means revision 0, 2 means revision 1, etc.
1404 return rev-1
1406 def update(self):
1407 """Reindex al pages that changed since last indexing."""
1409 last_rev = self.get_last_revision()
1410 if last_rev == -1:
1411 changed = self.storage.all_pages()
1412 else:
1413 changed = self.storage.changed_since(last_rev)
1414 self.reindex(changed)
1415 rev = self.storage.repo_revision()
1416 self.set_last_revision(rev)
1418 class WikiResponse(werkzeug.BaseResponse, werkzeug.ETagResponseMixin,
1419 werkzeug.CommonResponseDescriptorsMixin):
1420 """A typical HTTP response class made out of Werkzeug's mixins."""
1422 def make_conditional(self, request):
1423 ret = super(WikiResponse, self).make_conditional(request)
1424 # Remove all headers if it's 304, according to
1425 # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
1426 if self.status.startswith('304'):
1427 self.response = []
1428 try:
1429 del self.content_type
1430 except AttributeError:
1431 pass
1432 try:
1433 del self.content_length
1434 except AttributeError:
1435 pass
1436 try:
1437 del self.headers['Content-length']
1438 except (KeyError, IndexError):
1439 pass
1440 try:
1441 del self.headers['Content-type']
1442 except (KeyError, IndexError):
1443 pass
1444 return ret
1446 class WikiTempFile(object):
1447 """Wrap a file for uploading content."""
1449 def __init__(self, tmppath):
1450 self.tmppath = tempfile.mkdtemp(dir=tmppath)
1451 self.tmpname = os.path.join(self.tmppath, 'saved')
1452 self.f = open(self.tmpname, "wb")
1454 def read(self, *args, **kw):
1455 return self.f.read(*args, **kw)
1457 def readlines(self, *args, **kw):
1458 return self.f.readlines(*args, **kw)
1460 def write(self, *args, **kw):
1461 return self.f.write(*args, **kw)
1463 def seek(self, *args, **kw):
1464 return self.f.seek(*args, **kw)
1466 def truncate(self, *args, **kw):
1467 return self.f.truncate(*args, **kw)
1469 def close(self, *args, **kw):
1470 ret = self.f.close(*args, **kw)
1471 try:
1472 os.unlink(self.tmpname)
1473 except OSError:
1474 pass
1475 try:
1476 os.rmdir(self.tmppath)
1477 except OSError:
1478 pass
1479 return ret
1482 class WikiRequest(werkzeug.BaseRequest, werkzeug.ETagRequestMixin):
1483 """
1484 A Werkzeug's request with additional functions for handling file
1485 uploads and wiki-specific link generation.
1486 """
1488 charset = 'utf-8'
1489 encoding_errors = 'ignore'
1491 def __init__(self, wiki, adapter, environ, **kw):
1492 werkzeug.BaseRequest.__init__(self, environ, shallow=False, **kw)
1493 self.wiki = wiki
1494 self.adapter = adapter
1495 self.tmpfiles = []
1496 self.tmppath = wiki.path
1497 # Whether to print the css for highlighting
1498 self.print_highlight_styles = True
1500 def get_url(self, title=None, view=None, method='GET',
1501 external=False, **kw):
1502 if view is None:
1503 view = self.wiki.view
1504 if title is not None:
1505 kw['title'] = title
1506 return self.adapter.build(view, kw, method=method,
1507 force_external=external)
1509 def get_download_url(self, title):
1510 return self.get_url(title, view=self.wiki.download)
1512 def get_author(self):
1513 """Try to guess the author name. Use IP address as last resort."""
1515 try:
1516 cookie = werkzeug.url_unquote(self.cookies.get("author", ""))
1517 except UnicodeError:
1518 cookie = None
1519 try:
1520 auth = werkzeug.url_unquote(self.environ.get('REMOTE_USER', ""))
1521 except UnicodeError:
1522 auth = None
1523 author = (self.form.get("author") or cookie or auth or self.remote_addr)
1524 return author
1526 def _get_file_stream(self, total_content_length=None, content_type=None,
1527 filename=None, content_length=None):
1528 """Save all the POSTs to temporary files."""
1530 temp_file = WikiTempFile(self.tmppath)
1531 self.tmpfiles.append(temp_file)
1532 return temp_file
1534 def cleanup(self):
1535 """Clean up the temporary files created by POSTs."""
1537 for temp_file in self.tmpfiles:
1538 temp_file.close()
1539 self.tmpfiles = []
1541 class WikiPage(object):
1542 """Everything needed for rendering a page."""
1544 def __init__(self, wiki, request, title, mime):
1545 self.request = request
1546 self.title = title
1547 self.mime = mime
1548 # for now we just use the globals from wiki object
1549 self.get_url = self.request.get_url
1550 self.get_download_url = self.request.get_download_url
1551 self.wiki = wiki
1552 self.storage = self.wiki.storage
1553 self.index = self.wiki.index
1554 self.config = self.wiki.config
1556 def date_html(self, datetime):
1557 """
1558 Create HTML for a date, according to recommendation at
1559 http://microformats.org/wiki/date
1560 """
1562 text = datetime.strftime('%Y-%m-%d %H:%M')
1563 # We are going for YYYY-MM-DDTHH:MM:SSZ
1564 title = datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
1565 html = werkzeug.html.abbr(text, class_="date", title=title)
1566 return html
1569 def wiki_link(self, addr, label=None, class_=None, image=None, lineno=0):
1570 """Create HTML for a wiki link."""
1572 text = werkzeug.escape(label or addr)
1573 chunk = ''
1574 if class_ is not None:
1575 classes = [class_]
1576 else:
1577 classes = []
1578 if external_link(addr):
1579 if addr.startswith('mailto:'):
1580 class_ = 'external email'
1581 text = text.replace('@', '&#64;').replace('.', '&#46;')
1582 href = addr.replace('@', '%40').replace('.', '%2E')
1583 else:
1584 classes.append('external')
1585 href = werkzeug.escape(addr, quote=True)
1586 else:
1587 if '#' in addr:
1588 addr, chunk = addr.split('#', 1)
1589 chunk = '#'+chunk
1590 if addr.startswith('+'):
1591 href = '/'.join([self.request.script_root,
1592 '+'+werkzeug.escape(addr[1:], quote=True)])
1593 classes.append('special')
1594 elif addr == u'':
1595 href = chunk
1596 classes.append('anchor')
1597 else:
1598 classes.append('wiki')
1599 href = self.get_url(addr) + chunk
1600 if addr not in self.storage:
1601 classes.append('nonexistent')
1602 class_ = ' '.join(classes) or None
1603 return werkzeug.html.a(image or text, href=href, class_=class_,
1604 title=addr+chunk)
1606 def wiki_image(self, addr, alt, class_='wiki', lineno=0):
1607 """Create HTML for a wiki image."""
1609 html = werkzeug.html
1610 chunk = ''
1611 if external_link(addr):
1612 return html.img(src=werkzeug.url_fix(addr), class_="external",
1613 alt=alt)
1614 if '#' in addr:
1615 addr, chunk = addr.split('#', 1)
1616 if addr == '':
1617 return html.a(name=chunk)
1618 if addr in self.storage:
1619 mime = self.storage.page_mime(addr)
1620 if mime.startswith('image/'):
1621 return html.img(src=self.get_download_url(addr), class_=class_,
1622 alt=alt)
1623 else:
1624 return html.img(href=self.get_download_url(addr), alt=alt)
1625 else:
1626 return html.a(html(alt), href=self.get_url(addr))
1628 def search_form(self):
1629 html = werkzeug.html
1630 return html.form(html.div(html.input(name="q", class_="search"),
1631 html.input(class_="button", type_="submit", value=_(u'Search')),
1632 ), method="GET", class_="search",
1633 action=self.get_url(None, self.wiki.search))
1635 def logo(self):
1636 html = werkzeug.html
1637 img = html.img(alt=u"[%s]" % self.wiki.front_page,
1638 src=self.get_download_url(self.wiki.logo_page))
1639 return html.a(img, class_='logo', href=self.get_url(self.wiki.front_page))
1641 def menu(self):
1642 """Generate the menu items"""
1644 html = werkzeug.html
1645 if self.wiki.menu_page in self.storage:
1646 items = self.index.page_links_and_labels(self.wiki.menu_page)
1647 else:
1648 items = [
1649 (self.wiki.front_page, self.wiki.front_page),
1650 ('+history', _(u'Recent changes')),
1652 for link, label in items:
1653 if link == self.title:
1654 class_="current"
1655 else:
1656 class_ = None
1657 yield self.wiki_link(link, label, class_=class_)
1659 def header(self, special_title):
1660 html = werkzeug.html
1661 if self.wiki.logo_page in self.storage:
1662 yield self.logo()
1663 yield self.search_form()
1664 yield html.div(u" ".join(self.menu()), class_="menu")
1665 yield html.h1(html(special_title or self.title))
1667 header_template = Template(u"""\
1668 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
1669 "http://www.w3.org/TR/html4/strict.dtd">
1670 <html><head>
1671 <title>${title} - ${site_name}</title>
1672 <% if style_url %><link rel="stylesheet" type="text/css" href="${style_url}">\
1673 <% else %><style type="text/css">
1674 html { background: #fff; color: #2e3436;
1675 font-family: sans-serif; font-size: 96% }
1676 body { margin: 1em auto; line-height: 1.3; width: 40em }
1677 a { color: #3465a4; text-decoration: none }
1678 a:hover { text-decoration: underline }
1679 a.wiki:visited { color: #204a87 }
1680 a.nonexistent { color: #a40000; }
1681 a.external { color: #3465a4; text-decoration: underline }
1682 a.external:visited { color: #75507b }
1683 a img { border: none }
1684 img.math, img.smiley { vertical-align: middle }
1685 pre { font-size: 100%; white-space: pre-wrap; word-wrap: break-word;
1686 white-space: -moz-pre-wrap; white-space: -pre-wrap;
1687 white-space: -o-pre-wrap; line-height: 1.2; color: #555753 }
1688 div.conflict pre.local { background: #fcaf3e; margin-bottom: 0; color: 000}
1689 div.conflict pre.other { background: #ffdd66; margin-top: 0; color: 000; border-top: #d80 dashed 1px; }
1690 pre.diff div.orig { font-size: 75%; color: #babdb6 }
1691 b.highlight, pre.diff ins { font-weight: bold; background: #fcaf3e;
1692 color: #ce5c00; text-decoration: none }
1693 pre.diff del { background: #eeeeec; color: #888a85; text-decoration: none }
1694 pre.diff div.change { border-left: 2px solid #fcaf3e }
1695 div.footer { border-top: solid 1px #babdb6; text-align: right }
1696 h1, h2, h3, h4 { color: #babdb6; font-weight: normal; letter-spacing: 0.125em}
1697 div.buttons { text-align: center }
1698 input.button, div.buttons input { font-weight: bold; font-size: 100%;
1699 background: #eee; border: solid 1px #babdb6; margin: 0.25em; color: #888a85}
1700 .history input.button { font-size: 75% }
1701 .editor textarea { width: 100%; display: block; font-size: 100%;
1702 border: solid 1px #babdb6; }
1703 .editor label { display:block; text-align: right }
1704 .editor .upload { margin: 2em auto; text-align: center }
1705 form.search input.search, .editor label input { font-size: 100%;
1706 border: solid 1px #babdb6; margin: 0.125em 0 }
1707 .editor label.comment input { width: 32em }
1708 a.logo { float: left; display: block; margin: 0.25em }
1709 div.header h1 { margin: 0; }
1710 div.content { clear: left }
1711 form.search { margin:0; text-align: right; font-size: 80% }
1712 div.snippet { font-size: 80%; color: #888a85 }
1713 div.header div.menu { float: right; margin-top: 1.25em }
1714 div.header div.menu a.current { color: #000 }
1715 hr { background: transparent; border:none; height: 0;
1716 border-bottom: 1px solid #babdb6; clear: both }
1717 blockquote { border-left:.25em solid #ccc; padding-left:.5em; margin-left:0}
1718 abbr.date {border:none}
1719 </style><%endif%>
1720 <%if not robots %><meta name="robots" content="NOINDEX,NOFOLLOW"><%endif%>
1721 <%if edit_url %><link rel="alternate" type="application/wiki" \
1722 href="${edit_url}"><%endif%>
1723 <link rel="shortcut icon" type="image/x-icon" href="${favicon_url}">
1724 <link rel="alternate" type="application/rss+xml" title="${site_name} (RSS)" \
1725 href="${rss_url}">
1726 <link rel="alternate" type="application/rss+xml" title="${site_name} (ATOM)" \
1727 href="${atom_url}">
1728 <%if script_url %>
1729 <script type="application/javascript" src="${script_url}"></script>\
1730 <%endif%>
1731 </head><body>
1732 <div class="header">${header_content}</div>
1733 <div class="content">
1734 """)
1735 footer_template = Template(u"""<div class="footer">
1736 <%if edit_url%><a href="${edit_url}" class="edit">${_("Edit")}</a> <%endif%>
1737 <a href="${history_url}" class="history">${_("History")}</a>
1738 <a href="${backlinks_url}" class="backlinks">${_("Backlinks")}</a>
1739 </div></div></body></html>""")
1741 def render_content(self, content, special_title=None):
1742 """The main page template."""
1744 style_url = None
1745 edit_url = None
1746 script_url = None
1747 if self.wiki.style_page in self.storage:
1748 style_url = self.get_download_url(self.wiki.style_page)
1749 if not special_title:
1750 try:
1751 self.wiki.check_lock(self.title)
1752 edit_url = self.get_url(self.title, self.wiki.edit)
1753 except werkzeug.exceptions.Forbidden:
1754 pass
1755 if self.wiki.script_page in self.wiki.storage:
1756 script_url = self.get_download_url(self.wiki.script_page)
1758 yield self.header_template.render(
1759 title=werkzeug.escape(special_title or self.title, quote=True),
1760 site_name=werkzeug.escape(self.wiki.site_name, quote=True),
1761 style_url=style_url,
1762 robots=not special_title,
1763 edit_url=edit_url,
1764 favicon_url=self.get_url(None, self.wiki.favicon),
1765 rss_url=self.get_url(None, self.wiki.rss),
1766 atom_url=self.get_url(None, self.wiki.atom),
1767 script_url=script_url,
1768 header_content = ''.join(self.header(special_title))
1770 for part in content:
1771 yield part
1772 if special_title:
1773 yield "</div></body></html>"
1774 return
1775 yield self.footer_template.render(
1776 edit_url=edit_url,
1777 history_url=self.get_url(self.title, self.wiki.history),
1778 backlinks_url=self.get_url(self.title, self.wiki.backlinks),
1779 _=lambda s: werkzeug.escape(_(s), quote=True),
1782 history_item_template = Template(u"""<li>
1783 <a href="${date_url}">${date_html}</a>
1784 <%if not read_only%>
1785 <input type="submit" name="${rev}" value="${undo}" class="button">
1786 <%endif%>
1787 . . . . ${author_link}
1788 <div class="comment">${comment}</div>
1789 </li>""")
1791 def history_list(self):
1792 """Generate the content of the history page."""
1794 max_rev = -1;
1795 title = self.title
1796 link = self.wiki_link(title)
1797 yield werkzeug.html.p(werkzeug.escape(
1798 _(u'History of changes for %(link)s.')) % {'link': link})
1799 url = self.request.get_url(title, self.wiki.undo, method='POST')
1800 yield u'<form action="%s" method="POST"><ul class="history">' % url
1801 try:
1802 self.wiki.check_lock(title)
1803 read_only = False
1804 except werkzeug.exceptions.Forbidden:
1805 read_only = True
1806 for rev, date, author, comment in self.wiki.storage.page_history(title):
1807 if max_rev < rev:
1808 max_rev = rev
1809 if rev > 0:
1810 date_url = self.request.adapter.build(self.wiki.diff, {
1811 'title': title, 'from_rev': rev-1, 'to_rev': rev})
1812 else:
1813 date_url = self.request.adapter.build(self.wiki.revision, {
1814 'title': title, 'rev': rev})
1815 yield self.history_item_template.render(
1816 read_only=read_only,
1817 rev=rev,
1818 date_url=date_url,
1819 date_html=self.date_html(date),
1820 author_link=self.wiki_link(author),
1821 comment=werkzeug.escape(comment),
1822 undo=werkzeug.escape(_('Undo'), quote=True),
1824 yield (u'</ul><input type="hidden" name="parent" value="%d"></form>'
1825 % max_rev)
1827 def dependencies(self):
1828 """Refresh the page when any of those pages was changed."""
1830 dependencies = set()
1831 for link in [self.wiki.style_page, self.wiki.logo_page,
1832 self.wiki.menu_page]:
1833 if link not in self.storage:
1834 dependencies.add(werkzeug.url_quote(link))
1835 return dependencies
1837 def diff_content(self, from_rev, to_rev):
1838 """Genrate HTML for the diff of revisions of a page."""
1840 return []
1842 class WikiPageText(WikiPage):
1843 """Pages of mime type text/* use this for display."""
1845 def view_content(self, lines=None):
1846 """Generate HTML for the content."""
1848 if lines is None:
1849 text = self.storage.page_text(self.title)
1850 else:
1851 text = ''.join(lines)
1852 return self.highlight(text, mime=self.mime)
1854 def editor_form(self, preview=None):
1855 """Generate the HTML for the editor."""
1857 author = self.request.get_author()
1858 lines = []
1859 try:
1860 page_file = self.storage.open_page(self.title)
1861 lines = self.storage.page_lines(page_file)
1862 (rev, old_date, old_author,
1863 old_comment) = self.storage.page_meta(self.title)
1864 comment = _(u'modified')
1865 if old_author == author:
1866 comment = old_comment
1867 except werkzeug.exceptions.NotFound:
1868 comment = _(u'created')
1869 rev = -1
1870 if preview:
1871 lines = preview
1872 comment = self.request.form.get('comment', comment)
1873 html = werkzeug.html
1874 yield u'<form action="" method="POST" class="editor"><div>'
1875 yield u'<textarea name="text" cols="80" rows="20" id="editortext">'
1876 for line in lines:
1877 yield werkzeug.escape(line)
1878 yield u"""</textarea>"""
1879 yield html.input(type_="hidden", name="parent", value=rev)
1880 yield html.label(html(_(u'Comment')), html.input(name="comment",
1881 value=comment), class_="comment")
1882 yield html.label(html(_(u'Author')), html.input(name="author",
1883 value=self.request.get_author()), class_="comment")
1884 yield html.div(
1885 html.input(type_="submit", name="save", value=_(u'Save')),
1886 html.input(type_="submit", name="preview", value=_(u'Preview')),
1887 html.input(type_="submit", name="cancel", value=_(u'Cancel')),
1888 class_="buttons")
1889 yield u'</div></form>'
1890 if preview:
1891 yield html.h1(html(_(u'Preview, not saved')), class_="preview")
1892 for part in self.view_content(preview):
1893 yield part
1895 def highlight(self, text, mime=None, syntax=None, line_no=0):
1896 """Colorize the source code."""
1898 try:
1899 import pygments
1900 import pygments.util
1901 import pygments.lexers
1902 import pygments.formatters
1903 import pygments.styles
1904 except ImportError:
1905 yield werkzeug.html.pre(werkzeug.html(text))
1906 return
1907 if 'tango' in pygments.styles.STYLE_MAP:
1908 style = 'tango'
1909 else:
1910 style = 'friendly'
1911 formatter = pygments.formatters.HtmlFormatter(style=style)
1912 formatter.line_no = line_no
1914 def wrapper(source, outfile):
1915 """Wrap each line of formatted output."""
1917 yield 0, '<div class="highlight"><pre>'
1918 for lineno, line in source:
1919 if line.strip():
1920 yield (lineno,
1921 werkzeug.html.div(line.strip('\n'), id_="line_%d" %
1922 formatter.line_no))
1923 else:
1924 yield (lineno,
1925 werkzeug.html.div('&nbsp;', id_="line_%d" %
1926 formatter.line_no))
1927 formatter.line_no += 1
1928 yield 0, '</pre></div>'
1930 formatter.wrap = wrapper
1931 try:
1932 if mime:
1933 lexer = pygments.lexers.get_lexer_for_mimetype(mime)
1934 elif syntax:
1935 lexer = pygments.lexers.get_lexer_by_name(syntax)
1936 else:
1937 lexer = pygments.lexers.guess_lexer(text)
1938 except pygments.util.ClassNotFound:
1939 yield werkzeug.html.pre(werkzeug.html(text))
1940 return
1941 if self.request.print_highlight_styles:
1942 css = formatter.get_style_defs('.highlight')
1943 self.request.print_highlight_styles = False
1944 yield werkzeug.html.style(werkzeug.html(css), type="text/css")
1945 html = pygments.highlight(text, lexer, formatter)
1946 yield html
1948 def diff_content(self, from_rev, to_rev):
1949 """Generate the HTML markup for a diff."""
1951 def infiniter(iterator):
1952 """Turn an iterator into an infinite one, padding it with None"""
1954 for i in iterator:
1955 yield i
1956 while True:
1957 yield None
1959 text = self.storage.revision_text(self.title, from_rev)
1960 other_text = self.storage.revision_text(self.title, to_rev)
1961 diff = difflib._mdiff(text.split('\n'), other_text.split('\n'))
1962 stack = []
1964 mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])')
1965 yield u'<pre class="diff">'
1966 for old_line, new_line, changed in diff:
1967 old_no, old_text = old_line
1968 new_no, new_text = new_line
1969 line_no = (new_no or old_no or 1)-1
1970 if changed:
1971 yield u'<div class="change" id="line_%d">' % line_no
1972 old_iter = infiniter(mark_re.finditer(old_text))
1973 new_iter = infiniter(mark_re.finditer(new_text))
1974 old = old_iter.next()
1975 new = new_iter.next()
1976 buff = u''
1977 while old or new:
1978 while old and old.group(1):
1979 if buff:
1980 yield werkzeug.escape(buff)
1981 buff = u''
1982 yield u'<del>%s</del>' % werkzeug.escape(old.group(1))
1983 old = old_iter.next()
1984 while new and new.group(1):
1985 if buff:
1986 yield werkzeug.escape(buff)
1987 buff = u''
1988 yield u'<ins>%s</ins>' % werkzeug.escape(new.group(1))
1989 new = new_iter.next()
1990 if new:
1991 buff += new.group(2)
1992 old = old_iter.next()
1993 new = new_iter.next()
1994 if buff:
1995 yield werkzeug.escape(buff)
1996 yield u'</div>'
1997 else:
1998 yield u'<div class="orig" id="line_%d">%s</div>' % (
1999 line_no, werkzeug.escape(old_text))
2000 yield u'</pre>'
2002 class WikiPageWiki(WikiPageText):
2003 """Pages of with wiki markup use this for display."""
2005 def view_content(self, lines=None):
2006 if lines is None:
2007 f = self.storage.open_page(self.title)
2008 lines = self.storage.page_lines(f)
2009 if self.wiki.icon_page and self.wiki.icon_page in self.storage:
2010 icons = self.index.page_links_and_labels(self.wiki.icon_page)
2011 smilies = dict((emo, link) for (link, emo) in icons)
2012 else:
2013 smilies = None
2014 content = WikiParser(lines, self.wiki_link, self.wiki_image,
2015 self.highlight, self.wiki_math, smilies)
2016 return content
2018 def wiki_math(self, math):
2019 math_url = self.config.get('math_url',
2020 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2021 if '%s' in math_url:
2022 url = math_url % werkzeug.url_quote(math)
2023 else:
2024 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2025 label = werkzeug.escape(math, quote=True)
2026 return werkzeug.html.img(src=url, alt=label, class_="math")
2028 def dependencies(self):
2029 dependencies = WikiPage.dependencies(self)
2030 for link in self.index.page_links(self.title):
2031 if link not in self.storage:
2032 dependencies.add(werkzeug.url_quote(link))
2033 return dependencies
2035 class WikiPageFile(WikiPage):
2036 """Pages of all other mime types use this for display."""
2038 def view_content(self, lines=None):
2039 if self.title not in self.storage:
2040 raise werkzeug.exceptions.NotFound()
2041 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2042 (self.request.get_download_url(self.title),
2043 werkzeug.escape(self.title), self.mime)]
2044 return content
2046 def editor_form(self, preview=None):
2047 author = self.request.get_author()
2048 if self.title in self.storage:
2049 comment = _(u'changed')
2050 (rev, old_date, old_author,
2051 old_comment) = self.storage.page_meta(self.title)
2052 if old_author == author:
2053 comment = old_comment
2054 else:
2055 comment = _(u'uploaded')
2056 rev = -1
2057 html = werkzeug.html
2058 yield html.p(html(
2059 _(u"This is a binary file, it can't be edited on a wiki. "
2060 u"Please upload a new version instead.")))
2061 yield html.form(html.div(
2062 html.div(html.input(type_="file", name="data"), class_="upload"),
2063 html.input(type_="hidden", name="parent", value=rev),
2064 html.label(html(_(u'Comment')), html.input(name="comment",
2065 value=comment)),
2066 html.label(html(_(u'Author')), html.input(name="author",
2067 value=author)),
2068 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2069 html.input(type_="submit", name="cancel",
2070 value=_(u'Cancel')),
2071 class_="buttons")), action="", method="POST", class_="editor",
2072 enctype="multipart/form-data")
2074 class WikiPageImage(WikiPageFile):
2075 """Pages of mime type image/* use this for display."""
2077 render_file = '128x128.png'
2079 def view_content(self, lines=None):
2080 if self.title not in self.storage:
2081 raise werkzeug.exceptions.NotFound()
2082 content = ['<img src="%s" alt="%s">'
2083 % (self.request.get_url(self.title, self.wiki.render),
2084 werkzeug.escape(self.title))]
2085 return content
2087 def render_mime(self):
2088 """Give the filename and mime type of the rendered thumbnail."""
2090 if not Image:
2091 raise NotImplementedError('No Image library available')
2092 return self.render_file, 'image/png'
2094 def render_cache(self, cache_dir):
2095 """Render the thumbnail and save in the cache."""
2097 if not Image:
2098 raise NotImplementedError('No Image library available')
2099 page_file = self.storage.open_page(self.title)
2100 cache_path = os.path.join(cache_dir, self.render_file)
2101 cache_file = open(cache_path, 'wb')
2102 try:
2103 im = Image.open(page_file)
2104 im = im.convert('RGBA')
2105 im.thumbnail((128, 128), Image.ANTIALIAS)
2106 im.save(cache_file,'PNG')
2107 except IOError:
2108 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2109 cache_file.close()
2110 return cache_path
2112 class WikiPageCSV(WikiPageFile):
2113 """Display class for type text/csv."""
2115 def view_content(self, lines=None):
2116 if self.title not in self.storage:
2117 raise werkzeug.exceptions.NotFound()
2118 import csv
2119 csv_file = self.storage.open_page(self.title)
2120 reader = csv.reader(csv_file)
2121 html_title = werkzeug.escape(self.title, quote=True)
2122 yield u'<table id="%s" class="csvfile">' % html_title
2123 try:
2124 for row in reader:
2125 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2126 for cell in row))
2127 except csv.Error, e:
2128 yield u'</table>'
2129 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2130 % (html_title, reader.line_num, e))
2131 finally:
2132 csv_file.close()
2133 yield u'</table>'
2135 class WikiTitleConverter(werkzeug.routing.PathConverter):
2136 """Behaves like the path converter, except ".." components are rejected."""
2138 # RLS, FIXME NOTE: to_url appears to control "Title" rendering at top of pages...
2140 def __init__FIXME_RLS(self, junk):
2141 # If __init__FIXME_RLS is changed to __init__ then the class
2142 # is initialized, but backlinks fail... Don't know why ! 2009-11-27 RLS
2143 """
2144 Initialize for to_url and url_quote.
2145 """
2146 # RegEx matching path components that contain ".."
2147 self.dot_dot_re = re.compile('(^|/)\.\.(?=(/|$))')
2149 def to_url(self, value):
2150 # Kludge because __init__ breaks backlinks.
2151 self.dot_dot_re = re.compile('(^|/)\.\.(?=(/|$))')
2152 if self.dot_dot_re.search(value):
2153 raise werkzeug.exceptions.Forbidden(
2154 u'to_url: Name contains ".." path component: %s' % value)
2155 return werkzeug.url_quote(value, self.map.charset, safe='/')
2157 class WikiAllConverter(werkzeug.routing.BaseConverter):
2158 """Matches everything."""
2160 regex='.*'
2163 class Wiki(object):
2164 """
2165 The main class of the wiki, handling initialization of the whole
2166 application and most of the logic.
2167 """
2168 storage_class = WikiStorage
2169 titleConverter_class = WikiTitleConverter
2170 index_class = WikiSearch
2171 mime_map = {
2172 'text': WikiPageText,
2173 'application/javascript': WikiPageText,
2174 'text/csv': WikiPageCSV,
2175 'text/x-wiki': WikiPageWiki,
2176 'image': WikiPageImage,
2177 '': WikiPageFile,
2179 icon = base64.b64decode(
2180 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2181 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2182 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2183 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2184 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2185 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2186 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2187 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2188 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2189 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2190 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2191 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2192 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2193 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2194 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2196 def __init__(self, config):
2197 self.dead = False
2198 self.config = config
2199 self.language = config.get('language', None)
2200 global _
2201 if self.language is not None:
2202 try:
2203 _ = gettext.translation('hatta', 'locale',
2204 languages=[self.language]).ugettext
2205 except IOError:
2206 _ = gettext.translation('hatta', fallback=True,
2207 languages=[self.language]).ugettext
2208 else:
2209 _ = gettext.translation('hatta', fallback=True).ugettext
2210 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2211 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2212 self.page_charset = config.get('page_charset', 'utf-8')
2213 self.menu_page = self.config.get('menu_page', u'Menu')
2214 self.front_page = self.config.get('front_page', u'Home')
2215 self.logo_page = self.config.get('logo_page', u'logo.png')
2216 self.locked_page = self.config.get('locked_page', u'Locked')
2217 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2218 self.style_page = self.config.get('style_page', u'style.css')
2219 self.read_only = self.config.get_bool('read_only', False)
2220 self.script_page = self.config.get('script_page', None)
2221 self.icon_page = self.config.get('icon_page', None)
2223 self.storage = self.storage_class(self.path, self.page_charset)
2224 if not os.path.isdir(self.cache):
2225 os.makedirs(self.cache)
2226 reindex = True
2227 else:
2228 reindex = False
2229 self.index = self.index_class(self.cache, self.language, self.storage)
2230 R = werkzeug.routing.Rule
2231 self.url_map = werkzeug.routing.Map([
2232 R('/', defaults={'title': self.front_page},
2233 endpoint=self.view, methods=['GET', 'HEAD']),
2234 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2235 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2236 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2237 R('/+history/', endpoint=self.recent_changes,
2238 methods=['GET', 'HEAD']),
2239 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2240 endpoint=self.diff, methods=['GET']),
2241 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2242 methods=['GET']),
2243 R('/+history/<title:title>', endpoint=self.history,
2244 methods=['GET', 'HEAD']),
2245 R('/+download/<title:title>', endpoint=self.download,
2246 methods=['GET', 'HEAD']),
2247 R('/+render/<title:title>', endpoint=self.render,
2248 methods=['GET', 'HEAD']),
2249 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2250 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2251 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2252 R('/favicon.ico', endpoint=self.favicon, methods=['GET', 'HEAD']),
2253 R('/robots.txt', endpoint=self.robots, methods=['GET']),
2254 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2255 R('/+search/<title:title>', endpoint=self.backlinks,
2256 methods=['GET', 'POST']),
2257 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2258 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2259 methods=['GET', 'POST', 'HEAD']),
2260 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2262 def get_page(self, request, title):
2263 """Creates a page object based on page's mime type"""
2265 if title:
2266 mime = self.storage.page_mime(title)
2267 major, minor = mime.split('/', 1)
2268 plus_pos = minor.find('+')
2269 if plus_pos>0:
2270 minor_base = minor[plus_pos]
2271 else:
2272 minor_base = ''
2273 try:
2274 page_class = self.mime_map[mime]
2275 except KeyError:
2276 try:
2277 page_class = self.mime_map['/'.join([major, minor_base])]
2278 except KeyError:
2279 try:
2280 page_class = self.mime_map[major]
2281 except KeyError:
2282 page_class = self.mime_map['']
2283 else:
2284 page_class = WikiPage
2285 mime = ''
2286 return page_class(self, request, title, mime)
2288 def view(self, request, title):
2289 page = self.get_page(request, title)
2290 try:
2291 content = page.view_content()
2292 except werkzeug.exceptions.NotFound:
2293 url = request.get_url(title, self.edit, external=True)
2294 return werkzeug.routing.redirect(url, code=303)
2295 html = page.render_content(content)
2296 dependencies = page.dependencies()
2297 etag = '/(%s)' % u','.join(dependencies)
2298 return self.response(request, title, html, etag=etag)
2300 def revision(self, request, title, rev):
2301 text = self.storage.revision_text(title, rev)
2302 link = werkzeug.html.a(werkzeug.html(title),
2303 href=request.get_url(title))
2304 content = [
2305 werkzeug.html.p(
2306 werkzeug.html(
2307 _(u'Content of revision %(rev)d of page %(title)s:'))
2308 % {'rev': rev, 'title': link }),
2309 werkzeug.html.pre(werkzeug.html(text)),
2311 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2312 page = self.get_page(request, title)
2313 html = page.render_content(content, special_title)
2314 response = self.response(request, title, html, rev=rev, etag='/old')
2315 return response
2317 def check_lock(self, title):
2318 if self.read_only:
2319 raise werkzeug.exceptions.Forbidden(_("This site is read-only."))
2320 if self.script_page and title == self.script_page:
2321 raise werkzeug.exceptions.Forbidden(_("""Can't edit live scripts.
2322 To edit this page remove it from the script_page option first."""))
2323 if self.locked_page in self.storage:
2324 if title in self.index.page_links(self.locked_page):
2325 raise werkzeug.exceptions.Forbidden(_("This page is locked."))
2327 def save(self, request, title):
2328 self.check_lock(title)
2329 url = request.get_url(title)
2330 if request.form.get('cancel'):
2331 if title not in self.storage:
2332 url = request.get_url(self.front_page)
2333 if request.form.get('preview'):
2334 text = request.form.get("text")
2335 if text is not None:
2336 lines = text.split('\n')
2337 else:
2338 lines = [werkzeug.html.p(werkzeug.html(
2339 _(u'No preview for binaries.')))]
2340 return self.edit(request, title, preview=lines)
2341 elif request.form.get('save'):
2342 comment = request.form.get("comment", "")
2343 author = request.get_author()
2344 text = request.form.get("text")
2345 try:
2346 parent = int(request.form.get("parent"))
2347 except (ValueError, TypeError):
2348 parent = None
2349 self.storage.reopen()
2350 self.index.update()
2351 if text is not None:
2352 if title == self.locked_page:
2353 for link, label in WikiParser.extract_links(text):
2354 if title == link:
2355 raise werkzeug.exceptions.Forbidden()
2356 if u'href="' in comment or u'http:' in comment:
2357 raise werkzeug.exceptions.Forbidden()
2358 if text.strip() == '':
2359 self.storage.delete_page(title, author, comment)
2360 url = request.get_url(self.front_page)
2361 else:
2362 self.storage.save_text(title, text, author, comment, parent)
2363 else:
2364 text = u''
2365 upload = request.files['data']
2366 f = upload.stream
2367 if f is not None and upload.filename is not None:
2368 try:
2369 self.storage.save_file(title, f.tmpname, author,
2370 comment, parent)
2371 except AttributeError:
2372 self.storage.save_data(title, f.read(), author,
2373 comment, parent)
2374 else:
2375 self.storage.delete_page(title, author, comment)
2376 url = request.get_url(self.front_page)
2377 self.index.update_page(title, text=text)
2378 response = werkzeug.routing.redirect(url, code=303)
2379 response.set_cookie('author',
2380 werkzeug.url_quote(request.get_author()),
2381 max_age=604800)
2382 return response
2384 def edit(self, request, title, preview=None):
2385 self.check_lock(title)
2386 page = self.get_page(request, title)
2387 content = page.editor_form(preview)
2388 special_title = _(u'Editing "%(title)s"') % {'title': title}
2389 html = page.render_content(content, special_title)
2390 if title not in self.storage:
2391 return werkzeug.Response(html, mimetype="text/html",
2392 status='404 Not found')
2393 elif preview:
2394 return werkzeug.Response(html, mimetype="text/html")
2395 else:
2396 return self.response(request, title, html, '/edit')
2398 def atom(self, request):
2399 date_format = "%Y-%m-%dT%H:%M:%SZ"
2400 first_date = datetime.datetime.now()
2401 now = first_date.strftime(date_format)
2402 body = []
2403 first_title = u''
2404 count = 0
2405 unique_titles = {}
2406 for title, rev, date, author, comment in self.storage.history():
2407 if title in unique_titles:
2408 continue
2409 unique_titles[title] = True
2410 count += 1
2411 if count > 10:
2412 break
2413 if not first_title:
2414 first_title = title
2415 first_rev = rev
2416 first_date = date
2417 item = u"""<entry>
2418 <title>%(title)s</title>
2419 <link href="%(page_url)s" />
2420 <content>%(comment)s</content>
2421 <updated>%(date)s</updated>
2422 <author>
2423 <name>%(author)s</name>
2424 <uri>%(author_url)s</uri>
2425 </author>
2426 <id>%(url)s</id>
2427 </entry>""" % {
2428 'title': werkzeug.escape(title),
2429 'page_url': request.adapter.build(self.view, {'title': title},
2430 force_external=True),
2431 'comment': werkzeug.escape(comment),
2432 'date': date.strftime(date_format),
2433 'author': werkzeug.escape(author),
2434 'author_url': request.adapter.build(self.view,
2435 {'title': author},
2436 force_external=True),
2437 'url': request.adapter.build(self.revision,
2438 {'title': title, 'rev': rev},
2439 force_external=True),
2441 body.append(item)
2442 content = u"""<?xml version="1.0" encoding="utf-8"?>
2443 <feed xmlns="http://www.w3.org/2005/Atom">
2444 <title>%(title)s</title>
2445 <link rel="self" href="%(atom)s"/>
2446 <link href="%(home)s"/>
2447 <id>%(home)s</id>
2448 <updated>%(date)s</updated>
2449 <logo>%(logo)s</logo>
2450 %(body)s
2451 </feed>""" % {
2452 'title': self.site_name,
2453 'home': request.adapter.build(self.view, force_external=True),
2454 'atom': request.adapter.build(self.atom, force_external=True),
2455 'date': first_date.strftime(date_format),
2456 'logo': request.adapter.build(self.download,
2457 {'title': self.logo_page},
2458 force_external=True),
2459 'body': u''.join(body),
2461 response = self.response(request, 'atom', content, '/atom',
2462 'application/xml', first_rev, first_date)
2463 response.set_etag('/atom/%d' % self.storage.repo_revision())
2464 response.make_conditional(request)
2465 return response
2467 def rss(self, request):
2468 """Serve an RSS feed of recent changes."""
2470 first_date = datetime.datetime.now()
2471 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2472 rss_body = []
2473 first_title = u''
2474 count = 0
2475 unique_titles = {}
2476 for title, rev, date, author, comment in self.storage.history():
2477 if title in unique_titles:
2478 continue
2479 unique_titles[title] = True
2480 count += 1
2481 if count > 10:
2482 break
2483 if not first_title:
2484 first_title = title
2485 first_rev = rev
2486 first_date = date
2487 item = (u'<item><title>%s</title><link>%s</link>'
2488 u'<description>%s</description><pubDate>%s</pubDate>'
2489 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2490 werkzeug.escape(title),
2491 request.adapter.build(self.view, {'title': title},
2492 force_external=True),
2493 werkzeug.escape(comment),
2494 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2495 werkzeug.escape(author),
2496 request.adapter.build(self.revision,
2497 {'title': title, 'rev': rev})
2498 ))
2499 rss_body.append(item)
2500 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2501 <rss version="2.0"
2502 xmlns:dc="http://purl.org/dc/elements/1.1/"
2503 xmlns:atom="http://www.w3.org/2005/Atom"
2505 <channel>
2506 <title>%s</title>
2507 <atom:link href="%s" rel="self" type="application/rss+xml" />
2508 <link>%s</link>
2509 <description>%s</description>
2510 <generator>Hatta Wiki</generator>
2511 <language>en</language>
2512 <lastBuildDate>%s</lastBuildDate>
2514 """ % (
2515 werkzeug.escape(self.site_name),
2516 request.adapter.build(self.rss),
2517 request.adapter.build(self.recent_changes),
2518 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2519 u'in this feed.')),
2520 first_date,
2522 content = [rss_head]+rss_body+[u'</channel></rss>']
2523 response = self.response(request, 'rss', content, '/rss',
2524 'application/xml', first_rev, first_date)
2525 response.set_etag('/rss/%d' % self.storage.repo_revision())
2526 response.make_conditional(request)
2527 return response
2529 def response(self, request, title, content, etag='', mime='text/html',
2530 rev=None, date=None, size=None):
2531 """Create a WikiResponse for a page."""
2533 response = WikiResponse(content, mimetype=mime)
2534 if rev is None:
2535 inode, _size, mtime = self.storage.page_file_meta(title)
2536 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2537 inode, mtime))
2538 if size == -1:
2539 size = _size
2540 else:
2541 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2542 rev))
2543 if size:
2544 response.content_length = size
2545 response.make_conditional(request)
2546 return response
2548 def download(self, request, title):
2549 """Serve the raw content of a page."""
2551 mime = self.storage.page_mime(title)
2552 if mime == 'text/x-wiki':
2553 mime = 'text/plain'
2554 f = self.storage.open_page(title)
2555 response = self.response(request, title, f, '/download', mime, size=-1)
2556 return response
2558 def render(self, request, title):
2559 """Serve a thumbnail or otherwise rendered content."""
2561 def file_time_and_size(file_path):
2562 """Get file's modification timestamp and its size."""
2564 try:
2565 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2566 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2567 except OSError:
2568 st_mtime = 0
2569 st_size = None
2570 return st_mtime, st_size
2572 def rm_temp_dir(dir_path):
2573 """Delete the directory with subdirectories."""
2575 for root, dirs, files in os.walk(dir_path, topdown=False):
2576 for name in files:
2577 try:
2578 os.remove(os.path.join(root, name))
2579 except OSError:
2580 pass
2581 for name in dirs:
2582 try:
2583 os.rmdir(os.path.join(root, name))
2584 except OSError:
2585 pass
2586 try:
2587 os.rmdir(dir_path)
2588 except OSError:
2589 pass
2591 page = self.get_page(request, title)
2592 try:
2593 cache_filename, cache_mime = page.render_mime()
2594 render = page.render_cache
2595 except (AttributeError, NotImplementedError):
2596 return self.download(request, title)
2598 cache_dir = os.path.join(self.cache, 'render',
2599 werkzeug.url_quote(title, safe=''))
2600 cache_file = os.path.join(cache_dir, cache_filename)
2601 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2602 cache_mtime, cache_size = file_time_and_size(cache_file)
2603 if page_mtime > cache_mtime:
2604 if not os.path.exists(cache_dir):
2605 os.makedirs(cache_dir)
2606 try:
2607 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2608 result_file = render(temp_dir)
2609 mercurial.util.rename(result_file, cache_file)
2610 finally:
2611 rm_temp_dir(temp_dir)
2612 f = open(cache_file)
2613 response = self.response(request, title, f, '/render', cache_mime,
2614 size=cache_size)
2615 return response
2617 def undo(self, request, title):
2618 """Revert a change to a page."""
2620 self.check_lock(title)
2621 rev = None
2622 for key in request.form:
2623 try:
2624 rev = int(key)
2625 except ValueError:
2626 pass
2627 author = request.get_author()
2628 if rev is not None:
2629 try:
2630 parent = int(request.form.get("parent"))
2631 except (ValueError, TypeError):
2632 parent = None
2633 self.storage.reopen()
2634 self.index.update()
2635 if rev == 0:
2636 comment = _(u'Delete page %(title)s') % {'title': title}
2637 data = ''
2638 self.storage.delete_page(title, author, comment)
2639 else:
2640 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2641 'rev': rev, 'title': title}
2642 data = self.storage.page_revision(title, rev-1)
2643 self.storage.save_data(title, data, author, comment, parent)
2644 self.index.update_page(title, data=data)
2645 url = request.adapter.build(self.history, {'title': title},
2646 method='GET', force_external=True)
2647 return werkzeug.redirect(url, 303)
2649 def history(self, request, title):
2650 """Display history of changes of a page."""
2652 page = self.get_page(request, title)
2653 content = page.render_content(page.history_list(),
2654 _(u'History of "%(title)s"') % {'title': title})
2655 response = self.response(request, title, content, '/history')
2656 return response
2658 recent_changes_item_template = Template(u"""<li>
2659 <a href="${date_url}">${date_html}</a>
2660 ${page_link} . . . . ${author_link}
2661 <div class="comment">${comment}</div></li>""")
2663 def recent_changes(self, request):
2664 """Serve the recent changes page."""
2666 def changes_list(page):
2667 """Generate the content of the recent changes page."""
2669 yield u'<ul>'
2670 last = {}
2671 lastrev = {}
2672 count = 0
2673 for title, rev, date, author, comment in self.storage.history():
2674 if (author, comment) == last.get(title, (None, None)):
2675 continue
2676 count += 1
2677 if count > 100:
2678 break
2679 if rev > 0:
2680 date_url = request.adapter.build(self.diff, {
2681 'title': title,
2682 'from_rev': rev-1,
2683 'to_rev': lastrev.get(title, rev)
2684 })
2685 elif rev == 0:
2686 date_url = request.adapter.build(self.revision, {
2687 'title': title, 'rev': rev})
2688 else:
2689 date_url = request.adapter.build(self.history, {
2690 'title': title})
2691 last[title] = author, comment
2692 lastrev[title] = rev
2694 yield self.recent_changes_item_template.render(
2695 date_url=date_url,
2696 date_html=page.date_html(date),
2697 page_link=page.wiki_link(title),
2698 author_link=page.wiki_link(author),
2699 comment=werkzeug.escape(comment),
2701 yield u'</ul>'
2703 page = self.get_page(request, u'history')
2704 content = page.render_content(changes_list(page), _(u'Recent changes'))
2705 response = WikiResponse(content, mimetype='text/html')
2706 response.set_etag('/history/%d' % self.storage.repo_revision())
2707 response.make_conditional(request)
2708 return response
2710 def diff(self, request, title, from_rev, to_rev):
2711 """Show the differences between specified revisions."""
2713 page = self.get_page(request, title)
2714 diff = page.diff_content(from_rev, to_rev)
2715 from_url = request.adapter.build(self.revision,
2716 {'title': title, 'rev': from_rev})
2717 to_url = request.adapter.build(self.revision,
2718 {'title': title, 'rev': to_rev})
2719 content = itertools.chain(
2720 [werkzeug.html.p(werkzeug.html(_(u'Differences between revisions '
2721 u'%(link1)s and %(link2)s of page %(link)s.')) % {
2722 'link1': werkzeug.html.a(str(from_rev), href=from_url),
2723 'link2': werkzeug.html.a(str(to_rev), href=to_url),
2724 'link': werkzeug.html.a(werkzeug.html(title),
2725 href=request.get_url(title))
2726 })], diff)
2727 special_title = _(u'Diff for "%(title)s"') % {'title': title}
2728 html = page.render_content(content, special_title)
2729 response = werkzeug.Response(html, mimetype='text/html')
2730 return response
2732 search_item_template = Template(u"""<li>
2733 <b>${link}</b> <i>(${score})</i><div class="snippet">${snippet}</div>
2734 </li>""")
2736 def search(self, request):
2737 """Serve the search results page."""
2739 def page_index(page):
2740 """Display the index of all pages"""
2742 yield u'<p>%s</p><ul class="index">' % werkzeug.escape(
2743 _(u'Index of all pages.'))
2744 for title in sorted(self.storage.all_pages()):
2745 yield werkzeug.html.li(page.wiki_link(title))
2746 yield u'</ul>'
2748 def search_snippet(title, words):
2749 """Extract a snippet of text for search results."""
2751 try:
2752 text = self.storage.page_text(title)
2753 except werkzeug.exceptions.NotFound:
2754 return u''
2755 regexp = re.compile(u"|".join(re.escape(w) for w in words),
2756 re.U|re.I)
2757 match = regexp.search(text)
2758 if match is None:
2759 return u""
2760 position = match.start()
2761 min_pos = max(position - 60, 0)
2762 max_pos = min(position + 60, len(text))
2763 snippet = werkzeug.escape(text[min_pos:max_pos])
2764 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
2765 html = regexp.sub(highlighted, snippet)
2766 return html
2768 def page_search(words, page):
2769 """Display the search results."""
2771 self.storage.reopen()
2772 self.index.update()
2773 result = sorted(self.index.find(words), key=lambda x:-x[0])
2774 yield u'<p>%s</p><ul class="search">' % werkzeug.escape(
2775 _(u'%d page(s) containing all words:') % len(result))
2776 for score, title in result:
2777 yield self.search_item_template.render(
2778 link=page.wiki_link(title),
2779 score=score,
2780 snippet=search_snippet(title, words),
2782 yield u'</ul>'
2784 query = request.values.get('q', u'').strip()
2785 page = self.get_page(request, '')
2786 if not query:
2787 content = page_index(page)
2788 title = _(u'Page index')
2789 else:
2790 words = tuple(self.index.split_text(query, stop=False))
2791 if not words:
2792 words = (query,)
2793 title = _(u'Searching for "%s"') % u" ".join(words)
2794 content = page_search(words, page)
2795 html = page.render_content(content, title)
2796 return WikiResponse(html, mimetype='text/html')
2798 def backlinks(self, request, title):
2799 """Serve the page with backlinks."""
2801 def backlink_list(page):
2802 """Generate html for the backlinks list"""
2804 yield u'<p>%s</p><ul class="backlinks">' % (
2805 _(u'Pages that contain a link to %s.')
2806 % page.wiki_link(title))
2807 for link in self.index.page_backlinks(title):
2808 yield werkzeug.html.li(page.wiki_link(link))
2809 yield u'</ul>'
2811 self.storage.reopen()
2812 self.index.update()
2813 page = self.get_page(request, title)
2814 html = page.render_content(backlink_list(page),
2815 _(u'Links to "%s"') % title)
2816 response = WikiResponse(html, mimetype='text/html')
2817 response.set_etag('/search/%d' % self.storage.repo_revision())
2818 response.make_conditional(request)
2819 return response
2821 def favicon(self, request):
2822 """Serve the default favicon."""
2824 title = 'favicon.ico'
2825 if title in self.storage:
2826 return self.download(request, title)
2827 return werkzeug.Response(self.icon, mimetype='image/x-icon')
2829 def hgweb(self, request, path=None):
2830 """Serve the pages repository on the web like a normal hg repository."""
2832 if not self.config.get_bool('hgweb', False):
2833 raise werkzeug.exceptions.Forbidden('Repository access disabled.')
2834 app = mercurial.hgweb.request.wsgiapplication(
2835 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
2836 def hg_app(env, start):
2837 env = request.environ
2838 prefix='/+hg'
2839 if env['PATH_INFO'].startswith(prefix):
2840 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
2841 env["SCRIPT_NAME"] += prefix
2842 return app(env, start)
2843 return hg_app
2845 def robots(self, request):
2846 """Serve the robots directives."""
2848 title = 'robots.txt'
2849 if title in self.storage:
2850 return self.download(request, title)
2851 robots = ('User-agent: *\r\n'
2852 'Disallow: /+edit\r\n'
2853 'Disallow: /+feed\r\n'
2854 'Disallow: /+history\r\n'
2855 'Disallow: /+search\r\n'
2856 'Disallow: /+hg\r\n'
2858 return werkzeug.Response(robots, mimetype='text/plain')
2860 def _check_special(self, request):
2861 """
2862 Ensures that special requests come from localhost only.
2864 This seems reasonable to forbid remote URL requests to throw
2865 exceptions or kill the wiki :)
2866 """
2868 if not request.remote_addr.startswith('127.'):
2869 raise werkzeug.exceptions.Forbidden()
2871 def die(self, request):
2872 """Terminate the standalone server if invoked from localhost."""
2874 self._check_special(request)
2875 def agony():
2876 yield u'Oh dear!'
2877 self.dead = True
2878 return werkzeug.Response(agony(), mimetype='text/plain')
2880 # def test_exception(self, request):
2881 # """Used to test multi-thread thread exception handling."""
2882 # self._check_special(request)
2883 # def throw_up():
2884 # yield u'Bleeee *hyk*'
2885 # raise RuntimeError('This is a test exception.')
2886 # return werkzeug.Response(throw_up(), mimetype='text/plain')
2888 @werkzeug.responder
2889 def application(self, environ, start):
2890 """The main application loop."""
2892 adapter = self.url_map.bind_to_environ(environ)
2893 request = WikiRequest(self, adapter, environ)
2894 try:
2895 try:
2896 endpoint, values = adapter.match()
2897 return endpoint(request, **values)
2898 except werkzeug.exceptions.HTTPException, err:
2899 return err
2900 finally:
2901 request.cleanup()
2902 del request
2903 del adapter
2905 def read_config():
2906 """Read and parse the config."""
2908 config = WikiConfig(
2909 # Here you can modify the configuration: uncomment and change the ones
2910 # you need. Note that it's better use environment variables or command
2911 # line switches.
2913 # interface='',
2914 # port=8080,
2915 # pages_path = 'docs',
2916 # cache_path = 'cache',
2917 # front_page = 'Home',
2918 # site_name = 'Hatta Wiki',
2919 # page_charset = 'UTF-8',
2921 config.parse_args()
2922 config.parse_files()
2923 # config.sanitize()
2924 return config
2926 def application(env, start):
2927 """Detect that we are being run as WSGI application."""
2929 global application
2930 config = read_config()
2931 script_dir = os.path.dirname(os.path.abspath(__file__))
2932 if config.get('pages_path') == None:
2933 config.set('pages_path', os.path.join(script_dir, 'docs'))
2934 if config.get('pages_cache') == None:
2935 config.set('cache_path', os.path.join(script_dir, 'cache'))
2936 wiki = Wiki(config)
2937 application = wiki.application
2938 return application(env, start)
2940 def main():
2941 """Start a standalone WSGI server."""
2943 config = read_config()
2944 wiki = Wiki(config)
2945 app = wiki.application
2947 host, port = (config.get('interface', '0.0.0.0'),
2948 int(config.get('port', 8080)))
2949 try:
2950 from cherrypy import wsgiserver
2951 except ImportError:
2952 try:
2953 from cherrypy import _cpwsgiserver as wsgiserver
2954 except ImportError:
2955 import wsgiref.simple_server
2956 server = wsgiref.simple_server.make_server(host, port, app)
2957 try:
2958 server.serve_forever()
2959 except KeyboardInterrupt:
2960 pass
2961 return
2962 apps = [('', app)]
2963 name = wiki.site_name
2964 server = wsgiserver.CherryPyWSGIServer((host, port), apps, server_name=name)
2965 try:
2966 server.start()
2967 except KeyboardInterrupt:
2968 server.stop()
2970 if __name__ == "__main__":
2971 main()