Hatta Numbered Lists Branch

view hatta.py @ 644:1372a7f3b1ce

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