Hatta Numbered Lists Branch

view hatta.py @ 645:cc65a44d2f23

Made the parser more generic by introducing a text2html generator
author Ben
date Mon Dec 07 21:00:07 2009 +0100 (2009-12-07)
parents 1372a7f3b1ce
children ef5c68799a02
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, 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 if smilies is not None:
738 self.smilies = smilies
739 self.compile_patterns()
740 self.headings = {}
741 self.stack = []
742 self.line_no = 0
744 def compile_patterns(self):
745 self.quote_re = re.compile(self.quote_pat, re.U)
746 self.heading_re = re.compile(self.heading_pat, re.U)
747 self.bullets_re = re.compile(self.bullets_pat, re.U)
748 self.list_re = re.compile(self.list_pat, re.U)
749 self.block_re = re.compile(ur"|".join("(?P<%s>%s)" % kv
750 for kv in self.block))
751 self.code_close_re = re.compile(ur"^\}\}\}\s*$", re.U)
752 self.macro_close_re = re.compile(ur"^>>\s*$", re.U)
753 self.conflict_close_re = re.compile(ur"^>>>>>>> other\s*$", re.U)
754 self.conflict_sep_re = re.compile(ur"^=======\s*$", re.U)
755 self.image_re = re.compile(self.image_pat, re.U)
756 self.markup = [(name, pattern) for (name, pattern) in self.markup
757 if name != 'smiley']
758 self.markup.insert(-2, ('smiley', ur"(^|\b|(?<=\s))"
759 ur"(?P<smiley_face>%s)"
760 ur"((?=[\s.,:;!?)/&=+-])|$)"
761 % ur"|".join(re.escape(k)
762 for k in self.smilies)))
763 self.markup_re = re.compile(ur"|".join("(?P<%s>%s)" % kv
764 for kv in self.markup))
766 def text2html(self, lines):
767 self.enumerated_lines = enumerate(lines)
768 return self.parse()
770 def __iter__(self):
771 return self.parse()
773 @classmethod
774 def extract_links(cls, text):
775 links = []
776 def link(addr, label=None, class_=None, image=None, alt=None, lineno=0):
777 if external_link(addr):
778 return u''
779 if '#' in addr:
780 addr, chunk = addr.split('#', 1)
781 if addr == u'':
782 return u''
783 links.append((addr, label))
784 return u''
785 lines = text.split('\n')
786 parser = cls(link, link)
787 for part in parser.text2html(text):
788 for ret in links:
789 yield ret
790 links[:] = []
792 def parse(self):
793 """Parse a list of lines of wiki markup, yielding HTML for it."""
795 self.headings = {}
796 self.stack = []
797 self.line_no = 0
799 def key(enumerated_line):
800 line_no, line = enumerated_line
801 match = self.block_re.match(line)
802 if match:
803 return match.lastgroup
804 return "paragraph"
806 for kind, block in itertools.groupby(self.enumerated_lines, key):
807 func = getattr(self, "_block_%s" % kind)
808 for part in func(block):
809 yield part
811 def parse_line(self, line):
812 """
813 Find all the line-level markup and return HTML for it.
815 """
817 for match in self.markup_re.finditer(line):
818 func = getattr(self, "_line_%s" % match.lastgroup)
819 yield func(match.groupdict())
821 def pop_to(self, stop):
822 """
823 Pop from the stack until the specified tag is encoutered.
824 Return string containing closing tags of everything popped.
825 """
826 tags = []
827 tag = None
828 try:
829 while tag != stop:
830 tag = self.stack.pop()
831 tags.append(tag)
832 except IndexError:
833 pass
834 return u"".join(u"</%s>" % tag for tag in tags)
836 def lines_until(self, close_re):
837 """Get lines from input until the closing markup is encountered."""
839 self.line_no, line = self.enumerated_lines.next()
840 while not close_re.match(line):
841 yield line.rstrip()
842 line_no, line = self.enumerated_lines.next()
844 # methods for the markup inside lines:
846 def _line_table(self, groups):
847 return groups["table"]
849 def _line_linebreak(self, groups):
850 return u'<br>'
852 def _line_smiley(self, groups):
853 smiley = groups["smiley_face"]
854 return self.wiki_image(self.smilies[smiley], smiley,
855 class_="smiley")
857 def _line_bold(self, groups):
858 if 'b' in self.stack:
859 return self.pop_to('b')
860 else:
861 self.stack.append('b')
862 return u"<b>"
864 def _line_italic(self, groups):
865 if 'i' in self.stack:
866 return self.pop_to('i')
867 else:
868 self.stack.append('i')
869 return u"<i>"
871 def _line_mono(self, groups):
872 if 'tt' in self.stack:
873 return self.pop_to('tt')
874 else:
875 self.stack.append('tt')
876 return u"<tt>"
878 def _line_punct(self, groups):
879 text = groups["punct"]
880 return self.punct.get(text, text)
882 def _line_mono(self, groups):
883 if 'tt' in self.stack:
884 return self.pop_to('tt')
885 else:
886 self.stack.append('tt')
887 return u"<tt>"
889 def _line_newline(self, groups):
890 return "\n"
892 def _line_text(self, groups):
893 return werkzeug.escape(groups["text"])
895 def _line_math(self, groups):
896 if self.wiki_math:
897 return self.wiki_math(groups["math_text"])
898 else:
899 return "<var>%s</var>" % werkzeug.escape(groups["math_text"])
901 def _line_code(self, groups):
902 return u'<code>%s</code>' % werkzeug.escape(groups["code_text"])
904 def _line_free_link(self, groups):
905 groups['link_target'] = groups['free_link']
906 return self._line_link(groups)
908 def _line_camel_link(self, groups):
909 groups['link_target'] = groups['camel_link']
910 return self._line_link(groups)
912 def _line_camel_nolink(self, groups):
913 return werkzeug.escape(groups["camel_text"])
915 def _line_mail(self, groups):
916 addr = groups['mail']
917 groups['link_text'] = addr
918 if not addr.startswith(u'mailto:'):
919 addr = u'mailto:%s' % addr
920 groups['link_target'] = addr
921 return self._line_link(groups)
923 def _line_link(self, groups):
924 target = groups['link_target'].strip()
925 text = groups.get('link_text')
926 if not text:
927 text = target
928 if '#' in text:
929 text, chunk = text.split('#', 1)
930 match = self.image_re.match(text)
931 if match:
932 image = self._line_image(match.groupdict())
933 return self.wiki_link(target, text, image=image)
934 return self.wiki_link(target, text)
936 def _line_image(self, groups):
937 target = groups['image_target']
938 alt = groups.get('image_text')
939 if alt is None:
940 alt = target
941 return self.wiki_image(target, alt)
943 def _line_macro(self, groups):
944 name = groups['macro_name']
945 text = groups['macro_text'].strip()
946 return u'<span class="%s">%s</span>' % (
947 werkzeug.escape(name, quote=True),
948 werkzeug.escape(text))
950 # methods for the block (multiline) markup:
952 def _block_code(self, block):
953 for self.line_no, part in block:
954 inside = u"\n".join(self.lines_until(self.code_close_re))
955 yield werkzeug.html.pre(werkzeug.html(inside), class_="code",
956 id="line_%d" % self.line_no)
958 def _block_syntax(self, block):
959 for self.line_no, part in block:
960 syntax = part.lstrip('{#!').strip()
961 inside = u"\n".join(self.lines_until(self.code_close_re))
962 if self.wiki_syntax:
963 return self.wiki_syntax(inside, syntax=syntax,
964 line_no=self.line_no)
965 else:
966 return [werkzeug.html.div(werkzeug.html.pre(
967 werkzeug.html(inside), id="line_%d" % self.line_no),
968 class_="highlight")]
970 def _block_macro(self, block):
971 for self.line_no, part in block:
972 name = part.lstrip('<').strip()
973 inside = u"\n".join(self.lines_until(self.macro_close_re))
974 yield u'<div class="%s">%s</div>' % (
975 werkzeug.escape(name, quote=True),
976 werkzeug.escape(inside))
978 def _block_paragraph(self, block):
979 parts = []
980 first_line = None
981 for self.line_no, part in block:
982 if first_line is None:
983 first_line = self.line_no
984 parts.append(part)
985 text = u"".join(self.parse_line(u"".join(parts)))
986 yield werkzeug.html.p(text, self.pop_to(""), id="line_%d" % first_line)
988 def _block_indent(self, block):
989 parts = []
990 first_line = None
991 for self.line_no, part in block:
992 if first_line is None:
993 first_line = self.line_no
994 parts.append(part.rstrip())
995 text = u"\n".join(parts)
996 yield werkzeug.html.pre(werkzeug.html(text), id="line_%d" % first_line)
998 def _block_table(self, block):
999 first_line = None
1000 in_head = False
1001 for self.line_no, line in block:
1002 if first_line is None:
1003 first_line = self.line_no
1004 yield u'<table id="line_%d">' % first_line
1005 table_row = line.strip()
1006 is_header = table_row.startswith('|=') and table_row.endswith('=|')
1007 if not in_head and is_header:
1008 in_head = True
1009 yield '<thead>'
1010 elif in_head and not is_header:
1011 in_head = False
1012 yield '</thead>'
1013 yield '<tr>'
1014 in_cell = False
1015 in_th = False
1017 for part in self.parse_line(table_row):
1018 if part in ('=|', '|', '=|=', '|='):
1019 if in_cell:
1020 if in_th:
1021 yield '</th>'
1022 else:
1023 yield '</td>'
1024 in_cell = False
1025 if part in ('=|=', '|='):
1026 in_th = True
1027 else:
1028 in_th = False
1029 else:
1030 if not in_cell:
1031 if in_th:
1032 yield '<th>'
1033 else:
1034 yield '<td>'
1035 in_cell = True
1036 yield part
1037 if in_cell:
1038 if in_th:
1039 yield '</th>'
1040 else:
1041 yield '</td>'
1042 yield '</tr>'
1043 yield u'</table>'
1045 def _block_empty(self, block):
1046 yield u''
1048 def _block_rule(self, block):
1049 for self.line_no, line in block:
1050 yield werkzeug.html.hr()
1052 def _block_heading(self, block):
1053 for self.line_no, line in block:
1054 level = min(len(self.heading_re.match(line).group(0).strip()), 5)
1055 self.headings[level-1] = self.headings.get(level-1, 0)+1
1056 label = u"-".join(str(self.headings.get(i, 0))
1057 for i in range(level))
1058 yield werkzeug.html.a(name="head-%s" % label)
1059 yield u'<h%d id="line_%d">%s</h%d>' % (level, self.line_no,
1060 werkzeug.escape(line.strip("= \t\n\r\v")), level)
1062 def _block_bullets(self, block):
1063 level = 0
1064 in_ul = False
1065 for self.line_no, line in block:
1066 nest = len(self.bullets_re.match(line).group(0).strip())
1067 while nest > level:
1068 if in_ul:
1069 yield '<li>'
1070 yield '<ul id="line_%d">' % self.line_no
1071 in_ul = True
1072 level += 1
1073 while nest < level:
1074 yield '</li></ul>'
1075 in_ul = False
1076 level -= 1
1077 if nest == level and not in_ul:
1078 yield '</li>'
1079 content = line.lstrip().lstrip('*').strip()
1080 yield '<li>%s%s' % (u"".join(self.parse_line(content)),
1081 self.pop_to(""))
1082 in_ul = False
1083 yield '</li></ul>'*level
1085 def _block_list(self, block):
1086 level = 0
1087 in_ol = False
1088 for self.line_no, line in block:
1089 nest = len(self.list_re.match(line).group(0).strip())
1090 while nest > level:
1091 if in_ol:
1092 yield '<ol>'
1093 yield '<ol id="line_%d">' % self.line_no
1094 in_ol = True
1095 level += 1
1096 while nest < level:
1097 yield '</li></ol>'
1098 in_ol = False
1099 level -= 1
1100 if nest == level and not in_ol:
1101 yield '</li>'
1102 content = line.lstrip().lstrip('#').strip()
1103 yield '<li>%s%s' % (u"".join(self.parse_line(content)),
1104 self.pop_to(""))
1105 in_ol = False
1106 yield '</li></ol>'*level
1108 def _block_quote(self, block):
1109 level = 0
1110 in_p = False
1111 for self.line_no, line in block:
1112 nest = len(self.quote_re.match(line).group(0).strip())
1113 if nest == level:
1114 yield u'\n'
1115 while nest > level:
1116 if in_p:
1117 yield '%s</p>' % self.pop_to("")
1118 in_p = False
1119 yield '<blockquote>'
1120 level += 1
1121 while nest < level:
1122 if in_p:
1123 yield '%s</p>' % self.pop_to("")
1124 in_p = False
1125 yield '</blockquote>'
1126 level -= 1
1127 content = line.lstrip().lstrip('>').strip()
1128 if not in_p:
1129 yield '<p id="line_%d">' % self.line_no
1130 in_p = True
1131 yield u"".join(self.parse_line(content))
1132 if in_p:
1133 yield '%s</p>' % self.pop_to("")
1134 yield '</blockquote>'*level
1136 def _block_conflict(self, block):
1137 for self.line_no, part in block:
1138 yield u'<div class="conflict">'
1139 local = u"\n".join(self.lines_until(self.conflict_sep_re))
1140 yield werkzeug.html.pre(werkzeug.html(local),
1141 class_="local",
1142 id="line_%d" % self.line_no)
1143 other = u"\n".join(self.lines_until(self.conflict_close_re))
1144 yield werkzeug.html.pre(werkzeug.html(other),
1145 class_="other",
1146 id="line_%d" % self.line_no)
1147 yield u'</div>'
1149 class WikiSearch(object):
1150 """
1151 Responsible for indexing words and links, for fast searching and
1152 backlinks. Uses a cache directory to store the index files.
1153 """
1155 word_pattern = re.compile(ur"""\w[-~&\w]+\w""", re.UNICODE)
1156 jword_pattern = re.compile(
1157 ur"""[ヲ-゚]+|[ぁ-ん~ー]+|[ァ-ヶ~ー]+|[0-9A-Za-z]+|"""
1158 ur"""[0-9A-Za-zΑ-Ωα-ωА-я]+|"""
1159 ur"""[^- !"#$%&'()*+,./:;<=>?@\[\\\]^_`{|}"""
1160 ur"""‾。「」、・ 、。,.・:;?!゛゜´`¨"""
1161 ur"""^ ̄_/〜‖|…‥‘’“”"""
1162 ur"""()〔〕[]{}〈〉《》「」『』【】+−±×÷"""
1163 ur"""=≠<>≦≧∞∴♂♀°′″℃¥$¢£"""
1164 ur"""%#&*@§☆★○●◎◇◆□■△▲▽▼※〒"""
1165 ur"""→←↑↓〓∈∋⊆⊇⊂⊃∪∩∧∨¬⇒⇔∠∃∠⊥"""
1166 ur"""⌒∂∇≡≒≪≫√∽∝∵∫∬ʼn♯♭♪†‡¶◾"""
1167 ur"""─│┌┐┘└├┬┤┴┼"""
1168 ur"""━┃┏┓┛┗┣┫┻╋"""
1169 ur"""┠┯┨┷┿┝┰┥┸╂"""
1170 ur"""ヲ-゚ぁ-ん~ーァ-ヶ"""
1171 ur"""0-9A-Za-z0-9A-Za-zΑ-Ωα-ωА-я]+""", re.UNICODE)
1172 _con = {}
1174 def __init__(self, cache_path, lang, storage):
1175 self.path = cache_path
1176 self.storage = storage
1177 self.lang = lang
1178 if lang == "ja":
1179 self.split_text = self.split_japanese_text
1180 self.filename = os.path.join(cache_path, 'index.sqlite3')
1181 if not os.path.isdir(self.path):
1182 self.empty = True
1183 os.makedirs(self.path)
1184 elif not os.path.exists(self.filename):
1185 self.empty = True
1186 else:
1187 self.empty = False
1188 con = self.con # sqlite3.connect(self.filename)
1189 self.con.execute('CREATE TABLE IF NOT EXISTS titles '
1190 '(id INTEGER PRIMARY KEY, title VARCHAR);')
1191 self.con.execute('CREATE TABLE IF NOT EXISTS words '
1192 '(word VARCHAR, page INTEGER, count INTEGER);')
1193 self.con.execute('CREATE INDEX IF NOT EXISTS index1 '
1194 'ON words (page);')
1195 self.con.execute('CREATE INDEX IF NOT EXISTS index2 '
1196 'ON words (word);')
1197 self.con.execute('CREATE TABLE IF NOT EXISTS links '
1198 '(src INTEGER, target INTEGER, label VARCHAR, number INTEGER);')
1199 self.con.commit()
1200 self.stop_words_re = re.compile(u'^('+u'|'.join(re.escape(_(
1201 u"""am ii iii per po re a about above
1202 across after afterwards again against all almost alone along already also
1203 although always am among ain amongst amoungst amount an and another any aren
1204 anyhow anyone anything anyway anywhere are around as at back be became because
1205 become becomes becoming been before beforehand behind being below beside
1206 besides between beyond bill both but by can cannot cant con could couldnt
1207 describe detail do done down due during each eg eight either eleven else etc
1208 elsewhere empty enough even ever every everyone everything everywhere except
1209 few fifteen fifty fill find fire first five for former formerly forty found
1210 four from front full further get give go had has hasnt have he hence her here
1211 hereafter hereby herein hereupon hers herself him himself his how however
1212 hundred i ie if in inc indeed interest into is it its itself keep last latter
1213 latterly least isn less made many may me meanwhile might mill mine more
1214 moreover most mostly move much must my myself name namely neither never
1215 nevertheless next nine no nobody none noone nor not nothing now nowhere of off
1216 often on once one only onto or other others otherwise our ours ourselves out
1217 over own per perhaps please pre put rather re same see seem seemed seeming
1218 seems serious several she should show side since sincere six sixty so some
1219 somehow someone something sometime sometimes somewhere still such take ten than
1220 that the their theirs them themselves then thence there thereafter thereby
1221 therefore therein thereupon these they thick thin third this those though three
1222 through throughout thru thus to together too toward towards twelve twenty two
1223 un under ve until up upon us very via was wasn we well were what whatever when
1224 whence whenever where whereafter whereas whereby wherein whereupon wherever
1225 whether which while whither who whoever whole whom whose why will with within
1226 without would yet you your yours yourself yourselves""")).split())
1227 +ur')$|.*\d.*', re.U|re.I|re.X)
1228 self.update()
1232 @property
1233 def con(self):
1234 """Keep one connection per thread."""
1236 thread_id = thread.get_ident()
1237 try:
1238 return self._con[thread_id]
1239 except KeyError:
1240 connection = sqlite3.connect(self.filename)
1241 connection.isolation_level = None
1242 self._con[thread_id] = connection
1243 return connection
1245 def split_text(self, text, stop=True):
1246 """Splits text into words, removing stop words"""
1248 for match in self.word_pattern.finditer(text):
1249 word = match.group(0)
1250 if not (stop and self.stop_words_re.match(word)):
1251 yield word.lower()
1253 def split_japanese_text(self, text, stop=True):
1254 """Splits text into words, including rules for Japanese"""
1256 for match in self.word_pattern.finditer(text):
1257 word = match.group(0)
1258 got_japanese = False
1259 for m in self.jword_pattern.finditer(word):
1260 w = m.group(0)
1261 got_japanese = True
1262 if not (stop and self.stop_words_re.match(w)):
1263 yield w.lower()
1264 if not (got_japanese or stop and self.stop_words_re.match(word)):
1265 yield word.lower()
1267 def count_words(self, words):
1268 count = {}
1269 for word in words:
1270 count[word] = count.get(word, 0)+1
1271 return count
1273 def title_id(self, title, con):
1274 c = con.execute('SELECT id FROM titles WHERE title=?;', (title,))
1275 idents = c.fetchone()
1276 if idents is None:
1277 con.execute('INSERT INTO titles (title) VALUES (?);', (title,))
1278 c = con.execute('SELECT LAST_INSERT_ROWID();')
1279 idents = c.fetchone()
1280 return idents[0]
1282 def update_words(self, title, text, cursor):
1283 title_id = self.title_id(title, cursor)
1284 words = self.count_words(self.split_text(text))
1285 title_words = self.count_words(self.split_text(title))
1286 for word, count in title_words.iteritems():
1287 words[word] = words.get(word, 0) + count
1288 cursor.execute('DELETE FROM words WHERE page=?;', (title_id,))
1289 for word, count in words.iteritems():
1290 cursor.execute('INSERT INTO words VALUES (?, ?, ?);',
1291 (word, title_id, count))
1293 def update_links(self, title, links_and_labels, cursor):
1294 title_id = self.title_id(title, cursor)
1295 cursor.execute('DELETE FROM links WHERE src=?;', (title_id,))
1296 for number, (link, label) in enumerate(links_and_labels):
1297 cursor.execute('INSERT INTO links VALUES (?, ?, ?, ?);',
1298 (title_id, link, label, number))
1300 def page_backlinks(self, title):
1301 con = self.con # sqlite3.connect(self.filename)
1302 try:
1303 sql = ('SELECT DISTINCT(titles.title) '
1304 'FROM links, titles '
1305 'WHERE links.target=? AND titles.id=links.src '
1306 'ORDER BY titles.title;')
1307 for (backlink,) in con.execute(sql, (title,)):
1308 yield backlink
1309 finally:
1310 con.commit()
1312 def page_links(self, title):
1313 con = self.con # sqlite3.connect(self.filename)
1314 try:
1315 title_id = self.title_id(title, con)
1316 sql = 'SELECT TARGET from links where src=? ORDER BY number;'
1317 for (link,) in con.execute(sql, (title_id,)):
1318 yield link
1319 finally:
1320 con.commit()
1322 def page_links_and_labels (self, title):
1323 con = self.con # sqlite3.connect(self.filename)
1324 try:
1325 title_id = self.title_id(title, con)
1326 sql = 'SELECT target, label FROM links WHERE src=? ORDER BY number;'
1327 for link_and_label in con.execute(sql, (title_id,)):
1328 yield link_and_label
1329 finally:
1330 con.commit()
1332 def find(self, words):
1333 """Iterator of all pages containing the words, and their scores."""
1335 con = self.con
1336 try:
1337 ranks = []
1338 for word in words:
1339 # Calculate popularity of each word.
1340 sql = 'SELECT SUM(words.count) FROM words WHERE word LIKE ?;'
1341 rank = con.execute(sql, ('%%%s%%' % word,)).fetchone()[0]
1342 # If any rank is 0, there will be no results anyways
1343 if not rank:
1344 return
1345 ranks.append((rank, word))
1346 ranks.sort()
1347 # Start with the least popular word. Get all pages that contain it.
1348 first_rank, first = ranks[0]
1349 rest = ranks[1:]
1350 sql = ('SELECT words.page, titles.title, SUM(words.count) '
1351 'FROM words, titles '
1352 'WHERE word LIKE ? AND titles.id=words.page '
1353 'GROUP BY words.page;')
1354 first_counts = con.execute(sql, ('%%%s%%' % first,))
1355 # Check for the rest of words
1356 for title_id, title, first_count in first_counts:
1357 # Score for the first word
1358 score = float(first_count)/first_rank
1359 for rank, word in rest:
1360 sql = ('SELECT SUM(count) FROM words '
1361 'WHERE page=? AND word LIKE ?;')
1362 count = con.execute(sql,
1363 (title_id, '%%%s%%' % word)).fetchone()[0]
1364 if not count:
1365 # If page misses any of the words, its score is 0
1366 score = 0
1367 break
1368 score += float(count)/rank
1369 if score > 0:
1370 yield int(100*score), title
1371 finally:
1372 con.commit()
1374 def reindex_page(self, title, cursor, text=None):
1375 """Updates the content of the database, needs locks around."""
1377 mime = self.storage.page_mime(title)
1378 if not mime.startswith('text/'):
1379 self.update_words(title, '', cursor=cursor)
1380 return
1381 if text is None:
1382 try:
1383 text = self.storage.page_text(title)
1384 except werkzeug.exceptions.NotFound:
1385 text = u''
1386 if mime == 'text/x-wiki':
1387 links = WikiParser.extract_links(text)
1388 self.update_links(title, links, cursor=cursor)
1389 self.update_words(title, text, cursor=cursor)
1391 def update_page(self, title, data=None, text=None):
1392 """Updates the index with new page content, for a single page."""
1394 if text is None and data is not None:
1395 text = unicode(data, self.storage.charset, 'replace')
1396 cursor = self.con.cursor()
1397 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1398 try:
1399 self.set_last_revision(self.storage.repo_revision())
1400 self.reindex_page(title, cursor, text)
1401 cursor.execute('COMMIT TRANSACTION;')
1402 except:
1403 cursor.execute('ROLLBACK;')
1404 raise
1406 def reindex(self, pages):
1407 """Updates specified pages in bulk."""
1409 cursor = self.con.cursor()
1410 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1411 try:
1412 for title in pages:
1413 self.reindex_page(title, cursor)
1414 cursor.execute('COMMIT TRANSACTION;')
1415 self.empty = False
1416 except:
1417 cursor.execute('ROLLBACK;')
1418 raise
1420 def set_last_revision(self, rev):
1421 """Store the last indexed repository revision."""
1423 # We use % here because the sqlite3's substitiution doesn't work
1424 # We store revision 0 as 1, 1 as 2, etc. because 0 means "no revision"
1425 self.con.execute('PRAGMA USER_VERSION=%d;' % (int(rev+1),))
1427 def get_last_revision(self):
1428 """Retrieve the last indexed repository revision."""
1430 con = self.con
1431 c = con.execute('PRAGMA USER_VERSION;')
1432 rev = c.fetchone()[0]
1433 # -1 means "no revision", 1 means revision 0, 2 means revision 1, etc.
1434 return rev-1
1436 def update(self):
1437 """Reindex al pages that changed since last indexing."""
1439 last_rev = self.get_last_revision()
1440 if last_rev == -1:
1441 changed = self.storage.all_pages()
1442 else:
1443 changed = self.storage.changed_since(last_rev)
1444 self.reindex(changed)
1445 rev = self.storage.repo_revision()
1446 self.set_last_revision(rev)
1448 class WikiResponse(werkzeug.BaseResponse, werkzeug.ETagResponseMixin,
1449 werkzeug.CommonResponseDescriptorsMixin):
1450 """A typical HTTP response class made out of Werkzeug's mixins."""
1452 def make_conditional(self, request):
1453 ret = super(WikiResponse, self).make_conditional(request)
1454 # Remove all headers if it's 304, according to
1455 # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
1456 if self.status.startswith('304'):
1457 self.response = []
1458 try:
1459 del self.content_type
1460 except AttributeError:
1461 pass
1462 try:
1463 del self.content_length
1464 except AttributeError:
1465 pass
1466 try:
1467 del self.headers['Content-length']
1468 except (KeyError, IndexError):
1469 pass
1470 try:
1471 del self.headers['Content-type']
1472 except (KeyError, IndexError):
1473 pass
1474 return ret
1476 class WikiTempFile(object):
1477 """Wrap a file for uploading content."""
1479 def __init__(self, tmppath):
1480 self.tmppath = tempfile.mkdtemp(dir=tmppath)
1481 self.tmpname = os.path.join(self.tmppath, 'saved')
1482 self.f = open(self.tmpname, "wb")
1484 def read(self, *args, **kw):
1485 return self.f.read(*args, **kw)
1487 def readlines(self, *args, **kw):
1488 return self.f.readlines(*args, **kw)
1490 def write(self, *args, **kw):
1491 return self.f.write(*args, **kw)
1493 def seek(self, *args, **kw):
1494 return self.f.seek(*args, **kw)
1496 def truncate(self, *args, **kw):
1497 return self.f.truncate(*args, **kw)
1499 def close(self, *args, **kw):
1500 ret = self.f.close(*args, **kw)
1501 try:
1502 os.unlink(self.tmpname)
1503 except OSError:
1504 pass
1505 try:
1506 os.rmdir(self.tmppath)
1507 except OSError:
1508 pass
1509 return ret
1512 class WikiRequest(werkzeug.BaseRequest, werkzeug.ETagRequestMixin):
1513 """
1514 A Werkzeug's request with additional functions for handling file
1515 uploads and wiki-specific link generation.
1516 """
1518 charset = 'utf-8'
1519 encoding_errors = 'ignore'
1521 def __init__(self, wiki, adapter, environ, **kw):
1522 werkzeug.BaseRequest.__init__(self, environ, shallow=False, **kw)
1523 self.wiki = wiki
1524 self.adapter = adapter
1525 self.tmpfiles = []
1526 self.tmppath = wiki.path
1527 # Whether to print the css for highlighting
1528 self.print_highlight_styles = True
1530 def get_url(self, title=None, view=None, method='GET',
1531 external=False, **kw):
1532 if view is None:
1533 view = self.wiki.view
1534 if title is not None:
1535 kw['title'] = title
1536 return self.adapter.build(view, kw, method=method,
1537 force_external=external)
1539 def get_download_url(self, title):
1540 return self.get_url(title, view=self.wiki.download)
1542 def get_author(self):
1543 """Try to guess the author name. Use IP address as last resort."""
1545 try:
1546 cookie = werkzeug.url_unquote(self.cookies.get("author", ""))
1547 except UnicodeError:
1548 cookie = None
1549 try:
1550 auth = werkzeug.url_unquote(self.environ.get('REMOTE_USER', ""))
1551 except UnicodeError:
1552 auth = None
1553 author = (self.form.get("author") or cookie or auth or self.remote_addr)
1554 return author
1556 def _get_file_stream(self, total_content_length=None, content_type=None,
1557 filename=None, content_length=None):
1558 """Save all the POSTs to temporary files."""
1560 temp_file = WikiTempFile(self.tmppath)
1561 self.tmpfiles.append(temp_file)
1562 return temp_file
1564 def cleanup(self):
1565 """Clean up the temporary files created by POSTs."""
1567 for temp_file in self.tmpfiles:
1568 temp_file.close()
1569 self.tmpfiles = []
1571 class WikiPage(object):
1572 """Everything needed for rendering a page."""
1574 def __init__(self, wiki, request, title, mime):
1575 self.request = request
1576 self.title = title
1577 self.mime = mime
1578 # for now we just use the globals from wiki object
1579 self.get_url = self.request.get_url
1580 self.get_download_url = self.request.get_download_url
1581 self.wiki = wiki
1582 self.storage = self.wiki.storage
1583 self.index = self.wiki.index
1584 self.config = self.wiki.config
1586 def date_html(self, datetime):
1587 """
1588 Create HTML for a date, according to recommendation at
1589 http://microformats.org/wiki/date
1590 """
1592 text = datetime.strftime('%Y-%m-%d %H:%M')
1593 # We are going for YYYY-MM-DDTHH:MM:SSZ
1594 title = datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
1595 html = werkzeug.html.abbr(text, class_="date", title=title)
1596 return html
1599 def wiki_link(self, addr, label=None, class_=None, image=None, lineno=0):
1600 """Create HTML for a wiki link."""
1602 text = werkzeug.escape(label or addr)
1603 chunk = ''
1604 if class_ is not None:
1605 classes = [class_]
1606 else:
1607 classes = []
1608 if external_link(addr):
1609 if addr.startswith('mailto:'):
1610 class_ = 'external email'
1611 text = text.replace('@', '&#64;').replace('.', '&#46;')
1612 href = addr.replace('@', '%40').replace('.', '%2E')
1613 else:
1614 classes.append('external')
1615 href = werkzeug.escape(addr, quote=True)
1616 else:
1617 if '#' in addr:
1618 addr, chunk = addr.split('#', 1)
1619 chunk = '#'+chunk
1620 if addr.startswith('+'):
1621 href = '/'.join([self.request.script_root,
1622 '+'+werkzeug.escape(addr[1:], quote=True)])
1623 classes.append('special')
1624 elif addr == u'':
1625 href = chunk
1626 classes.append('anchor')
1627 else:
1628 classes.append('wiki')
1629 href = self.get_url(addr) + chunk
1630 if addr not in self.storage:
1631 classes.append('nonexistent')
1632 class_ = ' '.join(classes) or None
1633 return werkzeug.html.a(image or text, href=href, class_=class_,
1634 title=addr+chunk)
1636 def wiki_image(self, addr, alt, class_='wiki', lineno=0):
1637 """Create HTML for a wiki image."""
1639 html = werkzeug.html
1640 chunk = ''
1641 if external_link(addr):
1642 return html.img(src=werkzeug.url_fix(addr), class_="external",
1643 alt=alt)
1644 if '#' in addr:
1645 addr, chunk = addr.split('#', 1)
1646 if addr == '':
1647 return html.a(name=chunk)
1648 if addr in self.storage:
1649 mime = self.storage.page_mime(addr)
1650 if mime.startswith('image/'):
1651 return html.img(src=self.get_download_url(addr), class_=class_,
1652 alt=alt)
1653 else:
1654 return html.img(href=self.get_download_url(addr), alt=alt)
1655 else:
1656 return html.a(html(alt), href=self.get_url(addr))
1658 def search_form(self):
1659 html = werkzeug.html
1660 return html.form(html.div(html.input(name="q", class_="search"),
1661 html.input(class_="button", type_="submit", value=_(u'Search')),
1662 ), method="GET", class_="search",
1663 action=self.get_url(None, self.wiki.search))
1665 def logo(self):
1666 html = werkzeug.html
1667 img = html.img(alt=u"[%s]" % self.wiki.front_page,
1668 src=self.get_download_url(self.wiki.logo_page))
1669 return html.a(img, class_='logo', href=self.get_url(self.wiki.front_page))
1671 def menu(self):
1672 """Generate the menu items"""
1674 html = werkzeug.html
1675 if self.wiki.menu_page in self.storage:
1676 items = self.index.page_links_and_labels(self.wiki.menu_page)
1677 else:
1678 items = [
1679 (self.wiki.front_page, self.wiki.front_page),
1680 ('+history', _(u'Recent changes')),
1682 for link, label in items:
1683 if link == self.title:
1684 class_="current"
1685 else:
1686 class_ = None
1687 yield self.wiki_link(link, label, class_=class_)
1689 def header(self, special_title):
1690 html = werkzeug.html
1691 if self.wiki.logo_page in self.storage:
1692 yield self.logo()
1693 yield self.search_form()
1694 yield html.div(u" ".join(self.menu()), class_="menu")
1695 yield html.h1(html(special_title or self.title))
1697 header_template = Template(u"""\
1698 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
1699 "http://www.w3.org/TR/html4/strict.dtd">
1700 <html><head>
1701 <title>${title} - ${site_name}</title>
1702 <% if style_url %><link rel="stylesheet" type="text/css" href="${style_url}">\
1703 <% else %><style type="text/css">
1704 html { background: #fff; color: #2e3436;
1705 font-family: sans-serif; font-size: 96% }
1706 body { margin: 1em auto; line-height: 1.3; width: 40em }
1707 a { color: #3465a4; text-decoration: none }
1708 a:hover { text-decoration: underline }
1709 a.wiki:visited { color: #204a87 }
1710 a.nonexistent { color: #a40000; }
1711 a.external { color: #3465a4; text-decoration: underline }
1712 a.external:visited { color: #75507b }
1713 a img { border: none }
1714 img.math, img.smiley { vertical-align: middle }
1715 pre { font-size: 100%; white-space: pre-wrap; word-wrap: break-word;
1716 white-space: -moz-pre-wrap; white-space: -pre-wrap;
1717 white-space: -o-pre-wrap; line-height: 1.2; color: #555753 }
1718 div.conflict pre.local { background: #fcaf3e; margin-bottom: 0; color: 000}
1719 div.conflict pre.other { background: #ffdd66; margin-top: 0; color: 000; border-top: #d80 dashed 1px; }
1720 pre.diff div.orig { font-size: 75%; color: #babdb6 }
1721 b.highlight, pre.diff ins { font-weight: bold; background: #fcaf3e;
1722 color: #ce5c00; text-decoration: none }
1723 pre.diff del { background: #eeeeec; color: #888a85; text-decoration: none }
1724 pre.diff div.change { border-left: 2px solid #fcaf3e }
1725 div.footer { border-top: solid 1px #babdb6; text-align: right }
1726 h1, h2, h3, h4 { color: #babdb6; font-weight: normal; letter-spacing: 0.125em}
1727 div.buttons { text-align: center }
1728 input.button, div.buttons input { font-weight: bold; font-size: 100%;
1729 background: #eee; border: solid 1px #babdb6; margin: 0.25em; color: #888a85}
1730 .history input.button { font-size: 75% }
1731 .editor textarea { width: 100%; display: block; font-size: 100%;
1732 border: solid 1px #babdb6; }
1733 .editor label { display:block; text-align: right }
1734 .editor .upload { margin: 2em auto; text-align: center }
1735 form.search input.search, .editor label input { font-size: 100%;
1736 border: solid 1px #babdb6; margin: 0.125em 0 }
1737 .editor label.comment input { width: 32em }
1738 a.logo { float: left; display: block; margin: 0.25em }
1739 div.header h1 { margin: 0; }
1740 div.content { clear: left }
1741 form.search { margin:0; text-align: right; font-size: 80% }
1742 div.snippet { font-size: 80%; color: #888a85 }
1743 div.header div.menu { float: right; margin-top: 1.25em }
1744 div.header div.menu a.current { color: #000 }
1745 hr { background: transparent; border:none; height: 0;
1746 border-bottom: 1px solid #babdb6; clear: both }
1747 blockquote { border-left:.25em solid #ccc; padding-left:.5em; margin-left:0}
1748 abbr.date {border:none}
1749 </style><%endif%>
1750 <%if not robots %><meta name="robots" content="NOINDEX,NOFOLLOW"><%endif%>
1751 <%if edit_url %><link rel="alternate" type="application/wiki" \
1752 href="${edit_url}"><%endif%>
1753 <link rel="shortcut icon" type="image/x-icon" href="${favicon_url}">
1754 <link rel="alternate" type="application/rss+xml" title="${site_name} (RSS)" \
1755 href="${rss_url}">
1756 <link rel="alternate" type="application/rss+xml" title="${site_name} (ATOM)" \
1757 href="${atom_url}">
1758 <%if script_url %>
1759 <script type="application/javascript" src="${script_url}"></script>\
1760 <%endif%>
1761 </head><body>
1762 <div class="header">${header_content}</div>
1763 <div class="content">
1764 """)
1765 footer_template = Template(u"""<div class="footer">
1766 <%if edit_url%><a href="${edit_url}" class="edit">${_("Edit")}</a> <%endif%>
1767 <a href="${history_url}" class="history">${_("History")}</a>
1768 <a href="${backlinks_url}" class="backlinks">${_("Backlinks")}</a>
1769 </div></div></body></html>""")
1771 def render_content(self, content, special_title=None):
1772 """The main page template."""
1774 style_url = None
1775 edit_url = None
1776 script_url = None
1777 if self.wiki.style_page in self.storage:
1778 style_url = self.get_download_url(self.wiki.style_page)
1779 if not special_title:
1780 try:
1781 self.wiki.check_lock(self.title)
1782 edit_url = self.get_url(self.title, self.wiki.edit)
1783 except werkzeug.exceptions.Forbidden:
1784 pass
1785 if self.wiki.script_page in self.wiki.storage:
1786 script_url = self.get_download_url(self.wiki.script_page)
1788 yield self.header_template.render(
1789 title=werkzeug.escape(special_title or self.title, quote=True),
1790 site_name=werkzeug.escape(self.wiki.site_name, quote=True),
1791 style_url=style_url,
1792 robots=not special_title,
1793 edit_url=edit_url,
1794 favicon_url=self.get_url(None, self.wiki.favicon),
1795 rss_url=self.get_url(None, self.wiki.rss),
1796 atom_url=self.get_url(None, self.wiki.atom),
1797 script_url=script_url,
1798 header_content = ''.join(self.header(special_title))
1800 for part in content:
1801 yield part
1802 if special_title:
1803 yield "</div></body></html>"
1804 return
1805 yield self.footer_template.render(
1806 edit_url=edit_url,
1807 history_url=self.get_url(self.title, self.wiki.history),
1808 backlinks_url=self.get_url(self.title, self.wiki.backlinks),
1809 _=lambda s: werkzeug.escape(_(s), quote=True),
1812 history_item_template = Template(u"""<li>
1813 <a href="${date_url}">${date_html}</a>
1814 <%if not read_only%>
1815 <input type="submit" name="${rev}" value="${undo}" class="button">
1816 <%endif%>
1817 . . . . ${author_link}
1818 <div class="comment">${comment}</div>
1819 </li>""")
1821 def history_list(self):
1822 """Generate the content of the history page."""
1824 max_rev = -1;
1825 title = self.title
1826 link = self.wiki_link(title)
1827 yield werkzeug.html.p(werkzeug.escape(
1828 _(u'History of changes for %(link)s.')) % {'link': link})
1829 url = self.request.get_url(title, self.wiki.undo, method='POST')
1830 yield u'<form action="%s" method="POST"><ul class="history">' % url
1831 try:
1832 self.wiki.check_lock(title)
1833 read_only = False
1834 except werkzeug.exceptions.Forbidden:
1835 read_only = True
1836 for rev, date, author, comment in self.wiki.storage.page_history(title):
1837 if max_rev < rev:
1838 max_rev = rev
1839 if rev > 0:
1840 date_url = self.request.adapter.build(self.wiki.diff, {
1841 'title': title, 'from_rev': rev-1, 'to_rev': rev})
1842 else:
1843 date_url = self.request.adapter.build(self.wiki.revision, {
1844 'title': title, 'rev': rev})
1845 yield self.history_item_template.render(
1846 read_only=read_only,
1847 rev=rev,
1848 date_url=date_url,
1849 date_html=self.date_html(date),
1850 author_link=self.wiki_link(author),
1851 comment=werkzeug.escape(comment),
1852 undo=werkzeug.escape(_('Undo'), quote=True),
1854 yield (u'</ul><input type="hidden" name="parent" value="%d"></form>'
1855 % max_rev)
1857 def dependencies(self):
1858 """Refresh the page when any of those pages was changed."""
1860 dependencies = set()
1861 for link in [self.wiki.style_page, self.wiki.logo_page,
1862 self.wiki.menu_page]:
1863 if link not in self.storage:
1864 dependencies.add(werkzeug.url_quote(link))
1865 return dependencies
1867 def diff_content(self, from_rev, to_rev):
1868 """Genrate HTML for the diff of revisions of a page."""
1870 return []
1872 class WikiPageText(WikiPage):
1873 """Pages of mime type text/* use this for display."""
1875 def view_content(self, lines=None):
1876 """Generate HTML for the content."""
1878 if lines is None:
1879 text = self.storage.page_text(self.title)
1880 else:
1881 text = ''.join(lines)
1882 return self.highlight(text, mime=self.mime)
1884 def editor_form(self, preview=None):
1885 """Generate the HTML for the editor."""
1887 author = self.request.get_author()
1888 lines = []
1889 try:
1890 page_file = self.storage.open_page(self.title)
1891 lines = self.storage.page_lines(page_file)
1892 (rev, old_date, old_author,
1893 old_comment) = self.storage.page_meta(self.title)
1894 comment = _(u'modified')
1895 if old_author == author:
1896 comment = old_comment
1897 except werkzeug.exceptions.NotFound:
1898 comment = _(u'created')
1899 rev = -1
1900 if preview:
1901 lines = preview
1902 comment = self.request.form.get('comment', comment)
1903 html = werkzeug.html
1904 yield u'<form action="" method="POST" class="editor"><div>'
1905 yield u'<textarea name="text" cols="80" rows="20" id="editortext">'
1906 for line in lines:
1907 yield werkzeug.escape(line)
1908 yield u"""</textarea>"""
1909 yield html.input(type_="hidden", name="parent", value=rev)
1910 yield html.label(html(_(u'Comment')), html.input(name="comment",
1911 value=comment), class_="comment")
1912 yield html.label(html(_(u'Author')), html.input(name="author",
1913 value=self.request.get_author()), class_="comment")
1914 yield html.div(
1915 html.input(type_="submit", name="save", value=_(u'Save')),
1916 html.input(type_="submit", name="preview", value=_(u'Preview')),
1917 html.input(type_="submit", name="cancel", value=_(u'Cancel')),
1918 class_="buttons")
1919 yield u'</div></form>'
1920 if preview:
1921 yield html.h1(html(_(u'Preview, not saved')), class_="preview")
1922 for part in self.view_content(preview):
1923 yield part
1925 def highlight(self, text, mime=None, syntax=None, line_no=0):
1926 """Colorize the source code."""
1928 try:
1929 import pygments
1930 import pygments.util
1931 import pygments.lexers
1932 import pygments.formatters
1933 import pygments.styles
1934 except ImportError:
1935 yield werkzeug.html.pre(werkzeug.html(text))
1936 return
1937 if 'tango' in pygments.styles.STYLE_MAP:
1938 style = 'tango'
1939 else:
1940 style = 'friendly'
1941 formatter = pygments.formatters.HtmlFormatter(style=style)
1942 formatter.line_no = line_no
1944 def wrapper(source, outfile):
1945 """Wrap each line of formatted output."""
1947 yield 0, '<div class="highlight"><pre>'
1948 for lineno, line in source:
1949 if line.strip():
1950 yield (lineno,
1951 werkzeug.html.div(line.strip('\n'), id_="line_%d" %
1952 formatter.line_no))
1953 else:
1954 yield (lineno,
1955 werkzeug.html.div('&nbsp;', id_="line_%d" %
1956 formatter.line_no))
1957 formatter.line_no += 1
1958 yield 0, '</pre></div>'
1960 formatter.wrap = wrapper
1961 try:
1962 if mime:
1963 lexer = pygments.lexers.get_lexer_for_mimetype(mime)
1964 elif syntax:
1965 lexer = pygments.lexers.get_lexer_by_name(syntax)
1966 else:
1967 lexer = pygments.lexers.guess_lexer(text)
1968 except pygments.util.ClassNotFound:
1969 yield werkzeug.html.pre(werkzeug.html(text))
1970 return
1971 if self.request.print_highlight_styles:
1972 css = formatter.get_style_defs('.highlight')
1973 self.request.print_highlight_styles = False
1974 yield werkzeug.html.style(werkzeug.html(css), type="text/css")
1975 html = pygments.highlight(text, lexer, formatter)
1976 yield html
1978 def diff_content(self, from_rev, to_rev):
1979 """Generate the HTML markup for a diff."""
1981 def infiniter(iterator):
1982 """Turn an iterator into an infinite one, padding it with None"""
1984 for i in iterator:
1985 yield i
1986 while True:
1987 yield None
1989 text = self.storage.revision_text(self.title, from_rev)
1990 other_text = self.storage.revision_text(self.title, to_rev)
1991 diff = difflib._mdiff(text.split('\n'), other_text.split('\n'))
1992 stack = []
1994 mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])')
1995 yield u'<pre class="diff">'
1996 for old_line, new_line, changed in diff:
1997 old_no, old_text = old_line
1998 new_no, new_text = new_line
1999 line_no = (new_no or old_no or 1)-1
2000 if changed:
2001 yield u'<div class="change" id="line_%d">' % line_no
2002 old_iter = infiniter(mark_re.finditer(old_text))
2003 new_iter = infiniter(mark_re.finditer(new_text))
2004 old = old_iter.next()
2005 new = new_iter.next()
2006 buff = u''
2007 while old or new:
2008 while old and old.group(1):
2009 if buff:
2010 yield werkzeug.escape(buff)
2011 buff = u''
2012 yield u'<del>%s</del>' % werkzeug.escape(old.group(1))
2013 old = old_iter.next()
2014 while new and new.group(1):
2015 if buff:
2016 yield werkzeug.escape(buff)
2017 buff = u''
2018 yield u'<ins>%s</ins>' % werkzeug.escape(new.group(1))
2019 new = new_iter.next()
2020 if new:
2021 buff += new.group(2)
2022 old = old_iter.next()
2023 new = new_iter.next()
2024 if buff:
2025 yield werkzeug.escape(buff)
2026 yield u'</div>'
2027 else:
2028 yield u'<div class="orig" id="line_%d">%s</div>' % (
2029 line_no, werkzeug.escape(old_text))
2030 yield u'</pre>'
2032 class WikiPageWiki(WikiPageText):
2033 """Pages of with wiki markup use this for display."""
2035 def __init__(self, wiki, request, title, mime):
2036 WikiPageText.__init__(self, wiki, request, title, mime)
2037 parser = self.config.get("parser", "internal")
2038 if not (parser.lower() in ("internal")):
2039 parser = "internal"
2040 if parser == "internal":
2041 if self.wiki.icon_page and self.wiki.icon_page in self.storage:
2042 icons = self.index.page_links_and_labels(self.wiki.icon_page)
2043 smilies = dict((emo, link) for (link, emo) in icons)
2044 else:
2045 smilies = None
2046 parserengine = WikiParser(self.wiki_link, self.wiki_image,
2047 self.highlight, self.wiki_math, smilies)
2048 self.parser = parserengine.text2html
2050 def view_content(self, lines=None):
2051 if lines is None:
2052 f = self.storage.open_page(self.title)
2053 lines = self.storage.page_lines(f)
2054 content = self.parser(lines)
2055 return content
2057 def wiki_math(self, math):
2058 math_url = self.config.get('math_url',
2059 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2060 if '%s' in math_url:
2061 url = math_url % werkzeug.url_quote(math)
2062 else:
2063 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2064 label = werkzeug.escape(math, quote=True)
2065 return werkzeug.html.img(src=url, alt=label, class_="math")
2067 def dependencies(self):
2068 dependencies = WikiPage.dependencies(self)
2069 for link in self.index.page_links(self.title):
2070 if link not in self.storage:
2071 dependencies.add(werkzeug.url_quote(link))
2072 return dependencies
2074 class WikiPageFile(WikiPage):
2075 """Pages of all other mime types use this for display."""
2077 def view_content(self, lines=None):
2078 if self.title not in self.storage:
2079 raise werkzeug.exceptions.NotFound()
2080 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2081 (self.request.get_download_url(self.title),
2082 werkzeug.escape(self.title), self.mime)]
2083 return content
2085 def editor_form(self, preview=None):
2086 author = self.request.get_author()
2087 if self.title in self.storage:
2088 comment = _(u'changed')
2089 (rev, old_date, old_author,
2090 old_comment) = self.storage.page_meta(self.title)
2091 if old_author == author:
2092 comment = old_comment
2093 else:
2094 comment = _(u'uploaded')
2095 rev = -1
2096 html = werkzeug.html
2097 yield html.p(html(
2098 _(u"This is a binary file, it can't be edited on a wiki. "
2099 u"Please upload a new version instead.")))
2100 yield html.form(html.div(
2101 html.div(html.input(type_="file", name="data"), class_="upload"),
2102 html.input(type_="hidden", name="parent", value=rev),
2103 html.label(html(_(u'Comment')), html.input(name="comment",
2104 value=comment)),
2105 html.label(html(_(u'Author')), html.input(name="author",
2106 value=author)),
2107 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2108 html.input(type_="submit", name="cancel",
2109 value=_(u'Cancel')),
2110 class_="buttons")), action="", method="POST", class_="editor",
2111 enctype="multipart/form-data")
2113 class WikiPageImage(WikiPageFile):
2114 """Pages of mime type image/* use this for display."""
2116 render_file = '128x128.png'
2118 def view_content(self, lines=None):
2119 if self.title not in self.storage:
2120 raise werkzeug.exceptions.NotFound()
2121 content = ['<img src="%s" alt="%s">'
2122 % (self.request.get_url(self.title, self.wiki.render),
2123 werkzeug.escape(self.title))]
2124 return content
2126 def render_mime(self):
2127 """Give the filename and mime type of the rendered thumbnail."""
2129 if not Image:
2130 raise NotImplementedError('No Image library available')
2131 return self.render_file, 'image/png'
2133 def render_cache(self, cache_dir):
2134 """Render the thumbnail and save in the cache."""
2136 if not Image:
2137 raise NotImplementedError('No Image library available')
2138 page_file = self.storage.open_page(self.title)
2139 cache_path = os.path.join(cache_dir, self.render_file)
2140 cache_file = open(cache_path, 'wb')
2141 try:
2142 im = Image.open(page_file)
2143 im = im.convert('RGBA')
2144 im.thumbnail((128, 128), Image.ANTIALIAS)
2145 im.save(cache_file,'PNG')
2146 except IOError:
2147 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2148 cache_file.close()
2149 return cache_path
2151 class WikiPageCSV(WikiPageFile):
2152 """Display class for type text/csv."""
2154 def view_content(self, lines=None):
2155 if self.title not in self.storage:
2156 raise werkzeug.exceptions.NotFound()
2157 import csv
2158 csv_file = self.storage.open_page(self.title)
2159 reader = csv.reader(csv_file)
2160 html_title = werkzeug.escape(self.title, quote=True)
2161 yield u'<table id="%s" class="csvfile">' % html_title
2162 try:
2163 for row in reader:
2164 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2165 for cell in row))
2166 except csv.Error, e:
2167 yield u'</table>'
2168 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2169 % (html_title, reader.line_num, e))
2170 finally:
2171 csv_file.close()
2172 yield u'</table>'
2174 class WikiTitleConverter(werkzeug.routing.PathConverter):
2175 """Behaves like the path converter, except that it escapes slashes."""
2177 def to_url(self, value):
2178 return werkzeug.url_quote(value, self.map.charset, safe="")
2180 class WikiAllConverter(werkzeug.routing.BaseConverter):
2181 """Matches everything."""
2183 regex='.*'
2186 class Wiki(object):
2187 """
2188 The main class of the wiki, handling initialization of the whole
2189 application and most of the logic.
2190 """
2191 storage_class = WikiStorage
2192 index_class = WikiSearch
2193 mime_map = {
2194 'text': WikiPageText,
2195 'application/javascript': WikiPageText,
2196 'text/csv': WikiPageCSV,
2197 'text/x-wiki': WikiPageWiki,
2198 'image': WikiPageImage,
2199 '': WikiPageFile,
2201 icon = base64.b64decode(
2202 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2203 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2204 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2205 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2206 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2207 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2208 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2209 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2210 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2211 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2212 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2213 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2214 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2215 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2216 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2218 def __init__(self, config):
2219 self.dead = False
2220 self.config = config
2221 self.language = config.get('language', None)
2222 global _
2223 if self.language is not None:
2224 try:
2225 _ = gettext.translation('hatta', 'locale',
2226 languages=[self.language]).ugettext
2227 except IOError:
2228 _ = gettext.translation('hatta', fallback=True,
2229 languages=[self.language]).ugettext
2230 else:
2231 _ = gettext.translation('hatta', fallback=True).ugettext
2232 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2233 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2234 self.page_charset = config.get('page_charset', 'utf-8')
2235 self.menu_page = self.config.get('menu_page', u'Menu')
2236 self.front_page = self.config.get('front_page', u'Home')
2237 self.logo_page = self.config.get('logo_page', u'logo.png')
2238 self.locked_page = self.config.get('locked_page', u'Locked')
2239 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2240 self.style_page = self.config.get('style_page', u'style.css')
2241 self.read_only = self.config.get_bool('read_only', False)
2242 self.script_page = self.config.get('script_page', None)
2243 self.icon_page = self.config.get('icon_page', None)
2245 self.storage = self.storage_class(self.path, self.page_charset)
2246 if not os.path.isdir(self.cache):
2247 os.makedirs(self.cache)
2248 reindex = True
2249 else:
2250 reindex = False
2251 self.index = self.index_class(self.cache, self.language, self.storage)
2252 R = werkzeug.routing.Rule
2253 self.url_map = werkzeug.routing.Map([
2254 R('/', defaults={'title': self.front_page},
2255 endpoint=self.view, methods=['GET', 'HEAD']),
2256 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2257 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2258 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2259 R('/+history/', endpoint=self.recent_changes,
2260 methods=['GET', 'HEAD']),
2261 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2262 endpoint=self.diff, methods=['GET']),
2263 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2264 methods=['GET']),
2265 R('/+history/<title:title>', endpoint=self.history,
2266 methods=['GET', 'HEAD']),
2267 R('/+download/<title:title>', endpoint=self.download,
2268 methods=['GET', 'HEAD']),
2269 R('/+render/<title:title>', endpoint=self.render,
2270 methods=['GET', 'HEAD']),
2271 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2272 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2273 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2274 R('/favicon.ico', endpoint=self.favicon, methods=['GET', 'HEAD']),
2275 R('/robots.txt', endpoint=self.robots, methods=['GET']),
2276 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2277 R('/+search/<title:title>', endpoint=self.backlinks,
2278 methods=['GET', 'POST']),
2279 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2280 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2281 methods=['GET', 'POST', 'HEAD']),
2282 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2284 def get_page(self, request, title):
2285 """Creates a page object based on page's mime type"""
2287 if title:
2288 mime = self.storage.page_mime(title)
2289 major, minor = mime.split('/', 1)
2290 plus_pos = minor.find('+')
2291 if plus_pos>0:
2292 minor_base = minor[plus_pos]
2293 else:
2294 minor_base = ''
2295 try:
2296 page_class = self.mime_map[mime]
2297 except KeyError:
2298 try:
2299 page_class = self.mime_map['/'.join([major, minor_base])]
2300 except KeyError:
2301 try:
2302 page_class = self.mime_map[major]
2303 except KeyError:
2304 page_class = self.mime_map['']
2305 else:
2306 page_class = WikiPage
2307 mime = ''
2308 return page_class(self, request, title, mime)
2310 def view(self, request, title):
2311 page = self.get_page(request, title)
2312 try:
2313 content = page.view_content()
2314 except werkzeug.exceptions.NotFound:
2315 url = request.get_url(title, self.edit, external=True)
2316 return werkzeug.routing.redirect(url, code=303)
2317 html = page.render_content(content)
2318 dependencies = page.dependencies()
2319 etag = '/(%s)' % u','.join(dependencies)
2320 return self.response(request, title, html, etag=etag)
2322 def revision(self, request, title, rev):
2323 text = self.storage.revision_text(title, rev)
2324 link = werkzeug.html.a(werkzeug.html(title),
2325 href=request.get_url(title))
2326 content = [
2327 werkzeug.html.p(
2328 werkzeug.html(
2329 _(u'Content of revision %(rev)d of page %(title)s:'))
2330 % {'rev': rev, 'title': link }),
2331 werkzeug.html.pre(werkzeug.html(text)),
2333 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2334 page = self.get_page(request, title)
2335 html = page.render_content(content, special_title)
2336 response = self.response(request, title, html, rev=rev, etag='/old')
2337 return response
2339 def check_lock(self, title):
2340 if self.read_only:
2341 raise werkzeug.exceptions.Forbidden(_("This site is read-only."))
2342 if self.script_page and title == self.script_page:
2343 raise werkzeug.exceptions.Forbidden(_("""Can't edit live scripts.
2344 To edit this page remove it from the script_page option first."""))
2345 if self.locked_page in self.storage:
2346 if title in self.index.page_links(self.locked_page):
2347 raise werkzeug.exceptions.Forbidden(_("This page is locked."))
2349 def save(self, request, title):
2350 self.check_lock(title)
2351 url = request.get_url(title)
2352 if request.form.get('cancel'):
2353 if title not in self.storage:
2354 url = request.get_url(self.front_page)
2355 if request.form.get('preview'):
2356 text = request.form.get("text")
2357 if text is not None:
2358 lines = text.split('\n')
2359 else:
2360 lines = [werkzeug.html.p(werkzeug.html(
2361 _(u'No preview for binaries.')))]
2362 return self.edit(request, title, preview=lines)
2363 elif request.form.get('save'):
2364 comment = request.form.get("comment", "")
2365 author = request.get_author()
2366 text = request.form.get("text")
2367 try:
2368 parent = int(request.form.get("parent"))
2369 except (ValueError, TypeError):
2370 parent = None
2371 self.storage.reopen()
2372 self.index.update()
2373 if text is not None:
2374 if title == self.locked_page:
2375 for link, label in WikiParser.extract_links(text):
2376 if title == link:
2377 raise werkzeug.exceptions.Forbidden()
2378 if u'href="' in comment or u'http:' in comment:
2379 raise werkzeug.exceptions.Forbidden()
2380 if text.strip() == '':
2381 self.storage.delete_page(title, author, comment)
2382 url = request.get_url(self.front_page)
2383 else:
2384 self.storage.save_text(title, text, author, comment, parent)
2385 else:
2386 text = u''
2387 upload = request.files['data']
2388 f = upload.stream
2389 if f is not None and upload.filename is not None:
2390 try:
2391 self.storage.save_file(title, f.tmpname, author,
2392 comment, parent)
2393 except AttributeError:
2394 self.storage.save_data(title, f.read(), author,
2395 comment, parent)
2396 else:
2397 self.storage.delete_page(title, author, comment)
2398 url = request.get_url(self.front_page)
2399 self.index.update_page(title, text=text)
2400 response = werkzeug.routing.redirect(url, code=303)
2401 response.set_cookie('author',
2402 werkzeug.url_quote(request.get_author()),
2403 max_age=604800)
2404 return response
2406 def edit(self, request, title, preview=None):
2407 self.check_lock(title)
2408 page = self.get_page(request, title)
2409 content = page.editor_form(preview)
2410 special_title = _(u'Editing "%(title)s"') % {'title': title}
2411 html = page.render_content(content, special_title)
2412 if title not in self.storage:
2413 return werkzeug.Response(html, mimetype="text/html",
2414 status='404 Not found')
2415 elif preview:
2416 return werkzeug.Response(html, mimetype="text/html")
2417 else:
2418 return self.response(request, title, html, '/edit')
2420 def atom(self, request):
2421 date_format = "%Y-%m-%dT%H:%M:%SZ"
2422 first_date = datetime.datetime.now()
2423 now = first_date.strftime(date_format)
2424 body = []
2425 first_title = u''
2426 count = 0
2427 unique_titles = {}
2428 for title, rev, date, author, comment in self.storage.history():
2429 if title in unique_titles:
2430 continue
2431 unique_titles[title] = True
2432 count += 1
2433 if count > 10:
2434 break
2435 if not first_title:
2436 first_title = title
2437 first_rev = rev
2438 first_date = date
2439 item = u"""<entry>
2440 <title>%(title)s</title>
2441 <link href="%(page_url)s" />
2442 <content>%(comment)s</content>
2443 <updated>%(date)s</updated>
2444 <author>
2445 <name>%(author)s</name>
2446 <uri>%(author_url)s</uri>
2447 </author>
2448 <id>%(url)s</id>
2449 </entry>""" % {
2450 'title': werkzeug.escape(title),
2451 'page_url': request.adapter.build(self.view, {'title': title},
2452 force_external=True),
2453 'comment': werkzeug.escape(comment),
2454 'date': date.strftime(date_format),
2455 'author': werkzeug.escape(author),
2456 'author_url': request.adapter.build(self.view,
2457 {'title': author},
2458 force_external=True),
2459 'url': request.adapter.build(self.revision,
2460 {'title': title, 'rev': rev},
2461 force_external=True),
2463 body.append(item)
2464 content = u"""<?xml version="1.0" encoding="utf-8"?>
2465 <feed xmlns="http://www.w3.org/2005/Atom">
2466 <title>%(title)s</title>
2467 <link rel="self" href="%(atom)s"/>
2468 <link href="%(home)s"/>
2469 <id>%(home)s</id>
2470 <updated>%(date)s</updated>
2471 <logo>%(logo)s</logo>
2472 %(body)s
2473 </feed>""" % {
2474 'title': self.site_name,
2475 'home': request.adapter.build(self.view, force_external=True),
2476 'atom': request.adapter.build(self.atom, force_external=True),
2477 'date': first_date.strftime(date_format),
2478 'logo': request.adapter.build(self.download,
2479 {'title': self.logo_page},
2480 force_external=True),
2481 'body': u''.join(body),
2483 response = self.response(request, 'atom', content, '/atom',
2484 'application/xml', first_rev, first_date)
2485 response.set_etag('/atom/%d' % self.storage.repo_revision())
2486 response.make_conditional(request)
2487 return response
2489 def rss(self, request):
2490 """Serve an RSS feed of recent changes."""
2492 first_date = datetime.datetime.now()
2493 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2494 rss_body = []
2495 first_title = u''
2496 count = 0
2497 unique_titles = {}
2498 for title, rev, date, author, comment in self.storage.history():
2499 if title in unique_titles:
2500 continue
2501 unique_titles[title] = True
2502 count += 1
2503 if count > 10:
2504 break
2505 if not first_title:
2506 first_title = title
2507 first_rev = rev
2508 first_date = date
2509 item = (u'<item><title>%s</title><link>%s</link>'
2510 u'<description>%s</description><pubDate>%s</pubDate>'
2511 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2512 werkzeug.escape(title),
2513 request.adapter.build(self.view, {'title': title},
2514 force_external=True),
2515 werkzeug.escape(comment),
2516 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2517 werkzeug.escape(author),
2518 request.adapter.build(self.revision,
2519 {'title': title, 'rev': rev})
2520 ))
2521 rss_body.append(item)
2522 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2523 <rss version="2.0"
2524 xmlns:dc="http://purl.org/dc/elements/1.1/"
2525 xmlns:atom="http://www.w3.org/2005/Atom"
2527 <channel>
2528 <title>%s</title>
2529 <atom:link href="%s" rel="self" type="application/rss+xml" />
2530 <link>%s</link>
2531 <description>%s</description>
2532 <generator>Hatta Wiki</generator>
2533 <language>en</language>
2534 <lastBuildDate>%s</lastBuildDate>
2536 """ % (
2537 werkzeug.escape(self.site_name),
2538 request.adapter.build(self.rss),
2539 request.adapter.build(self.recent_changes),
2540 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2541 u'in this feed.')),
2542 first_date,
2544 content = [rss_head]+rss_body+[u'</channel></rss>']
2545 response = self.response(request, 'rss', content, '/rss',
2546 'application/xml', first_rev, first_date)
2547 response.set_etag('/rss/%d' % self.storage.repo_revision())
2548 response.make_conditional(request)
2549 return response
2551 def response(self, request, title, content, etag='', mime='text/html',
2552 rev=None, date=None, size=None):
2553 """Create a WikiResponse for a page."""
2555 response = WikiResponse(content, mimetype=mime)
2556 if rev is None:
2557 inode, _size, mtime = self.storage.page_file_meta(title)
2558 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2559 inode, mtime))
2560 if size == -1:
2561 size = _size
2562 else:
2563 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2564 rev))
2565 if size:
2566 response.content_length = size
2567 response.make_conditional(request)
2568 return response
2570 def download(self, request, title):
2571 """Serve the raw content of a page."""
2573 mime = self.storage.page_mime(title)
2574 if mime == 'text/x-wiki':
2575 mime = 'text/plain'
2576 f = self.storage.open_page(title)
2577 response = self.response(request, title, f, '/download', mime, size=-1)
2578 return response
2580 def render(self, request, title):
2581 """Serve a thumbnail or otherwise rendered content."""
2583 def file_time_and_size(file_path):
2584 """Get file's modification timestamp and its size."""
2586 try:
2587 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2588 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2589 except OSError:
2590 st_mtime = 0
2591 st_size = None
2592 return st_mtime, st_size
2594 def rm_temp_dir(dir_path):
2595 """Delete the directory with subdirectories."""
2597 for root, dirs, files in os.walk(dir_path, topdown=False):
2598 for name in files:
2599 try:
2600 os.remove(os.path.join(root, name))
2601 except OSError:
2602 pass
2603 for name in dirs:
2604 try:
2605 os.rmdir(os.path.join(root, name))
2606 except OSError:
2607 pass
2608 try:
2609 os.rmdir(dir_path)
2610 except OSError:
2611 pass
2613 page = self.get_page(request, title)
2614 try:
2615 cache_filename, cache_mime = page.render_mime()
2616 render = page.render_cache
2617 except (AttributeError, NotImplementedError):
2618 return self.download(request, title)
2620 cache_dir = os.path.join(self.cache, 'render',
2621 werkzeug.url_quote(title, safe=''))
2622 cache_file = os.path.join(cache_dir, cache_filename)
2623 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2624 cache_mtime, cache_size = file_time_and_size(cache_file)
2625 if page_mtime > cache_mtime:
2626 if not os.path.exists(cache_dir):
2627 os.makedirs(cache_dir)
2628 try:
2629 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2630 result_file = render(temp_dir)
2631 mercurial.util.rename(result_file, cache_file)
2632 finally:
2633 rm_temp_dir(temp_dir)
2634 f = open(cache_file)
2635 response = self.response(request, title, f, '/render', cache_mime,
2636 size=cache_size)
2637 return response
2639 def undo(self, request, title):
2640 """Revert a change to a page."""
2642 self.check_lock(title)
2643 rev = None
2644 for key in request.form:
2645 try:
2646 rev = int(key)
2647 except ValueError:
2648 pass
2649 author = request.get_author()
2650 if rev is not None:
2651 try:
2652 parent = int(request.form.get("parent"))
2653 except (ValueError, TypeError):
2654 parent = None
2655 self.storage.reopen()
2656 self.index.update()
2657 if rev == 0:
2658 comment = _(u'Delete page %(title)s') % {'title': title}
2659 data = ''
2660 self.storage.delete_page(title, author, comment)
2661 else:
2662 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2663 'rev': rev, 'title': title}
2664 data = self.storage.page_revision(title, rev-1)
2665 self.storage.save_data(title, data, author, comment, parent)
2666 self.index.update_page(title, data=data)
2667 url = request.adapter.build(self.history, {'title': title},
2668 method='GET', force_external=True)
2669 return werkzeug.redirect(url, 303)
2671 def history(self, request, title):
2672 """Display history of changes of a page."""
2674 page = self.get_page(request, title)
2675 content = page.render_content(page.history_list(),
2676 _(u'History of "%(title)s"') % {'title': title})
2677 response = self.response(request, title, content, '/history')
2678 return response
2680 recent_changes_item_template = Template(u"""<li>
2681 <a href="${date_url}">${date_html}</a>
2682 ${page_link} . . . . ${author_link}
2683 <div class="comment">${comment}</div></li>""")
2685 def recent_changes(self, request):
2686 """Serve the recent changes page."""
2688 def changes_list(page):
2689 """Generate the content of the recent changes page."""
2691 yield u'<ul>'
2692 last = {}
2693 lastrev = {}
2694 count = 0
2695 for title, rev, date, author, comment in self.storage.history():
2696 if (author, comment) == last.get(title, (None, None)):
2697 continue
2698 count += 1
2699 if count > 100:
2700 break
2701 if rev > 0:
2702 date_url = request.adapter.build(self.diff, {
2703 'title': title,
2704 'from_rev': rev-1,
2705 'to_rev': lastrev.get(title, rev)
2706 })
2707 elif rev == 0:
2708 date_url = request.adapter.build(self.revision, {
2709 'title': title, 'rev': rev})
2710 else:
2711 date_url = request.adapter.build(self.history, {
2712 'title': title})
2713 last[title] = author, comment
2714 lastrev[title] = rev
2716 yield self.recent_changes_item_template.render(
2717 date_url=date_url,
2718 date_html=page.date_html(date),
2719 page_link=page.wiki_link(title),
2720 author_link=page.wiki_link(author),
2721 comment=werkzeug.escape(comment),
2723 yield u'</ul>'
2725 page = self.get_page(request, u'history')
2726 content = page.render_content(changes_list(page), _(u'Recent changes'))
2727 response = WikiResponse(content, mimetype='text/html')
2728 response.set_etag('/history/%d' % self.storage.repo_revision())
2729 response.make_conditional(request)
2730 return response
2732 def diff(self, request, title, from_rev, to_rev):
2733 """Show the differences between specified revisions."""
2735 page = self.get_page(request, title)
2736 diff = page.diff_content(from_rev, to_rev)
2737 from_url = request.adapter.build(self.revision,
2738 {'title': title, 'rev': from_rev})
2739 to_url = request.adapter.build(self.revision,
2740 {'title': title, 'rev': to_rev})
2741 content = itertools.chain(
2742 [werkzeug.html.p(werkzeug.html(_(u'Differences between revisions '
2743 u'%(link1)s and %(link2)s of page %(link)s.')) % {
2744 'link1': werkzeug.html.a(str(from_rev), href=from_url),
2745 'link2': werkzeug.html.a(str(to_rev), href=to_url),
2746 'link': werkzeug.html.a(werkzeug.html(title),
2747 href=request.get_url(title))
2748 })], diff)
2749 special_title = _(u'Diff for "%(title)s"') % {'title': title}
2750 html = page.render_content(content, special_title)
2751 response = werkzeug.Response(html, mimetype='text/html')
2752 return response
2754 search_item_template = Template(u"""<li>
2755 <b>${link}</b> <i>(${score})</i><div class="snippet">${snippet}</div>
2756 </li>""")
2758 def search(self, request):
2759 """Serve the search results page."""
2761 def page_index(page):
2762 """Display the index of all pages"""
2764 yield u'<p>%s</p><ul class="index">' % werkzeug.escape(
2765 _(u'Index of all pages.'))
2766 for title in sorted(self.storage.all_pages()):
2767 yield werkzeug.html.li(page.wiki_link(title))
2768 yield u'</ul>'
2770 def search_snippet(title, words):
2771 """Extract a snippet of text for search results."""
2773 try:
2774 text = self.storage.page_text(title)
2775 except werkzeug.exceptions.NotFound:
2776 return u''
2777 regexp = re.compile(u"|".join(re.escape(w) for w in words),
2778 re.U|re.I)
2779 match = regexp.search(text)
2780 if match is None:
2781 return u""
2782 position = match.start()
2783 min_pos = max(position - 60, 0)
2784 max_pos = min(position + 60, len(text))
2785 snippet = werkzeug.escape(text[min_pos:max_pos])
2786 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
2787 html = regexp.sub(highlighted, snippet)
2788 return html
2790 def page_search(words, page):
2791 """Display the search results."""
2793 self.storage.reopen()
2794 self.index.update()
2795 result = sorted(self.index.find(words), key=lambda x:-x[0])
2796 yield u'<p>%s</p><ul class="search">' % werkzeug.escape(
2797 _(u'%d page(s) containing all words:') % len(result))
2798 for score, title in result:
2799 yield self.search_item_template.render(
2800 link=page.wiki_link(title),
2801 score=score,
2802 snippet=search_snippet(title, words),
2804 yield u'</ul>'
2806 query = request.values.get('q', u'').strip()
2807 page = self.get_page(request, '')
2808 if not query:
2809 content = page_index(page)
2810 title = _(u'Page index')
2811 else:
2812 words = tuple(self.index.split_text(query, stop=False))
2813 if not words:
2814 words = (query,)
2815 title = _(u'Searching for "%s"') % u" ".join(words)
2816 content = page_search(words, page)
2817 html = page.render_content(content, title)
2818 return WikiResponse(html, mimetype='text/html')
2820 def backlinks(self, request, title):
2821 """Serve the page with backlinks."""
2823 def backlink_list(page):
2824 """Generate html for the backlinks list"""
2826 yield u'<p>%s</p><ul class="backlinks">' % (
2827 _(u'Pages that contain a link to %s.')
2828 % page.wiki_link(title))
2829 for link in self.index.page_backlinks(title):
2830 yield werkzeug.html.li(page.wiki_link(link))
2831 yield u'</ul>'
2833 self.storage.reopen()
2834 self.index.update()
2835 page = self.get_page(request, title)
2836 html = page.render_content(backlink_list(page),
2837 _(u'Links to "%s"') % title)
2838 response = WikiResponse(html, mimetype='text/html')
2839 response.set_etag('/search/%d' % self.storage.repo_revision())
2840 response.make_conditional(request)
2841 return response
2843 def favicon(self, request):
2844 """Serve the default favicon."""
2846 title = 'favicon.ico'
2847 if title in self.storage:
2848 return self.download(request, title)
2849 return werkzeug.Response(self.icon, mimetype='image/x-icon')
2851 def hgweb(self, request, path=None):
2852 """Serve the pages repository on the web like a normal hg repository."""
2854 if not self.config.get_bool('hgweb', False):
2855 raise werkzeug.exceptions.Forbidden('Repository access disabled.')
2856 app = mercurial.hgweb.request.wsgiapplication(
2857 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
2858 def hg_app(env, start):
2859 env = request.environ
2860 prefix='/+hg'
2861 if env['PATH_INFO'].startswith(prefix):
2862 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
2863 env["SCRIPT_NAME"] += prefix
2864 return app(env, start)
2865 return hg_app
2867 def robots(self, request):
2868 """Serve the robots directives."""
2870 title = 'robots.txt'
2871 if title in self.storage:
2872 return self.download(request, title)
2873 robots = ('User-agent: *\r\n'
2874 'Disallow: /+edit\r\n'
2875 'Disallow: /+feed\r\n'
2876 'Disallow: /+history\r\n'
2877 'Disallow: /+search\r\n'
2878 'Disallow: /+hg\r\n'
2880 return werkzeug.Response(robots, mimetype='text/plain')
2882 def _check_special(self, request):
2883 """
2884 Ensures that special requests come from localhost only.
2886 This seems reasonable to forbid remote URL requests to throw
2887 exceptions or kill the wiki :)
2888 """
2890 if not request.remote_addr.startswith('127.'):
2891 raise werkzeug.exceptions.Forbidden()
2893 def die(self, request):
2894 """Terminate the standalone server if invoked from localhost."""
2896 self._check_special(request)
2897 def agony():
2898 yield u'Oh dear!'
2899 self.dead = True
2900 return werkzeug.Response(agony(), mimetype='text/plain')
2902 # def test_exception(self, request):
2903 # """Used to test multi-thread thread exception handling."""
2904 # self._check_special(request)
2905 # def throw_up():
2906 # yield u'Bleeee *hyk*'
2907 # raise RuntimeError('This is a test exception.')
2908 # return werkzeug.Response(throw_up(), mimetype='text/plain')
2910 @werkzeug.responder
2911 def application(self, environ, start):
2912 """The main application loop."""
2914 adapter = self.url_map.bind_to_environ(environ)
2915 request = WikiRequest(self, adapter, environ)
2916 try:
2917 try:
2918 endpoint, values = adapter.match()
2919 return endpoint(request, **values)
2920 except werkzeug.exceptions.HTTPException, err:
2921 return err
2922 finally:
2923 request.cleanup()
2924 del request
2925 del adapter
2927 def read_config():
2928 """Read and parse the config."""
2930 config = WikiConfig(
2931 # Here you can modify the configuration: uncomment and change the ones
2932 # you need. Note that it's better use environment variables or command
2933 # line switches.
2935 # interface='',
2936 # port=8080,
2937 # pages_path = 'docs',
2938 # cache_path = 'cache',
2939 # front_page = 'Home',
2940 # site_name = 'Hatta Wiki',
2941 # page_charset = 'UTF-8',
2943 config.parse_args()
2944 config.parse_files()
2945 # config.sanitize()
2946 return config
2948 def application(env, start):
2949 """Detect that we are being run as WSGI application."""
2951 global application
2952 config = read_config()
2953 script_dir = os.path.dirname(os.path.abspath(__file__))
2954 if config.get('pages_path') == None:
2955 config.set('pages_path', os.path.join(script_dir, 'docs'))
2956 if config.get('pages_cache') == None:
2957 config.set('cache_path', os.path.join(script_dir, 'cache'))
2958 wiki = Wiki(config)
2959 application = wiki.application
2960 return application(env, start)
2962 def main():
2963 """Start a standalone WSGI server."""
2965 config = read_config()
2966 wiki = Wiki(config)
2967 app = wiki.application
2969 host, port = (config.get('interface', '0.0.0.0'),
2970 int(config.get('port', 8080)))
2971 try:
2972 from cherrypy import wsgiserver
2973 except ImportError:
2974 try:
2975 from cherrypy import _cpwsgiserver as wsgiserver
2976 except ImportError:
2977 import wsgiref.simple_server
2978 server = wsgiref.simple_server.make_server(host, port, app)
2979 try:
2980 server.serve_forever()
2981 except KeyboardInterrupt:
2982 pass
2983 return
2984 apps = [('', app)]
2985 name = wiki.site_name
2986 server = wsgiserver.CherryPyWSGIServer((host, port), apps, server_name=name)
2987 try:
2988 server.start()
2989 except KeyboardInterrupt:
2990 server.stop()
2992 if __name__ == "__main__":
2993 main()