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