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