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