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