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