Hatta Numbered Lists Branch

view hatta.py @ 646:ef5c68799a02

Support two more creole parsers for text processing
author Ben
date Mon Dec 07 21:13:59 2009 +0100 (2009-12-07)
parents cc65a44d2f23
children b2b2ecc3cc31
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", "creoleparser", "python-creole")):
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
2049 elif parser == "creoleparser":
2050 from creoleparser import text2html
2051 self.parser = lambda lines: text2html(u''.join(lines))
2052 elif parser == "python-creole":
2053 from creole import creole2html
2054 self.parser = lambda lines: creole2html(u''.join(lines))
2056 def view_content(self, lines=None):
2057 if lines is None:
2058 f = self.storage.open_page(self.title)
2059 lines = self.storage.page_lines(f)
2060 content = self.parser(lines)
2061 return content
2063 def wiki_math(self, math):
2064 math_url = self.config.get('math_url',
2065 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2066 if '%s' in math_url:
2067 url = math_url % werkzeug.url_quote(math)
2068 else:
2069 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2070 label = werkzeug.escape(math, quote=True)
2071 return werkzeug.html.img(src=url, alt=label, class_="math")
2073 def dependencies(self):
2074 dependencies = WikiPage.dependencies(self)
2075 for link in self.index.page_links(self.title):
2076 if link not in self.storage:
2077 dependencies.add(werkzeug.url_quote(link))
2078 return dependencies
2080 class WikiPageFile(WikiPage):
2081 """Pages of all other mime types use this for display."""
2083 def view_content(self, lines=None):
2084 if self.title not in self.storage:
2085 raise werkzeug.exceptions.NotFound()
2086 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2087 (self.request.get_download_url(self.title),
2088 werkzeug.escape(self.title), self.mime)]
2089 return content
2091 def editor_form(self, preview=None):
2092 author = self.request.get_author()
2093 if self.title in self.storage:
2094 comment = _(u'changed')
2095 (rev, old_date, old_author,
2096 old_comment) = self.storage.page_meta(self.title)
2097 if old_author == author:
2098 comment = old_comment
2099 else:
2100 comment = _(u'uploaded')
2101 rev = -1
2102 html = werkzeug.html
2103 yield html.p(html(
2104 _(u"This is a binary file, it can't be edited on a wiki. "
2105 u"Please upload a new version instead.")))
2106 yield html.form(html.div(
2107 html.div(html.input(type_="file", name="data"), class_="upload"),
2108 html.input(type_="hidden", name="parent", value=rev),
2109 html.label(html(_(u'Comment')), html.input(name="comment",
2110 value=comment)),
2111 html.label(html(_(u'Author')), html.input(name="author",
2112 value=author)),
2113 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2114 html.input(type_="submit", name="cancel",
2115 value=_(u'Cancel')),
2116 class_="buttons")), action="", method="POST", class_="editor",
2117 enctype="multipart/form-data")
2119 class WikiPageImage(WikiPageFile):
2120 """Pages of mime type image/* use this for display."""
2122 render_file = '128x128.png'
2124 def view_content(self, lines=None):
2125 if self.title not in self.storage:
2126 raise werkzeug.exceptions.NotFound()
2127 content = ['<img src="%s" alt="%s">'
2128 % (self.request.get_url(self.title, self.wiki.render),
2129 werkzeug.escape(self.title))]
2130 return content
2132 def render_mime(self):
2133 """Give the filename and mime type of the rendered thumbnail."""
2135 if not Image:
2136 raise NotImplementedError('No Image library available')
2137 return self.render_file, 'image/png'
2139 def render_cache(self, cache_dir):
2140 """Render the thumbnail and save in the cache."""
2142 if not Image:
2143 raise NotImplementedError('No Image library available')
2144 page_file = self.storage.open_page(self.title)
2145 cache_path = os.path.join(cache_dir, self.render_file)
2146 cache_file = open(cache_path, 'wb')
2147 try:
2148 im = Image.open(page_file)
2149 im = im.convert('RGBA')
2150 im.thumbnail((128, 128), Image.ANTIALIAS)
2151 im.save(cache_file,'PNG')
2152 except IOError:
2153 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2154 cache_file.close()
2155 return cache_path
2157 class WikiPageCSV(WikiPageFile):
2158 """Display class for type text/csv."""
2160 def view_content(self, lines=None):
2161 if self.title not in self.storage:
2162 raise werkzeug.exceptions.NotFound()
2163 import csv
2164 csv_file = self.storage.open_page(self.title)
2165 reader = csv.reader(csv_file)
2166 html_title = werkzeug.escape(self.title, quote=True)
2167 yield u'<table id="%s" class="csvfile">' % html_title
2168 try:
2169 for row in reader:
2170 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2171 for cell in row))
2172 except csv.Error, e:
2173 yield u'</table>'
2174 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2175 % (html_title, reader.line_num, e))
2176 finally:
2177 csv_file.close()
2178 yield u'</table>'
2180 class WikiTitleConverter(werkzeug.routing.PathConverter):
2181 """Behaves like the path converter, except that it escapes slashes."""
2183 def to_url(self, value):
2184 return werkzeug.url_quote(value, self.map.charset, safe="")
2186 class WikiAllConverter(werkzeug.routing.BaseConverter):
2187 """Matches everything."""
2189 regex='.*'
2192 class Wiki(object):
2193 """
2194 The main class of the wiki, handling initialization of the whole
2195 application and most of the logic.
2196 """
2197 storage_class = WikiStorage
2198 index_class = WikiSearch
2199 mime_map = {
2200 'text': WikiPageText,
2201 'application/javascript': WikiPageText,
2202 'text/csv': WikiPageCSV,
2203 'text/x-wiki': WikiPageWiki,
2204 'image': WikiPageImage,
2205 '': WikiPageFile,
2207 icon = base64.b64decode(
2208 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2209 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2210 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2211 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2212 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2213 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2214 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2215 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2216 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2217 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2218 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2219 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2220 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2221 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2222 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2224 def __init__(self, config):
2225 self.dead = False
2226 self.config = config
2227 self.language = config.get('language', None)
2228 global _
2229 if self.language is not None:
2230 try:
2231 _ = gettext.translation('hatta', 'locale',
2232 languages=[self.language]).ugettext
2233 except IOError:
2234 _ = gettext.translation('hatta', fallback=True,
2235 languages=[self.language]).ugettext
2236 else:
2237 _ = gettext.translation('hatta', fallback=True).ugettext
2238 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2239 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2240 self.page_charset = config.get('page_charset', 'utf-8')
2241 self.menu_page = self.config.get('menu_page', u'Menu')
2242 self.front_page = self.config.get('front_page', u'Home')
2243 self.logo_page = self.config.get('logo_page', u'logo.png')
2244 self.locked_page = self.config.get('locked_page', u'Locked')
2245 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2246 self.style_page = self.config.get('style_page', u'style.css')
2247 self.read_only = self.config.get_bool('read_only', False)
2248 self.script_page = self.config.get('script_page', None)
2249 self.icon_page = self.config.get('icon_page', None)
2251 self.storage = self.storage_class(self.path, self.page_charset)
2252 if not os.path.isdir(self.cache):
2253 os.makedirs(self.cache)
2254 reindex = True
2255 else:
2256 reindex = False
2257 self.index = self.index_class(self.cache, self.language, self.storage)
2258 R = werkzeug.routing.Rule
2259 self.url_map = werkzeug.routing.Map([
2260 R('/', defaults={'title': self.front_page},
2261 endpoint=self.view, methods=['GET', 'HEAD']),
2262 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2263 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2264 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2265 R('/+history/', endpoint=self.recent_changes,
2266 methods=['GET', 'HEAD']),
2267 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2268 endpoint=self.diff, methods=['GET']),
2269 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2270 methods=['GET']),
2271 R('/+history/<title:title>', endpoint=self.history,
2272 methods=['GET', 'HEAD']),
2273 R('/+download/<title:title>', endpoint=self.download,
2274 methods=['GET', 'HEAD']),
2275 R('/+render/<title:title>', endpoint=self.render,
2276 methods=['GET', 'HEAD']),
2277 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2278 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2279 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2280 R('/favicon.ico', endpoint=self.favicon, methods=['GET', 'HEAD']),
2281 R('/robots.txt', endpoint=self.robots, methods=['GET']),
2282 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2283 R('/+search/<title:title>', endpoint=self.backlinks,
2284 methods=['GET', 'POST']),
2285 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2286 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2287 methods=['GET', 'POST', 'HEAD']),
2288 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2290 def get_page(self, request, title):
2291 """Creates a page object based on page's mime type"""
2293 if title:
2294 mime = self.storage.page_mime(title)
2295 major, minor = mime.split('/', 1)
2296 plus_pos = minor.find('+')
2297 if plus_pos>0:
2298 minor_base = minor[plus_pos]
2299 else:
2300 minor_base = ''
2301 try:
2302 page_class = self.mime_map[mime]
2303 except KeyError:
2304 try:
2305 page_class = self.mime_map['/'.join([major, minor_base])]
2306 except KeyError:
2307 try:
2308 page_class = self.mime_map[major]
2309 except KeyError:
2310 page_class = self.mime_map['']
2311 else:
2312 page_class = WikiPage
2313 mime = ''
2314 return page_class(self, request, title, mime)
2316 def view(self, request, title):
2317 page = self.get_page(request, title)
2318 try:
2319 content = page.view_content()
2320 except werkzeug.exceptions.NotFound:
2321 url = request.get_url(title, self.edit, external=True)
2322 return werkzeug.routing.redirect(url, code=303)
2323 html = page.render_content(content)
2324 dependencies = page.dependencies()
2325 etag = '/(%s)' % u','.join(dependencies)
2326 return self.response(request, title, html, etag=etag)
2328 def revision(self, request, title, rev):
2329 text = self.storage.revision_text(title, rev)
2330 link = werkzeug.html.a(werkzeug.html(title),
2331 href=request.get_url(title))
2332 content = [
2333 werkzeug.html.p(
2334 werkzeug.html(
2335 _(u'Content of revision %(rev)d of page %(title)s:'))
2336 % {'rev': rev, 'title': link }),
2337 werkzeug.html.pre(werkzeug.html(text)),
2339 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2340 page = self.get_page(request, title)
2341 html = page.render_content(content, special_title)
2342 response = self.response(request, title, html, rev=rev, etag='/old')
2343 return response
2345 def check_lock(self, title):
2346 if self.read_only:
2347 raise werkzeug.exceptions.Forbidden(_("This site is read-only."))
2348 if self.script_page and title == self.script_page:
2349 raise werkzeug.exceptions.Forbidden(_("""Can't edit live scripts.
2350 To edit this page remove it from the script_page option first."""))
2351 if self.locked_page in self.storage:
2352 if title in self.index.page_links(self.locked_page):
2353 raise werkzeug.exceptions.Forbidden(_("This page is locked."))
2355 def save(self, request, title):
2356 self.check_lock(title)
2357 url = request.get_url(title)
2358 if request.form.get('cancel'):
2359 if title not in self.storage:
2360 url = request.get_url(self.front_page)
2361 if request.form.get('preview'):
2362 text = request.form.get("text")
2363 if text is not None:
2364 lines = text.split('\n')
2365 else:
2366 lines = [werkzeug.html.p(werkzeug.html(
2367 _(u'No preview for binaries.')))]
2368 return self.edit(request, title, preview=lines)
2369 elif request.form.get('save'):
2370 comment = request.form.get("comment", "")
2371 author = request.get_author()
2372 text = request.form.get("text")
2373 try:
2374 parent = int(request.form.get("parent"))
2375 except (ValueError, TypeError):
2376 parent = None
2377 self.storage.reopen()
2378 self.index.update()
2379 if text is not None:
2380 if title == self.locked_page:
2381 for link, label in WikiParser.extract_links(text):
2382 if title == link:
2383 raise werkzeug.exceptions.Forbidden()
2384 if u'href="' in comment or u'http:' in comment:
2385 raise werkzeug.exceptions.Forbidden()
2386 if text.strip() == '':
2387 self.storage.delete_page(title, author, comment)
2388 url = request.get_url(self.front_page)
2389 else:
2390 self.storage.save_text(title, text, author, comment, parent)
2391 else:
2392 text = u''
2393 upload = request.files['data']
2394 f = upload.stream
2395 if f is not None and upload.filename is not None:
2396 try:
2397 self.storage.save_file(title, f.tmpname, author,
2398 comment, parent)
2399 except AttributeError:
2400 self.storage.save_data(title, f.read(), author,
2401 comment, parent)
2402 else:
2403 self.storage.delete_page(title, author, comment)
2404 url = request.get_url(self.front_page)
2405 self.index.update_page(title, text=text)
2406 response = werkzeug.routing.redirect(url, code=303)
2407 response.set_cookie('author',
2408 werkzeug.url_quote(request.get_author()),
2409 max_age=604800)
2410 return response
2412 def edit(self, request, title, preview=None):
2413 self.check_lock(title)
2414 page = self.get_page(request, title)
2415 content = page.editor_form(preview)
2416 special_title = _(u'Editing "%(title)s"') % {'title': title}
2417 html = page.render_content(content, special_title)
2418 if title not in self.storage:
2419 return werkzeug.Response(html, mimetype="text/html",
2420 status='404 Not found')
2421 elif preview:
2422 return werkzeug.Response(html, mimetype="text/html")
2423 else:
2424 return self.response(request, title, html, '/edit')
2426 def atom(self, request):
2427 date_format = "%Y-%m-%dT%H:%M:%SZ"
2428 first_date = datetime.datetime.now()
2429 now = first_date.strftime(date_format)
2430 body = []
2431 first_title = u''
2432 count = 0
2433 unique_titles = {}
2434 for title, rev, date, author, comment in self.storage.history():
2435 if title in unique_titles:
2436 continue
2437 unique_titles[title] = True
2438 count += 1
2439 if count > 10:
2440 break
2441 if not first_title:
2442 first_title = title
2443 first_rev = rev
2444 first_date = date
2445 item = u"""<entry>
2446 <title>%(title)s</title>
2447 <link href="%(page_url)s" />
2448 <content>%(comment)s</content>
2449 <updated>%(date)s</updated>
2450 <author>
2451 <name>%(author)s</name>
2452 <uri>%(author_url)s</uri>
2453 </author>
2454 <id>%(url)s</id>
2455 </entry>""" % {
2456 'title': werkzeug.escape(title),
2457 'page_url': request.adapter.build(self.view, {'title': title},
2458 force_external=True),
2459 'comment': werkzeug.escape(comment),
2460 'date': date.strftime(date_format),
2461 'author': werkzeug.escape(author),
2462 'author_url': request.adapter.build(self.view,
2463 {'title': author},
2464 force_external=True),
2465 'url': request.adapter.build(self.revision,
2466 {'title': title, 'rev': rev},
2467 force_external=True),
2469 body.append(item)
2470 content = u"""<?xml version="1.0" encoding="utf-8"?>
2471 <feed xmlns="http://www.w3.org/2005/Atom">
2472 <title>%(title)s</title>
2473 <link rel="self" href="%(atom)s"/>
2474 <link href="%(home)s"/>
2475 <id>%(home)s</id>
2476 <updated>%(date)s</updated>
2477 <logo>%(logo)s</logo>
2478 %(body)s
2479 </feed>""" % {
2480 'title': self.site_name,
2481 'home': request.adapter.build(self.view, force_external=True),
2482 'atom': request.adapter.build(self.atom, force_external=True),
2483 'date': first_date.strftime(date_format),
2484 'logo': request.adapter.build(self.download,
2485 {'title': self.logo_page},
2486 force_external=True),
2487 'body': u''.join(body),
2489 response = self.response(request, 'atom', content, '/atom',
2490 'application/xml', first_rev, first_date)
2491 response.set_etag('/atom/%d' % self.storage.repo_revision())
2492 response.make_conditional(request)
2493 return response
2495 def rss(self, request):
2496 """Serve an RSS feed of recent changes."""
2498 first_date = datetime.datetime.now()
2499 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2500 rss_body = []
2501 first_title = u''
2502 count = 0
2503 unique_titles = {}
2504 for title, rev, date, author, comment in self.storage.history():
2505 if title in unique_titles:
2506 continue
2507 unique_titles[title] = True
2508 count += 1
2509 if count > 10:
2510 break
2511 if not first_title:
2512 first_title = title
2513 first_rev = rev
2514 first_date = date
2515 item = (u'<item><title>%s</title><link>%s</link>'
2516 u'<description>%s</description><pubDate>%s</pubDate>'
2517 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2518 werkzeug.escape(title),
2519 request.adapter.build(self.view, {'title': title},
2520 force_external=True),
2521 werkzeug.escape(comment),
2522 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2523 werkzeug.escape(author),
2524 request.adapter.build(self.revision,
2525 {'title': title, 'rev': rev})
2526 ))
2527 rss_body.append(item)
2528 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2529 <rss version="2.0"
2530 xmlns:dc="http://purl.org/dc/elements/1.1/"
2531 xmlns:atom="http://www.w3.org/2005/Atom"
2533 <channel>
2534 <title>%s</title>
2535 <atom:link href="%s" rel="self" type="application/rss+xml" />
2536 <link>%s</link>
2537 <description>%s</description>
2538 <generator>Hatta Wiki</generator>
2539 <language>en</language>
2540 <lastBuildDate>%s</lastBuildDate>
2542 """ % (
2543 werkzeug.escape(self.site_name),
2544 request.adapter.build(self.rss),
2545 request.adapter.build(self.recent_changes),
2546 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2547 u'in this feed.')),
2548 first_date,
2550 content = [rss_head]+rss_body+[u'</channel></rss>']
2551 response = self.response(request, 'rss', content, '/rss',
2552 'application/xml', first_rev, first_date)
2553 response.set_etag('/rss/%d' % self.storage.repo_revision())
2554 response.make_conditional(request)
2555 return response
2557 def response(self, request, title, content, etag='', mime='text/html',
2558 rev=None, date=None, size=None):
2559 """Create a WikiResponse for a page."""
2561 response = WikiResponse(content, mimetype=mime)
2562 if rev is None:
2563 inode, _size, mtime = self.storage.page_file_meta(title)
2564 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2565 inode, mtime))
2566 if size == -1:
2567 size = _size
2568 else:
2569 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2570 rev))
2571 if size:
2572 response.content_length = size
2573 response.make_conditional(request)
2574 return response
2576 def download(self, request, title):
2577 """Serve the raw content of a page."""
2579 mime = self.storage.page_mime(title)
2580 if mime == 'text/x-wiki':
2581 mime = 'text/plain'
2582 f = self.storage.open_page(title)
2583 response = self.response(request, title, f, '/download', mime, size=-1)
2584 return response
2586 def render(self, request, title):
2587 """Serve a thumbnail or otherwise rendered content."""
2589 def file_time_and_size(file_path):
2590 """Get file's modification timestamp and its size."""
2592 try:
2593 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2594 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2595 except OSError:
2596 st_mtime = 0
2597 st_size = None
2598 return st_mtime, st_size
2600 def rm_temp_dir(dir_path):
2601 """Delete the directory with subdirectories."""
2603 for root, dirs, files in os.walk(dir_path, topdown=False):
2604 for name in files:
2605 try:
2606 os.remove(os.path.join(root, name))
2607 except OSError:
2608 pass
2609 for name in dirs:
2610 try:
2611 os.rmdir(os.path.join(root, name))
2612 except OSError:
2613 pass
2614 try:
2615 os.rmdir(dir_path)
2616 except OSError:
2617 pass
2619 page = self.get_page(request, title)
2620 try:
2621 cache_filename, cache_mime = page.render_mime()
2622 render = page.render_cache
2623 except (AttributeError, NotImplementedError):
2624 return self.download(request, title)
2626 cache_dir = os.path.join(self.cache, 'render',
2627 werkzeug.url_quote(title, safe=''))
2628 cache_file = os.path.join(cache_dir, cache_filename)
2629 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2630 cache_mtime, cache_size = file_time_and_size(cache_file)
2631 if page_mtime > cache_mtime:
2632 if not os.path.exists(cache_dir):
2633 os.makedirs(cache_dir)
2634 try:
2635 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2636 result_file = render(temp_dir)
2637 mercurial.util.rename(result_file, cache_file)
2638 finally:
2639 rm_temp_dir(temp_dir)
2640 f = open(cache_file)
2641 response = self.response(request, title, f, '/render', cache_mime,
2642 size=cache_size)
2643 return response
2645 def undo(self, request, title):
2646 """Revert a change to a page."""
2648 self.check_lock(title)
2649 rev = None
2650 for key in request.form:
2651 try:
2652 rev = int(key)
2653 except ValueError:
2654 pass
2655 author = request.get_author()
2656 if rev is not None:
2657 try:
2658 parent = int(request.form.get("parent"))
2659 except (ValueError, TypeError):
2660 parent = None
2661 self.storage.reopen()
2662 self.index.update()
2663 if rev == 0:
2664 comment = _(u'Delete page %(title)s') % {'title': title}
2665 data = ''
2666 self.storage.delete_page(title, author, comment)
2667 else:
2668 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2669 'rev': rev, 'title': title}
2670 data = self.storage.page_revision(title, rev-1)
2671 self.storage.save_data(title, data, author, comment, parent)
2672 self.index.update_page(title, data=data)
2673 url = request.adapter.build(self.history, {'title': title},
2674 method='GET', force_external=True)
2675 return werkzeug.redirect(url, 303)
2677 def history(self, request, title):
2678 """Display history of changes of a page."""
2680 page = self.get_page(request, title)
2681 content = page.render_content(page.history_list(),
2682 _(u'History of "%(title)s"') % {'title': title})
2683 response = self.response(request, title, content, '/history')
2684 return response
2686 recent_changes_item_template = Template(u"""<li>
2687 <a href="${date_url}">${date_html}</a>
2688 ${page_link} . . . . ${author_link}
2689 <div class="comment">${comment}</div></li>""")
2691 def recent_changes(self, request):
2692 """Serve the recent changes page."""
2694 def changes_list(page):
2695 """Generate the content of the recent changes page."""
2697 yield u'<ul>'
2698 last = {}
2699 lastrev = {}
2700 count = 0
2701 for title, rev, date, author, comment in self.storage.history():
2702 if (author, comment) == last.get(title, (None, None)):
2703 continue
2704 count += 1
2705 if count > 100:
2706 break
2707 if rev > 0:
2708 date_url = request.adapter.build(self.diff, {
2709 'title': title,
2710 'from_rev': rev-1,
2711 'to_rev': lastrev.get(title, rev)
2712 })
2713 elif rev == 0:
2714 date_url = request.adapter.build(self.revision, {
2715 'title': title, 'rev': rev})
2716 else:
2717 date_url = request.adapter.build(self.history, {
2718 'title': title})
2719 last[title] = author, comment
2720 lastrev[title] = rev
2722 yield self.recent_changes_item_template.render(
2723 date_url=date_url,
2724 date_html=page.date_html(date),
2725 page_link=page.wiki_link(title),
2726 author_link=page.wiki_link(author),
2727 comment=werkzeug.escape(comment),
2729 yield u'</ul>'
2731 page = self.get_page(request, u'history')
2732 content = page.render_content(changes_list(page), _(u'Recent changes'))
2733 response = WikiResponse(content, mimetype='text/html')
2734 response.set_etag('/history/%d' % self.storage.repo_revision())
2735 response.make_conditional(request)
2736 return response
2738 def diff(self, request, title, from_rev, to_rev):
2739 """Show the differences between specified revisions."""
2741 page = self.get_page(request, title)
2742 diff = page.diff_content(from_rev, to_rev)
2743 from_url = request.adapter.build(self.revision,
2744 {'title': title, 'rev': from_rev})
2745 to_url = request.adapter.build(self.revision,
2746 {'title': title, 'rev': to_rev})
2747 content = itertools.chain(
2748 [werkzeug.html.p(werkzeug.html(_(u'Differences between revisions '
2749 u'%(link1)s and %(link2)s of page %(link)s.')) % {
2750 'link1': werkzeug.html.a(str(from_rev), href=from_url),
2751 'link2': werkzeug.html.a(str(to_rev), href=to_url),
2752 'link': werkzeug.html.a(werkzeug.html(title),
2753 href=request.get_url(title))
2754 })], diff)
2755 special_title = _(u'Diff for "%(title)s"') % {'title': title}
2756 html = page.render_content(content, special_title)
2757 response = werkzeug.Response(html, mimetype='text/html')
2758 return response
2760 search_item_template = Template(u"""<li>
2761 <b>${link}</b> <i>(${score})</i><div class="snippet">${snippet}</div>
2762 </li>""")
2764 def search(self, request):
2765 """Serve the search results page."""
2767 def page_index(page):
2768 """Display the index of all pages"""
2770 yield u'<p>%s</p><ul class="index">' % werkzeug.escape(
2771 _(u'Index of all pages.'))
2772 for title in sorted(self.storage.all_pages()):
2773 yield werkzeug.html.li(page.wiki_link(title))
2774 yield u'</ul>'
2776 def search_snippet(title, words):
2777 """Extract a snippet of text for search results."""
2779 try:
2780 text = self.storage.page_text(title)
2781 except werkzeug.exceptions.NotFound:
2782 return u''
2783 regexp = re.compile(u"|".join(re.escape(w) for w in words),
2784 re.U|re.I)
2785 match = regexp.search(text)
2786 if match is None:
2787 return u""
2788 position = match.start()
2789 min_pos = max(position - 60, 0)
2790 max_pos = min(position + 60, len(text))
2791 snippet = werkzeug.escape(text[min_pos:max_pos])
2792 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
2793 html = regexp.sub(highlighted, snippet)
2794 return html
2796 def page_search(words, page):
2797 """Display the search results."""
2799 self.storage.reopen()
2800 self.index.update()
2801 result = sorted(self.index.find(words), key=lambda x:-x[0])
2802 yield u'<p>%s</p><ul class="search">' % werkzeug.escape(
2803 _(u'%d page(s) containing all words:') % len(result))
2804 for score, title in result:
2805 yield self.search_item_template.render(
2806 link=page.wiki_link(title),
2807 score=score,
2808 snippet=search_snippet(title, words),
2810 yield u'</ul>'
2812 query = request.values.get('q', u'').strip()
2813 page = self.get_page(request, '')
2814 if not query:
2815 content = page_index(page)
2816 title = _(u'Page index')
2817 else:
2818 words = tuple(self.index.split_text(query, stop=False))
2819 if not words:
2820 words = (query,)
2821 title = _(u'Searching for "%s"') % u" ".join(words)
2822 content = page_search(words, page)
2823 html = page.render_content(content, title)
2824 return WikiResponse(html, mimetype='text/html')
2826 def backlinks(self, request, title):
2827 """Serve the page with backlinks."""
2829 def backlink_list(page):
2830 """Generate html for the backlinks list"""
2832 yield u'<p>%s</p><ul class="backlinks">' % (
2833 _(u'Pages that contain a link to %s.')
2834 % page.wiki_link(title))
2835 for link in self.index.page_backlinks(title):
2836 yield werkzeug.html.li(page.wiki_link(link))
2837 yield u'</ul>'
2839 self.storage.reopen()
2840 self.index.update()
2841 page = self.get_page(request, title)
2842 html = page.render_content(backlink_list(page),
2843 _(u'Links to "%s"') % title)
2844 response = WikiResponse(html, mimetype='text/html')
2845 response.set_etag('/search/%d' % self.storage.repo_revision())
2846 response.make_conditional(request)
2847 return response
2849 def favicon(self, request):
2850 """Serve the default favicon."""
2852 title = 'favicon.ico'
2853 if title in self.storage:
2854 return self.download(request, title)
2855 return werkzeug.Response(self.icon, mimetype='image/x-icon')
2857 def hgweb(self, request, path=None):
2858 """Serve the pages repository on the web like a normal hg repository."""
2860 if not self.config.get_bool('hgweb', False):
2861 raise werkzeug.exceptions.Forbidden('Repository access disabled.')
2862 app = mercurial.hgweb.request.wsgiapplication(
2863 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
2864 def hg_app(env, start):
2865 env = request.environ
2866 prefix='/+hg'
2867 if env['PATH_INFO'].startswith(prefix):
2868 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
2869 env["SCRIPT_NAME"] += prefix
2870 return app(env, start)
2871 return hg_app
2873 def robots(self, request):
2874 """Serve the robots directives."""
2876 title = 'robots.txt'
2877 if title in self.storage:
2878 return self.download(request, title)
2879 robots = ('User-agent: *\r\n'
2880 'Disallow: /+edit\r\n'
2881 'Disallow: /+feed\r\n'
2882 'Disallow: /+history\r\n'
2883 'Disallow: /+search\r\n'
2884 'Disallow: /+hg\r\n'
2886 return werkzeug.Response(robots, mimetype='text/plain')
2888 def _check_special(self, request):
2889 """
2890 Ensures that special requests come from localhost only.
2892 This seems reasonable to forbid remote URL requests to throw
2893 exceptions or kill the wiki :)
2894 """
2896 if not request.remote_addr.startswith('127.'):
2897 raise werkzeug.exceptions.Forbidden()
2899 def die(self, request):
2900 """Terminate the standalone server if invoked from localhost."""
2902 self._check_special(request)
2903 def agony():
2904 yield u'Oh dear!'
2905 self.dead = True
2906 return werkzeug.Response(agony(), mimetype='text/plain')
2908 # def test_exception(self, request):
2909 # """Used to test multi-thread thread exception handling."""
2910 # self._check_special(request)
2911 # def throw_up():
2912 # yield u'Bleeee *hyk*'
2913 # raise RuntimeError('This is a test exception.')
2914 # return werkzeug.Response(throw_up(), mimetype='text/plain')
2916 @werkzeug.responder
2917 def application(self, environ, start):
2918 """The main application loop."""
2920 adapter = self.url_map.bind_to_environ(environ)
2921 request = WikiRequest(self, adapter, environ)
2922 try:
2923 try:
2924 endpoint, values = adapter.match()
2925 return endpoint(request, **values)
2926 except werkzeug.exceptions.HTTPException, err:
2927 return err
2928 finally:
2929 request.cleanup()
2930 del request
2931 del adapter
2933 def read_config():
2934 """Read and parse the config."""
2936 config = WikiConfig(
2937 # Here you can modify the configuration: uncomment and change the ones
2938 # you need. Note that it's better use environment variables or command
2939 # line switches.
2941 # interface='',
2942 # port=8080,
2943 # pages_path = 'docs',
2944 # cache_path = 'cache',
2945 # front_page = 'Home',
2946 # site_name = 'Hatta Wiki',
2947 # page_charset = 'UTF-8',
2949 config.parse_args()
2950 config.parse_files()
2951 # config.sanitize()
2952 return config
2954 def application(env, start):
2955 """Detect that we are being run as WSGI application."""
2957 global application
2958 config = read_config()
2959 script_dir = os.path.dirname(os.path.abspath(__file__))
2960 if config.get('pages_path') == None:
2961 config.set('pages_path', os.path.join(script_dir, 'docs'))
2962 if config.get('pages_cache') == None:
2963 config.set('cache_path', os.path.join(script_dir, 'cache'))
2964 wiki = Wiki(config)
2965 application = wiki.application
2966 return application(env, start)
2968 def main():
2969 """Start a standalone WSGI server."""
2971 config = read_config()
2972 wiki = Wiki(config)
2973 app = wiki.application
2975 host, port = (config.get('interface', '0.0.0.0'),
2976 int(config.get('port', 8080)))
2977 try:
2978 from cherrypy import wsgiserver
2979 except ImportError:
2980 try:
2981 from cherrypy import _cpwsgiserver as wsgiserver
2982 except ImportError:
2983 import wsgiref.simple_server
2984 server = wsgiref.simple_server.make_server(host, port, app)
2985 try:
2986 server.serve_forever()
2987 except KeyboardInterrupt:
2988 pass
2989 return
2990 apps = [('', app)]
2991 name = wiki.site_name
2992 server = wsgiserver.CherryPyWSGIServer((host, port), apps, server_name=name)
2993 try:
2994 server.start()
2995 except KeyboardInterrupt:
2996 server.stop()
2998 if __name__ == "__main__":
2999 main()