Hatta Devel
view hatta.py @ 731:ecb0716ced56
localize forbidden pages
| author | sheep@wintermute |
|---|---|
| date | Fri Jan 22 15:56:31 2010 +0100 (2010-01-22) |
| parents | 22b21a40da4e |
| children | 96dbeefb8178 |
line source
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
4 # @copyright: 2008-2009 Radomir Dopieralski <hatta@sheep.art.pl>
5 # @license: GNU GPL, see COPYING for details.
7 """
8 Hatta Wiki is a wiki engine designed to be used with Mercurial repositories.
9 It requires Mercurial and Werkzeug python modules.
11 Hatta's pages are just plain text files (and also images, binaries, etc.) in
12 some directory in your repository. For example, you can put it in your
13 project's "docs" directory to keep documentation. The files can be edited both
14 from the wiki or with a text editor -- in either case the changes committed to
15 the repository will appear in the recent changes and in page's history.
17 See hatta.py --help for usage.
18 """
20 import base64
21 import datetime
22 import difflib
23 import gettext
24 import itertools
25 import mimetypes
26 import os
27 import re
28 import sqlite3
29 import sys
30 import tempfile
31 import thread
32 import unicodedata
35 # Avoid WSGI errors, see http://mercurial.selenic.com/bts/issue1095
36 sys.stdout = sys.__stdout__
37 sys.stderr = sys.__stderr__
39 import werkzeug
40 import werkzeug.exceptions
41 import werkzeug.routing
43 try:
44 import Image
45 except ImportError:
46 Image = None
48 try:
49 import pygments
50 import pygments.util
51 import pygments.lexers
52 import pygments.formatters
53 import pygments.styles
54 except ImportError:
55 pygments = None
57 # Note: we have to set these before importing Mercurial
58 os.environ['HGENCODING'] = 'utf-8'
59 os.environ['HGMERGE'] = "internal:merge"
61 import mercurial.hg
62 import mercurial.ui
63 import mercurial.revlog
64 import mercurial.util
65 import mercurial.hgweb
67 __version__ = '1.3.3dev'
68 name = 'Hatta'
69 url = 'http://hatta-wiki.org/'
70 description = 'Wiki engine that lives in Mercurial repository.'
72 mimetypes.add_type('application/x-python', '.wsgi')
73 mimetypes.add_type('application/x-javascript', '.js')
74 mimetypes.add_type('text/x-rst', '.rst')
77 def external_link(addr):
78 """
79 Decide whether a link is absolute or internal.
81 >>> external_link('http://example.com')
82 True
83 >>> external_link('https://example.com')
84 True
85 >>> external_link('ftp://example.com')
86 True
87 >>> external_link('mailto:user@example.com')
88 True
89 >>> external_link('PageTitle')
90 False
91 >>> external_link(u'ąęśćUnicodePage')
92 False
94 """
96 return (addr.startswith('http://')
97 or addr.startswith('https://')
98 or addr.startswith('ftp://')
99 or addr.startswith('mailto:'))
102 class WikiConfig(object):
103 """
104 Responsible for reading and storing site configuration. Contains the
105 default settings.
107 >>> config = WikiConfig(port='2080')
108 >>> config.sanitize()
109 >>> config.get('port')
110 2080
111 """
113 default_filename = u'hatta.conf'
115 # Please see the bottom of the script for modifying these values.
117 def __init__(self, **kw):
118 self.config = dict(kw)
119 self.parse_environ()
121 def sanitize(self):
122 """
123 Convert options to their required types.
124 """
126 try:
127 self.config['port'] = int(self.get('port', 0))
128 except ValueError:
129 self.config['port'] = 8080
131 def parse_environ(self):
132 """Check the environment variables for options."""
134 prefix = 'HATTA_'
135 for key, value in os.environ.iteritems():
136 if key.startswith(prefix):
137 name = key[len(prefix):].lower()
138 self.config[name] = value
140 def parse_args(self):
141 """Check the commandline arguments for options."""
143 import optparse
145 self.options = []
146 parser = optparse.OptionParser()
148 def add(*args, **kw):
149 self.options.append(kw['dest'])
150 parser.add_option(*args, **kw)
152 add('-V', '--version', dest='show_version', default=False,
153 help='Display version and exit', action="store_true")
154 add('-d', '--pages-dir', dest='pages_path',
155 help='Store pages in DIR', metavar='DIR')
156 add('-t', '--cache-dir', dest='cache_path',
157 help='Store cache in DIR', metavar='DIR')
158 add('-i', '--interface', dest='interface',
159 help='Listen on interface INT', metavar='INT')
160 add('-p', '--port', dest='port', type='int',
161 help='Listen on port PORT', metavar='PORT')
162 add('-s', '--script-name', dest='script_name',
163 help='Override SCRIPT_NAME to NAME', metavar='NAME')
164 add('-n', '--site-name', dest='site_name',
165 help='Set the name of the site to NAME', metavar='NAME')
166 add('-m', '--front-page', dest='front_page',
167 help='Use PAGE as the front page', metavar='PAGE')
168 add('-e', '--encoding', dest='page_charset',
169 help='Use encoding ENC to read and write pages', metavar='ENC')
170 add('-c', '--config-file', dest='config_file',
171 help='Read configuration from FILE', metavar='FILE')
172 add('-l', '--language', dest='language',
173 help='Translate interface to LANG', metavar='LANG')
174 add('-r', '--read-only', dest='read_only',
175 help='Whether the wiki should be read-only', action="store_true")
176 add('-g', '--icon-page', dest='icon_page', metavar="PAGE",
177 help='Read icons graphics from PAGE.')
178 add('-w', '--hgweb', dest='hgweb',
179 help='Enable hgweb access to the repository', action="store_true")
180 add('-W', '--wiki-words', dest='wiki_words',
181 help='Enable WikiWord links', action="store_true")
182 add('-I', '--ignore-indent', dest='ignore_indent',
183 help='Treat indented lines as normal text', action="store_true")
184 add('-P', '--pygments-style', dest='pygments_style',
185 help='Use the STYLE pygments style for highlighting',
186 metavar='STYLE')
188 options, args = parser.parse_args()
189 for option, value in options.__dict__.iteritems():
190 if option in self.options:
191 if value is not None:
192 self.config[option] = value
194 def parse_files(self, files=None):
195 """Check the config files for options."""
197 import ConfigParser
199 if files is None:
200 files = [self.get('config_file', self.default_filename)]
201 parser = ConfigParser.SafeConfigParser()
202 parser.read(files)
203 for section in parser.sections():
204 for option, value in parser.items(section):
205 self.config[option] = value
207 def save_config(self, filename=None):
208 """Saves configuration to a given file."""
209 if filename is None:
210 filename = self.default_filename
212 import ConfigParser
213 parser = ConfigParser.RawConfigParser()
214 section = self.config['site_name']
215 parser.add_section(section)
216 for key, value in self.config.iteritems():
217 parser.set(section, str(key), str(value))
219 configfile = open(filename, 'wb')
220 try:
221 parser.write(configfile)
222 finally:
223 configfile.close()
225 def get(self, option, default_value=None):
226 """
227 Get the value of a config option or default if not set.
229 >>> config = WikiConfig(option=4)
230 >>> config.get("ziew", 3)
231 3
232 >>> config.get("ziew")
233 >>> config.get("ziew", "ziew")
234 'ziew'
235 >>> config.get("option")
236 4
237 """
239 return self.config.get(option, default_value)
241 def get_bool(self, option, default_value=False):
242 """
243 Like get, only convert the value to True or False.
244 """
246 value = self.get(option, default_value)
247 if value in (
248 1, True,
249 'True', 'true', 'TRUE',
250 '1',
251 'on', 'On', 'ON',
252 'yes', 'Yes', 'YES',
253 'enable', 'Enable', 'ENABLE',
254 'enabled', 'Enabled', 'ENABLED',
255 ):
256 return True
257 elif value in (
258 None, 0, False,
259 'False', 'false', 'FALSE',
260 '0',
261 'off', 'Off', 'OFF',
262 'no', 'No', 'NO',
263 'disable', 'Disable', 'DISABLE',
264 'disabled', 'Disabled', 'DISABLED',
265 ):
266 return False
267 else:
268 raise ValueError("expected boolean value")
270 def set(self, key, value):
271 self.config[key] = value
274 def locked_repo(func):
275 """A decorator for locking the repository when calling a method."""
277 def new_func(self, *args, **kwargs):
278 """Wrap the original function in locks."""
280 wlock = self.repo.wlock()
281 lock = self.repo.lock()
282 try:
283 func(self, *args, **kwargs)
284 finally:
285 lock.release()
286 wlock.release()
288 return new_func
290 class WikiStorage(object):
291 """
292 Provides means of storing wiki pages and keeping track of their
293 change history, using Mercurial repository as the storage method.
294 """
296 def __init__(self, path, charset=None):
297 """
298 Takes the path to the directory where the pages are to be kept.
299 If the directory doen't exist, it will be created. If it's inside
300 a Mercurial repository, that repository will be used, otherwise
301 a new repository will be created in it.
302 """
304 self.charset = charset or 'utf-8'
305 self.path = path
306 if not os.path.exists(self.path):
307 os.makedirs(self.path)
308 self.repo_path = self._find_repo_path(self.path)
309 try:
310 self.ui = mercurial.ui.ui(report_untrusted=False,
311 interactive=False, quiet=True)
312 except TypeError:
313 # Mercurial 1.3 changed the way we setup the ui object.
314 self.ui = mercurial.ui.ui()
315 self.ui.quiet = True
316 self.ui._report_untrusted = False
317 self.ui.setconfig('ui', 'interactive', False)
318 if self.repo_path is None:
319 self.repo_path = self.path
320 create = True
321 else:
322 create = False
323 self.repo_prefix = self.path[len(self.repo_path):].strip('/')
324 self._repos = {}
325 # Create the repository if needed.
326 mercurial.hg.repository(self.ui, self.repo_path, create=create)
328 def reopen(self):
329 """Close and reopen the repo, to make sure we are up to date."""
331 #self.repo = mercurial.hg.repository(self.ui, self.repo_path)
332 self._repos = {}
334 @property
335 def repo(self):
336 """Keep one open repository per thread."""
338 thread_id = thread.get_ident()
339 try:
340 return self._repos[thread_id]
341 except KeyError:
342 repo = mercurial.hg.repository(self.ui, self.repo_path)
343 self._repos[thread_id] = repo
344 return repo
346 def _find_repo_path(self, path):
347 """Go up the directory tree looking for a repository."""
349 while not os.path.isdir(os.path.join(path, ".hg")):
350 old_path, path = path, os.path.dirname(path)
351 if path == old_path:
352 return None
353 return path
355 def _file_path(self, title):
356 title = unicode(title).strip()
357 return os.path.join(self.path,
358 werkzeug.url_quote(title, safe=''))
360 def _title_to_file(self, title):
361 title = unicode(title).strip()
362 return os.path.join(self.repo_prefix,
363 werkzeug.url_quote(title, safe=''))
365 def _file_to_title(self, filename):
366 assert filename.startswith(self.repo_prefix)
367 name = filename[len(self.repo_prefix):].strip('/')
368 return werkzeug.url_unquote(name)
370 def __contains__(self, title):
371 if title:
372 file_path = self._file_path(title)
373 return os.path.isfile(file_path) and not os.path.islink(file_path)
375 def __iter__(self):
376 return self.all_pages()
378 def merge_changes(self, changectx, repo_file, text, user, parent):
379 """Commits and merges conflicting changes in the repository."""
381 tip_node = changectx.node()
382 filectx = changectx[repo_file].filectx(parent)
383 parent_node = filectx.changectx().node()
385 self.repo.dirstate.setparents(parent_node)
386 node = self._commit([repo_file], text, user)
388 partial = lambda filename: repo_file == filename
389 try:
390 unresolved = mercurial.merge.update(self.repo, tip_node,
391 True, True, partial)
392 msg = _(u'merge of edit conflict')
393 except mercurial.util.Abort:
394 unresolved = 1, 1, 1, 1
395 msg = _(u'failed merge of edit conflict')
396 self.repo.dirstate.setparents(tip_node, node)
397 # Mercurial 1.1 and later need updating the merge state
398 try:
399 mercurial.merge.mergestate(self.repo).mark(repo_file, "r")
400 except (AttributeError, KeyError):
401 pass
402 return msg
404 @locked_repo
405 def save_file(self, title, file_name, author=u'', comment=u'', parent=None):
406 """Save an existing file as specified page."""
408 user = author.encode('utf-8') or _(u'anon').encode('utf-8')
409 text = comment.encode('utf-8') or _(u'comment').encode('utf-8')
410 repo_file = self._title_to_file(title)
411 file_path = self._file_path(title)
412 if os.path.islink(file_path):
413 raise werkzeug.exceptions.Forbidden(_(u"Can't edit symbolic links"))
414 mercurial.util.rename(file_name, file_path)
415 changectx = self._changectx()
416 try:
417 filectx_tip = changectx[repo_file]
418 current_page_rev = filectx_tip.filerev()
419 except mercurial.revlog.LookupError:
420 self.repo.add([repo_file])
421 current_page_rev = -1
422 if parent is not None and current_page_rev != parent:
423 msg = self.merge_changes(changectx, repo_file, text, user, parent)
424 user = '<wiki>'
425 text = msg.encode('utf-8')
426 self._commit([repo_file], text, user)
429 def _commit(self, files, text, user):
430 try:
431 return self.repo.commit(files=files, text=text, user=user,
432 force=True, empty_ok=True)
433 except TypeError:
434 # Mercurial 1.3 doesn't accept empty_ok or files parameter
435 match = mercurial.match.exact(self.repo_path, '', list(files))
436 return self.repo.commit(match=match, text=text, user=user,
437 force=True)
440 def save_data(self, title, data, author=u'', comment=u'', parent=None):
441 """Save data as specified page."""
443 try:
444 temp_path = tempfile.mkdtemp(dir=self.path)
445 file_path = os.path.join(temp_path, 'saved')
446 f = open(file_path, "wb")
447 f.write(data)
448 f.close()
449 self.save_file(title, file_path, author, comment, parent)
450 finally:
451 try:
452 os.unlink(file_path)
453 except OSError:
454 pass
455 try:
456 os.rmdir(temp_path)
457 except OSError:
458 pass
460 def save_text(self, title, text, author=u'', comment=u'', parent=None):
461 """Save text as specified page, encoded to charset."""
463 data = text.encode(self.charset)
464 self.save_data(title, data, author, comment, parent)
466 def page_text(self, title):
467 """Read unicode text of a page."""
469 data = self.open_page(title).read()
470 text = unicode(data, self.charset, 'replace')
471 return text
473 def page_lines(self, page):
474 for data in page.xreadlines():
475 yield unicode(data, self.charset, 'replace')
477 @locked_repo
478 def delete_page(self, title, author=u'', comment=u''):
479 user = author.encode('utf-8') or 'anon'
480 text = comment.encode('utf-8') or 'deleted'
481 repo_file = self._title_to_file(title)
482 file_path = self._file_path(title)
483 if os.path.islink(file_path):
484 raise werkzeug.exceptions.Forbidden(_(u"Can't edit symbolic links"))
485 try:
486 os.unlink(file_path)
487 except OSError:
488 pass
489 self.repo.remove([repo_file])
490 self._commit([repo_file], text, user)
492 def open_page(self, title):
493 """Open the page and return a file-like object with its contents."""
495 file_path = self._file_path(title)
496 if os.path.islink(file_path):
497 raise werkzeug.exceptions.Forbidden(_(u"Can't read symbolic links"))
498 try:
499 return open(file_path, "rb")
500 except IOError:
501 raise werkzeug.exceptions.NotFound()
503 def page_file_meta(self, title):
504 """Get page's inode number, size and last modification time."""
506 try:
507 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
508 st_atime, st_mtime, st_ctime) = os.stat(self._file_path(title))
509 except OSError:
510 return 0, 0, 0
511 return st_ino, st_size, st_mtime
513 def page_meta(self, title):
514 """Get page's revision, date, last editor and his edit comment."""
516 filectx_tip = self._find_filectx(title)
517 if filectx_tip is None:
518 raise werkzeug.exceptions.NotFound()
519 #return -1, None, u'', u''
520 rev = filectx_tip.filerev()
521 filectx = filectx_tip.filectx(rev)
522 date = datetime.datetime.fromtimestamp(filectx.date()[0])
523 author = unicode(filectx.user(), "utf-8",
524 'replace').split('<')[0].strip()
525 comment = unicode(filectx.description(), "utf-8", 'replace')
526 return rev, date, author, comment
528 def repo_revision(self):
529 """Give the latest revision of the repository."""
531 return self._changectx().rev()
533 def page_mime(self, title):
534 """
535 Guess page's mime type ased on corresponding file name.
536 Default ot text/x-wiki for files without an extension.
538 >>> page_mime('something.txt')
539 'text/plain'
540 >>> page_mime('SomePage')
541 'text/x-wiki'
542 >>> page_mime(u'ąęśUnicodePage')
543 'text/x-wiki'
544 >>> page_mime('image.png')
545 'image/png'
546 >>> page_mime('style.css')
547 'text/css'
548 >>> page_mime('archive.tar.gz')
549 'archive/gzip'
550 """
552 addr = self._file_path(title)
553 mime, encoding = mimetypes.guess_type(addr, strict=False)
554 if encoding:
555 mime = 'archive/%s' % encoding
556 if mime is None:
557 mime = 'text/x-wiki'
558 return mime
560 def _changectx(self):
561 """Get the changectx of the tip."""
563 try:
564 # This is for Mercurial 1.0
565 return self.repo.changectx()
566 except TypeError:
567 # Mercurial 1.3 (and possibly earlier) needs an argument
568 return self.repo.changectx('tip')
570 def _find_filectx(self, title):
571 """Find the last revision in which the file existed."""
573 repo_file = self._title_to_file(title)
574 changectx = self._changectx()
575 stack = [changectx]
576 while repo_file not in changectx:
577 if not stack:
578 return None
579 changectx = stack.pop()
580 for parent in changectx.parents():
581 if parent != changectx:
582 stack.append(parent)
583 return changectx[repo_file]
585 def page_history(self, title):
586 """Iterate over the page's history."""
588 filectx_tip = self._find_filectx(title)
589 if filectx_tip is None:
590 return
591 maxrev = filectx_tip.filerev()
592 minrev = 0
593 for rev in range(maxrev, minrev-1, -1):
594 filectx = filectx_tip.filectx(rev)
595 date = datetime.datetime.fromtimestamp(filectx.date()[0])
596 author = unicode(filectx.user(), "utf-8",
597 'replace').split('<')[0].strip()
598 comment = unicode(filectx.description(), "utf-8", 'replace')
599 yield rev, date, author, comment
601 def page_revision(self, title, rev):
602 """Get binary content of the specified revision of the page."""
604 filectx_tip = self._find_filectx(title)
605 if filectx_tip is None:
606 raise werkzeug.exceptions.NotFound()
607 try:
608 data = filectx_tip.filectx(rev).data()
609 except IndexError:
610 raise werkzeug.exceptions.NotFound()
611 return data
613 def revision_text(self, title, rev):
614 """Get unicode text of the specified revision of the page."""
616 data = self.page_revision(title, rev)
617 text = unicode(data, self.charset, 'replace')
618 return text
620 def history(self):
621 """Iterate over the history of entire wiki."""
623 changectx = self._changectx()
624 maxrev = changectx.rev()
625 minrev = 0
626 for wiki_rev in range(maxrev, minrev-1, -1):
627 change = self.repo.changectx(wiki_rev)
628 date = datetime.datetime.fromtimestamp(change.date()[0])
629 author = unicode(change.user(), "utf-8",
630 'replace').split('<')[0].strip()
631 comment = unicode(change.description(), "utf-8", 'replace')
632 for repo_file in change.files():
633 if repo_file.startswith(self.repo_prefix):
634 title = self._file_to_title(repo_file)
635 try:
636 rev = change[repo_file].filerev()
637 except mercurial.revlog.LookupError:
638 rev = -1
639 yield title, rev, date, author, comment
641 def all_pages(self):
642 """Iterate over the titles of all pages in the wiki."""
644 for filename in os.listdir(self.path):
645 if (os.path.isfile(os.path.join(self.path, filename))
646 and not filename.startswith('.')):
647 yield werkzeug.url_unquote(filename)
649 def changed_since(self, rev):
650 """Return all pages that changed since specified repository revision."""
652 try:
653 last = self.repo.lookup(int(rev))
654 except IndexError:
655 for page in self.all_pages():
656 yield page
657 return
658 current = self.repo.lookup('tip')
659 status = self.repo.status(current, last)
660 modified, added, removed, deleted, unknown, ignored, clean = status
661 for filename in modified+added+removed+deleted:
662 if filename.startswith(self.repo_prefix):
663 yield self._file_to_title(filename)
665 class WikiParser(object):
666 r"""
667 Responsible for generating HTML markup from the wiki markup.
669 The parser works on two levels. On the block level, it analyzes lines
670 of text and decides what kind of block element they belong to (block
671 elements include paragraphs, lists, headings, preformatted blocks).
672 Lines belonging to the same block are joined together, and a second
673 pass is made using regular expressions to parse line-level elements,
674 such as links, bold and italic text and smileys.
676 Some block-level elements, such as preformatted blocks, consume additional
677 lines from the input until they encounter the end-of-block marker, using
678 lines_until. Most block-level elements are just runs of marked up lines
679 though.
682 """
684 bullets_pat = ur"^\s*[*]+\s+"
685 heading_pat = ur"^\s*=+"
686 quote_pat = ur"^[>]+\s+"
687 block = {
688 # "name": (priority, ur"pattern"),
689 "bullets": (10, bullets_pat),
690 "code": (20, ur"^[{][{][{]+\s*$"),
691 "conflict": (30, ur"^<<<<<<< local\s*$"),
692 "empty": (40, ur"^\s*$"),
693 "heading": (50, heading_pat),
694 "indent": (60, ur"^[ \t]+"),
695 "macro":(70, ur"^<<\w+\s*$"),
696 "quote": (80, quote_pat),
697 "rule": (90, ur"^\s*---+\s*$"),
698 "syntax": (100, ur"^\{\{\{\#![\w+#.-]+\s*$"),
699 "table": (110, ur"^\|"),
700 }
701 image_pat = (ur"\{\{(?P<image_target>([^|}]|}[^|}])*)"
702 ur"(\|(?P<image_text>([^}]|}[^}])*))?}}")
703 smilies = {
704 r':)': "smile.png",
705 r':(': "frown.png",
706 r':P': "tongue.png",
707 r':D': "grin.png",
708 r';)': "wink.png",
709 }
710 punct = {
711 r'...': "…",
712 r'--': "–",
713 r'---': "—",
714 r'~': " ",
715 r'\~': "~",
716 r'~~': "∼",
717 r'(C)': "©",
718 r'-->': "→",
719 r'<--': "←",
720 r'(R)': "®",
721 r'(TM)': "™",
722 r'%%': "‰",
723 r'``': "“",
724 r"''": "”",
725 r",,": "„",
726 }
727 markup = {
728 # "name": (priority, ur"pattern"),
729 "bold": (10, ur"[*][*]"),
730 "code": (20, ur"[{][{][{](?P<code_text>([^}]|[^}][}]|[^}][}][}])"
731 ur"*[}]*)[}][}][}]"),
732 "free_link": (30, ur"""(http|https|ftp)://\S+[^\s.,:;!?()'"=+<>-]"""),
733 "italic": (40 , ur"//"),
734 "link": (50, ur"\[\[(?P<link_target>([^|\]]|\][^|\]])+)"
735 ur"(\|(?P<link_text>([^\]]|\][^\]])+))?\]\]"),
736 "image": (60, image_pat),
737 "linebreak": (70, ur"\\\\"),
738 "macro": (80, ur"[<][<](?P<macro_name>\w+)\s+"
739 ur"(?P<macro_text>([^>]|[^>][>])+)[>][>]"),
740 "mail": (90, ur"""(mailto:)?\S+@\S+(\.[^\s.,:;!?()'"/=+<>-]+)+"""),
741 "math": (100, ur"\$\$(?P<math_text>[^$]+)\$\$"),
742 "mono": (110, ur"##"),
743 "newline": (120, ur"\n"),
744 "punct": (130, ur'(^|\b|(?<=\s))(%s)((?=[\s.,:;!?)/&=+])|\b|$)' %
745 ur"|".join(re.escape(k) for k in punct)),
746 "table": (140, ur"=?\|=?"),
747 "text": (150, ur".+?"),
748 }
751 def __init__(self, lines, wiki_link, wiki_image,
752 wiki_syntax=None, wiki_math=None, smilies=None):
753 self.wiki_link = wiki_link
754 self.wiki_image = wiki_image
755 self.wiki_syntax = wiki_syntax
756 self.wiki_math = wiki_math
757 self.enumerated_lines = enumerate(lines)
758 if smilies is not None:
759 self.smilies = smilies
760 self.compile_patterns()
761 self.headings = {}
762 self.stack = []
763 self.line_no = 0
765 def compile_patterns(self):
766 self.quote_re = re.compile(self.quote_pat, re.U)
767 self.heading_re = re.compile(self.heading_pat, re.U)
768 self.bullets_re = re.compile(self.bullets_pat, re.U)
769 patterns = ((k, p) for (k, (x, p)) in
770 sorted(self.block.iteritems(), key=lambda x: x[1][0]))
771 self.block_re = re.compile(ur"|".join("(?P<%s>%s)" % pat
772 for pat in patterns), re.U)
773 self.code_close_re = re.compile(ur"^\}\}\}\s*$", re.U)
774 self.macro_close_re = re.compile(ur"^>>\s*$", re.U)
775 self.conflict_close_re = re.compile(ur"^>>>>>>> other\s*$", re.U)
776 self.conflict_sep_re = re.compile(ur"^=======\s*$", re.U)
777 self.image_re = re.compile(self.image_pat, re.U)
778 smileys = ur"|".join(re.escape(k) for k in self.smilies)
779 smiley_pat = (ur"(^|\b|(?<=\s))(?P<smiley_face>%s)"
780 ur"((?=[\s.,:;!?)/&=+-])|$)" % smileys)
781 self.markup['smiley'] = (125, smiley_pat)
782 patterns = ((k, p) for (k, (x, p)) in
783 sorted(self.markup.iteritems(), key=lambda x: x[1][0]))
784 self.markup_re = re.compile(ur"|".join("(?P<%s>%s)" % pat
785 for pat in patterns), re.U)
787 def __iter__(self):
788 return self.parse()
790 @classmethod
791 def extract_links(cls, text):
792 links = []
793 def link(addr, label=None, class_=None, image=None, alt=None, lineno=0):
794 addr = addr.strip()
795 if external_link(addr):
796 return u''
797 if '#' in addr:
798 addr, chunk = addr.split('#', 1)
799 if addr == u'':
800 return u''
801 links.append((addr, label))
802 return u''
803 lines = text.split('\n')
804 for part in cls(lines, link, link):
805 for ret in links:
806 yield ret
807 links[:] = []
809 def parse(self):
810 """Parse a list of lines of wiki markup, yielding HTML for it."""
812 self.headings = {}
813 self.stack = []
814 self.line_no = 0
816 def key(enumerated_line):
817 line_no, line = enumerated_line
818 match = self.block_re.match(line)
819 if match:
820 return match.lastgroup
821 return "paragraph"
823 for kind, block in itertools.groupby(self.enumerated_lines, key):
824 func = getattr(self, "_block_%s" % kind)
825 for part in func(block):
826 yield part
828 def parse_line(self, line):
829 """
830 Find all the line-level markup and return HTML for it.
832 """
834 for match in self.markup_re.finditer(line):
835 func = getattr(self, "_line_%s" % match.lastgroup)
836 yield func(match.groupdict())
838 def pop_to(self, stop):
839 """
840 Pop from the stack until the specified tag is encoutered.
841 Return string containing closing tags of everything popped.
842 """
843 tags = []
844 tag = None
845 try:
846 while tag != stop:
847 tag = self.stack.pop()
848 tags.append(tag)
849 except IndexError:
850 pass
851 return u"".join(u"</%s>" % tag for tag in tags)
853 def lines_until(self, close_re):
854 """Get lines from input until the closing markup is encountered."""
856 self.line_no, line = self.enumerated_lines.next()
857 while not close_re.match(line):
858 yield line.rstrip()
859 line_no, line = self.enumerated_lines.next()
861 # methods for the markup inside lines:
863 def _line_table(self, groups):
864 return groups["table"]
866 def _line_linebreak(self, groups):
867 return u'<br>'
869 def _line_smiley(self, groups):
870 smiley = groups["smiley_face"]
871 try:
872 url = self.smilies[smiley]
873 except KeyError:
874 url = ''
875 return self.wiki_image(url, smiley, class_="smiley")
877 def _line_bold(self, groups):
878 if 'b' in self.stack:
879 return self.pop_to('b')
880 else:
881 self.stack.append('b')
882 return u"<b>"
884 def _line_italic(self, groups):
885 if 'i' in self.stack:
886 return self.pop_to('i')
887 else:
888 self.stack.append('i')
889 return u"<i>"
891 def _line_mono(self, groups):
892 if 'tt' in self.stack:
893 return self.pop_to('tt')
894 else:
895 self.stack.append('tt')
896 return u"<tt>"
898 def _line_punct(self, groups):
899 text = groups["punct"]
900 return self.punct.get(text, text)
902 def _line_newline(self, groups):
903 return "\n"
905 def _line_text(self, groups):
906 return werkzeug.escape(groups["text"])
908 def _line_math(self, groups):
909 if self.wiki_math:
910 return self.wiki_math(groups["math_text"])
911 else:
912 return "<var>%s</var>" % werkzeug.escape(groups["math_text"])
914 def _line_code(self, groups):
915 return u'<code>%s</code>' % werkzeug.escape(groups["code_text"])
917 def _line_free_link(self, groups):
918 groups['link_target'] = groups['free_link']
919 return self._line_link(groups)
921 def _line_mail(self, groups):
922 addr = groups['mail']
923 groups['link_text'] = addr
924 if not addr.startswith(u'mailto:'):
925 addr = u'mailto:%s' % addr
926 groups['link_target'] = addr
927 return self._line_link(groups)
929 def _line_link(self, groups):
930 target = groups['link_target']
931 text = groups.get('link_text')
932 if not text:
933 text = target
934 if '#' in text:
935 text, chunk = text.split('#', 1)
936 match = self.image_re.match(text)
937 if match:
938 image = self._line_image(match.groupdict())
939 return self.wiki_link(target, text, image=image)
940 return self.wiki_link(target, text)
942 def _line_image(self, groups):
943 target = groups['image_target']
944 alt = groups.get('image_text')
945 if alt is None:
946 alt = target
947 return self.wiki_image(target, alt)
949 def _line_macro(self, groups):
950 name = groups['macro_name']
951 text = groups['macro_text'].strip()
952 return u'<span class="%s">%s</span>' % (
953 werkzeug.escape(name, quote=True),
954 werkzeug.escape(text))
956 # methods for the block (multiline) markup:
958 def _block_code(self, block):
959 for self.line_no, part in block:
960 inside = u"\n".join(self.lines_until(self.code_close_re))
961 yield werkzeug.html.pre(werkzeug.html(inside), class_="code",
962 id="line_%d" % self.line_no)
964 def _block_syntax(self, block):
965 for self.line_no, part in block:
966 syntax = part.lstrip('{#!').strip()
967 inside = u"\n".join(self.lines_until(self.code_close_re))
968 if self.wiki_syntax:
969 return self.wiki_syntax(inside, syntax=syntax,
970 line_no=self.line_no)
971 else:
972 return [werkzeug.html.div(werkzeug.html.pre(
973 werkzeug.html(inside), id="line_%d" % self.line_no),
974 class_="highlight")]
976 def _block_macro(self, block):
977 for self.line_no, part in block:
978 name = part.lstrip('<').strip()
979 inside = u"\n".join(self.lines_until(self.macro_close_re))
980 yield u'<div class="%s">%s</div>' % (
981 werkzeug.escape(name, quote=True),
982 werkzeug.escape(inside))
984 def _block_paragraph(self, block):
985 parts = []
986 first_line = None
987 for self.line_no, part in block:
988 if first_line is None:
989 first_line = self.line_no
990 parts.append(part)
991 text = u"".join(self.parse_line(u"".join(parts)))
992 yield werkzeug.html.p(text, self.pop_to(""), id="line_%d" % first_line)
994 def _block_indent(self, block):
995 parts = []
996 first_line = None
997 for self.line_no, part in block:
998 if first_line is None:
999 first_line = self.line_no
1000 parts.append(part.rstrip())
1001 text = u"\n".join(parts)
1002 yield werkzeug.html.pre(werkzeug.html(text), id="line_%d" % first_line)
1004 def _block_table(self, block):
1005 first_line = None
1006 in_head = False
1007 for self.line_no, line in block:
1008 if first_line is None:
1009 first_line = self.line_no
1010 yield u'<table id="line_%d">' % first_line
1011 table_row = line.strip()
1012 is_header = table_row.startswith('|=') and table_row.endswith('=|')
1013 if not in_head and is_header:
1014 in_head = True
1015 yield '<thead>'
1016 elif in_head and not is_header:
1017 in_head = False
1018 yield '</thead>'
1019 yield '<tr>'
1020 in_cell = False
1021 in_th = False
1023 for part in self.parse_line(table_row):
1024 if part in ('=|', '|', '=|=', '|='):
1025 if in_cell:
1026 if in_th:
1027 yield '</th>'
1028 else:
1029 yield '</td>'
1030 in_cell = False
1031 if part in ('=|=', '|='):
1032 in_th = True
1033 else:
1034 in_th = False
1035 else:
1036 if not in_cell:
1037 if in_th:
1038 yield '<th>'
1039 else:
1040 yield '<td>'
1041 in_cell = True
1042 yield part
1043 if in_cell:
1044 if in_th:
1045 yield '</th>'
1046 else:
1047 yield '</td>'
1048 yield '</tr>'
1049 yield u'</table>'
1051 def _block_empty(self, block):
1052 yield u''
1054 def _block_rule(self, block):
1055 for self.line_no, line in block:
1056 yield werkzeug.html.hr()
1058 def _block_heading(self, block):
1059 for self.line_no, line in block:
1060 level = min(len(self.heading_re.match(line).group(0).strip()), 5)
1061 self.headings[level-1] = self.headings.get(level-1, 0)+1
1062 label = u"-".join(str(self.headings.get(i, 0))
1063 for i in range(level))
1064 yield werkzeug.html.a(name="head-%s" % label)
1065 yield u'<h%d id="line_%d">%s</h%d>' % (level, self.line_no,
1066 werkzeug.escape(line.strip("= \t\n\r\v")), level)
1068 def _block_bullets(self, block):
1069 level = 0
1070 in_ul = False
1071 for self.line_no, line in block:
1072 nest = len(self.bullets_re.match(line).group(0).strip())
1073 while nest > level:
1074 if in_ul:
1075 yield '<li>'
1076 yield '<ul id="line_%d">' % self.line_no
1077 in_ul = True
1078 level += 1
1079 while nest < level:
1080 yield '</li></ul>'
1081 in_ul = False
1082 level -= 1
1083 if nest == level and not in_ul:
1084 yield '</li>'
1085 content = line.lstrip().lstrip('*').strip()
1086 yield '<li>%s%s' % (u"".join(self.parse_line(content)),
1087 self.pop_to(""))
1088 in_ul = False
1089 yield '</li></ul>'*level
1091 def _block_quote(self, block):
1092 level = 0
1093 in_p = False
1094 for self.line_no, line in block:
1095 nest = len(self.quote_re.match(line).group(0).strip())
1096 if nest == level:
1097 yield u'\n'
1098 while nest > level:
1099 if in_p:
1100 yield '%s</p>' % self.pop_to("")
1101 in_p = False
1102 yield '<blockquote>'
1103 level += 1
1104 while nest < level:
1105 if in_p:
1106 yield '%s</p>' % self.pop_to("")
1107 in_p = False
1108 yield '</blockquote>'
1109 level -= 1
1110 content = line.lstrip().lstrip('>').strip()
1111 if not in_p:
1112 yield '<p id="line_%d">' % self.line_no
1113 in_p = True
1114 yield u"".join(self.parse_line(content))
1115 if in_p:
1116 yield '%s</p>' % self.pop_to("")
1117 yield '</blockquote>'*level
1119 def _block_conflict(self, block):
1120 for self.line_no, part in block:
1121 yield u'<div class="conflict">'
1122 local = u"\n".join(self.lines_until(self.conflict_sep_re))
1123 yield werkzeug.html.pre(werkzeug.html(local),
1124 class_="local",
1125 id="line_%d" % self.line_no)
1126 other = u"\n".join(self.lines_until(self.conflict_close_re))
1127 yield werkzeug.html.pre(werkzeug.html(other),
1128 class_="other",
1129 id="line_%d" % self.line_no)
1130 yield u'</div>'
1133 class WikiWikiParser(WikiParser):
1134 """A version of WikiParser that recognizes WikiWord links."""
1136 markup = dict(WikiParser.markup)
1137 camel_link = ur"\w+[%s]\w+" % re.escape(
1138 u''.join(unichr(i) for i in xrange(sys.maxunicode)
1139 if unicodedata.category(unichr(i))=='Lu'))
1140 markup["camel_link"] = (105, camel_link)
1141 markup["camel_nolink"] = (106, ur"[!~](?P<camel_text>%s)" % camel_link)
1143 def _line_camel_link(self, groups):
1144 groups['link_target'] = groups['camel_link']
1145 return self._line_link(groups)
1147 def _line_camel_nolink(self, groups):
1148 return werkzeug.escape(groups["camel_text"])
1151 class WikiSearch(object):
1152 """
1153 Responsible for indexing words and links, for fast searching and
1154 backlinks. Uses a cache directory to store the index files.
1155 """
1157 word_pattern = re.compile(ur"""\w[-~&\w]+\w""", re.UNICODE)
1158 jword_pattern = re.compile(
1159 ur"""[ヲ-゚]+|[ぁ-ん~ー]+|[ァ-ヶ~ー]+|[0-9A-Za-z]+|"""
1160 ur"""[0-9A-Za-zΑ-Ωα-ωА-я]+|"""
1161 ur"""[^- !"#$%&'()*+,./:;<=>?@\[\\\]^_`{|}"""
1162 ur"""‾。「」、・ 、。,.・:;?!゛゜´`¨"""
1163 ur"""^ ̄_/〜‖|…‥‘’“”"""
1164 ur"""()〔〕[]{}〈〉《》「」『』【】+−±×÷"""
1165 ur"""=≠<>≦≧∞∴♂♀°′″℃¥$¢£"""
1166 ur"""%#&*@§☆★○●◎◇◆□■△▲▽▼※〒"""
1167 ur"""→←↑↓〓∈∋⊆⊇⊂⊃∪∩∧∨¬⇒⇔∠∃∠⊥"""
1168 ur"""⌒∂∇≡≒≪≫√∽∝∵∫∬ʼn♯♭♪†‡¶◾"""
1169 ur"""─│┌┐┘└├┬┤┴┼"""
1170 ur"""━┃┏┓┛┗┣┫┻╋"""
1171 ur"""┠┯┨┷┿┝┰┥┸╂"""
1172 ur"""ヲ-゚ぁ-ん~ーァ-ヶ"""
1173 ur"""0-9A-Za-z0-9A-Za-zΑ-Ωα-ωА-я]+""", re.UNICODE)
1174 _con = {}
1176 def __init__(self, cache_path, lang, storage):
1177 self.path = cache_path
1178 self.storage = storage
1179 self.lang = lang
1180 if lang == "ja":
1181 self.split_text = self.split_japanese_text
1182 self.filename = os.path.join(cache_path, 'index.sqlite3')
1183 if not os.path.isdir(self.path):
1184 self.empty = True
1185 os.makedirs(self.path)
1186 elif not os.path.exists(self.filename):
1187 self.empty = True
1188 else:
1189 self.empty = False
1190 con = self.con # sqlite3.connect(self.filename)
1191 self.con.execute('CREATE TABLE IF NOT EXISTS titles '
1192 '(id INTEGER PRIMARY KEY, title VARCHAR);')
1193 self.con.execute('CREATE TABLE IF NOT EXISTS words '
1194 '(word VARCHAR, page INTEGER, count INTEGER);')
1195 self.con.execute('CREATE INDEX IF NOT EXISTS index1 '
1196 'ON words (page);')
1197 self.con.execute('CREATE INDEX IF NOT EXISTS index2 '
1198 'ON words (word);')
1199 self.con.execute('CREATE TABLE IF NOT EXISTS links '
1200 '(src INTEGER, target INTEGER, label VARCHAR, number INTEGER);')
1201 self.con.commit()
1202 self.stop_words_re = re.compile(u'^('+u'|'.join(re.escape(_(
1203 u"""am ii iii per po re a about above
1204 across after afterwards again against all almost alone along already also
1205 although always am among ain amongst amoungst amount an and another any aren
1206 anyhow anyone anything anyway anywhere are around as at back be became because
1207 become becomes becoming been before beforehand behind being below beside
1208 besides between beyond bill both but by can cannot cant con could couldnt
1209 describe detail do done down due during each eg eight either eleven else etc
1210 elsewhere empty enough even ever every everyone everything everywhere except
1211 few fifteen fifty fill find fire first five for former formerly forty found
1212 four from front full further get give go had has hasnt have he hence her here
1213 hereafter hereby herein hereupon hers herself him himself his how however
1214 hundred i ie if in inc indeed interest into is it its itself keep last latter
1215 latterly least isn less made many may me meanwhile might mill mine more
1216 moreover most mostly move much must my myself name namely neither never
1217 nevertheless next nine no nobody none noone nor not nothing now nowhere of off
1218 often on once one only onto or other others otherwise our ours ourselves out
1219 over own per perhaps please pre put rather re same see seem seemed seeming
1220 seems serious several she should show side since sincere six sixty so some
1221 somehow someone something sometime sometimes somewhere still such take ten than
1222 that the their theirs them themselves then thence there thereafter thereby
1223 therefore therein thereupon these they thick thin third this those though three
1224 through throughout thru thus to together too toward towards twelve twenty two
1225 un under ve until up upon us very via was wasn we well were what whatever when
1226 whence whenever where whereafter whereas whereby wherein whereupon wherever
1227 whether which while whither who whoever whole whom whose why will with within
1228 without would yet you your yours yourself yourselves""")).split())
1229 +ur')$|.*\d.*', re.U|re.I|re.X)
1233 @property
1234 def con(self):
1235 """Keep one connection per thread."""
1237 thread_id = thread.get_ident()
1238 try:
1239 return self._con[thread_id]
1240 except KeyError:
1241 connection = sqlite3.connect(self.filename)
1242 connection.isolation_level = None
1243 self._con[thread_id] = connection
1244 return connection
1246 def split_text(self, text, stop=True):
1247 """Splits text into words, removing stop words"""
1249 for match in self.word_pattern.finditer(text):
1250 word = match.group(0)
1251 if not (stop and self.stop_words_re.match(word)):
1252 yield word.lower()
1254 def split_japanese_text(self, text, stop=True):
1255 """Splits text into words, including rules for Japanese"""
1257 for match in self.word_pattern.finditer(text):
1258 word = match.group(0)
1259 got_japanese = False
1260 for m in self.jword_pattern.finditer(word):
1261 w = m.group(0)
1262 got_japanese = True
1263 if not (stop and self.stop_words_re.match(w)):
1264 yield w.lower()
1265 if not (got_japanese or stop and self.stop_words_re.match(word)):
1266 yield word.lower()
1268 def count_words(self, words):
1269 count = {}
1270 for word in words:
1271 count[word] = count.get(word, 0)+1
1272 return count
1274 def title_id(self, title, con):
1275 c = con.execute('SELECT id FROM titles WHERE title=?;', (title,))
1276 idents = c.fetchone()
1277 if idents is None:
1278 con.execute('INSERT INTO titles (title) VALUES (?);', (title,))
1279 c = con.execute('SELECT LAST_INSERT_ROWID();')
1280 idents = c.fetchone()
1281 return idents[0]
1283 def update_words(self, title, text, cursor):
1284 title_id = self.title_id(title, cursor)
1285 words = self.count_words(self.split_text(text))
1286 title_words = self.count_words(self.split_text(title))
1287 for word, count in title_words.iteritems():
1288 words[word] = words.get(word, 0) + count
1289 cursor.execute('DELETE FROM words WHERE page=?;', (title_id,))
1290 for word, count in words.iteritems():
1291 cursor.execute('INSERT INTO words VALUES (?, ?, ?);',
1292 (word, title_id, count))
1294 def update_links(self, title, links_and_labels, cursor):
1295 title_id = self.title_id(title, cursor)
1296 cursor.execute('DELETE FROM links WHERE src=?;', (title_id,))
1297 for number, (link, label) in enumerate(links_and_labels):
1298 cursor.execute('INSERT INTO links VALUES (?, ?, ?, ?);',
1299 (title_id, link, label, number))
1301 def orphaned_pages(self):
1302 """Gives all pages with no links to them."""
1304 con = self.con
1305 try:
1306 sql = ('SELECT title FROM titles '
1307 'WHERE NOT EXISTS '
1308 '(SELECT * FROM links WHERE target=title) '
1309 'ORDER BY title;')
1310 for (title,) in con.execute(sql):
1311 yield unicode(title)
1312 finally:
1313 con.commit()
1315 def wanted_pages(self):
1316 """Gives all pages that are linked to, but don't exist, together with
1317 the number of links."""
1319 con = self.con
1320 try:
1321 sql = ('SELECT COUNT(*), target FROM links '
1322 'WHERE NOT EXISTS '
1323 '(SELECT * FROM titles WHERE target=title) '
1324 'GROUP BY target ORDER BY -COUNT(*);')
1325 for (refs, db_title,) in con.execute(sql):
1326 title = unicode(db_title)
1327 if not external_link(title) and not title.startswith('+'):
1328 yield refs, title
1329 finally:
1330 con.commit()
1333 def page_backlinks(self, title):
1334 """Gives a list of pages linking to specified page."""
1336 con = self.con # sqlite3.connect(self.filename)
1337 try:
1338 sql = ('SELECT DISTINCT(titles.title) '
1339 'FROM links, titles '
1340 'WHERE links.target=? AND titles.id=links.src '
1341 'ORDER BY titles.title;')
1342 for (backlink,) in con.execute(sql, (title,)):
1343 yield unicode(backlink)
1344 finally:
1345 con.commit()
1347 def page_links(self, title):
1348 """Gives a list of links on specified page."""
1350 con = self.con # sqlite3.connect(self.filename)
1351 try:
1352 title_id = self.title_id(title, con)
1353 sql = 'SELECT target FROM links WHERE src=? ORDER BY number;'
1354 for (link,) in con.execute(sql, (title_id,)):
1355 yield unicode(link)
1356 finally:
1357 con.commit()
1359 def page_links_and_labels (self, title):
1360 con = self.con # sqlite3.connect(self.filename)
1361 try:
1362 title_id = self.title_id(title, con)
1363 sql = 'SELECT target, label FROM links WHERE src=? ORDER BY number;'
1364 for link, label in con.execute(sql, (title_id,)):
1365 yield unicode(link), unicode(label)
1366 finally:
1367 con.commit()
1369 def find(self, words):
1370 """Iterator of all pages containing the words, and their scores."""
1372 con = self.con
1373 try:
1374 ranks = []
1375 for word in words:
1376 # Calculate popularity of each word.
1377 sql = 'SELECT SUM(words.count) FROM words WHERE word LIKE ?;'
1378 rank = con.execute(sql, ('%%%s%%' % word,)).fetchone()[0]
1379 # If any rank is 0, there will be no results anyways
1380 if not rank:
1381 return
1382 ranks.append((rank, word))
1383 ranks.sort()
1384 # Start with the least popular word. Get all pages that contain it.
1385 first_rank, first = ranks[0]
1386 rest = ranks[1:]
1387 sql = ('SELECT words.page, titles.title, SUM(words.count) '
1388 'FROM words, titles '
1389 'WHERE word LIKE ? AND titles.id=words.page '
1390 'GROUP BY words.page;')
1391 first_counts = con.execute(sql, ('%%%s%%' % first,))
1392 # Check for the rest of words
1393 for title_id, title, first_count in first_counts:
1394 # Score for the first word
1395 score = float(first_count)/first_rank
1396 for rank, word in rest:
1397 sql = ('SELECT SUM(count) FROM words '
1398 'WHERE page=? AND word LIKE ?;')
1399 count = con.execute(sql,
1400 (title_id, '%%%s%%' % word)).fetchone()[0]
1401 if not count:
1402 # If page misses any of the words, its score is 0
1403 score = 0
1404 break
1405 score += float(count)/rank
1406 if score > 0:
1407 yield int(100*score), unicode(title)
1408 finally:
1409 con.commit()
1411 def reindex_page(self, page, title, cursor, text=None):
1412 """Updates the content of the database, needs locks around."""
1414 mime = self.storage.page_mime(title)
1415 if not mime.startswith('text/'):
1416 self.update_words(title, '', cursor=cursor)
1417 return
1418 if text is None:
1419 try:
1420 text = self.storage.page_text(title)
1421 except werkzeug.exceptions.NotFound:
1422 text = u''
1423 extract_links = getattr(page, 'extract_links', None)
1424 if extract_links is not None:
1425 links = extract_links(text)
1426 self.update_links(title, links, cursor=cursor)
1427 self.update_words(title, text, cursor=cursor)
1429 def update_page(self, page, title, data=None, text=None):
1430 """Updates the index with new page content, for a single page."""
1432 if text is None and data is not None:
1433 text = unicode(data, self.storage.charset, 'replace')
1434 cursor = self.con.cursor()
1435 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1436 try:
1437 self.set_last_revision(self.storage.repo_revision())
1438 self.reindex_page(page, title, cursor, text)
1439 cursor.execute('COMMIT TRANSACTION;')
1440 except:
1441 cursor.execute('ROLLBACK;')
1442 raise
1444 def reindex(self, wiki, request, pages):
1445 """Updates specified pages in bulk."""
1447 cursor = self.con.cursor()
1448 cursor.execute('BEGIN IMMEDIATE TRANSACTION;')
1449 try:
1450 for title in pages:
1451 page = wiki.get_page(request, title)
1452 self.reindex_page(page, title, cursor)
1453 cursor.execute('COMMIT TRANSACTION;')
1454 self.empty = False
1455 except:
1456 cursor.execute('ROLLBACK;')
1457 raise
1459 def set_last_revision(self, rev):
1460 """Store the last indexed repository revision."""
1462 # We use % here because the sqlite3's substitiution doesn't work
1463 # We store revision 0 as 1, 1 as 2, etc. because 0 means "no revision"
1464 self.con.execute('PRAGMA USER_VERSION=%d;' % (int(rev+1),))
1466 def get_last_revision(self):
1467 """Retrieve the last indexed repository revision."""
1469 con = self.con
1470 c = con.execute('PRAGMA USER_VERSION;')
1471 rev = c.fetchone()[0]
1472 # -1 means "no revision", 1 means revision 0, 2 means revision 1, etc.
1473 return rev-1
1475 def update(self, wiki, request):
1476 """Reindex al pages that changed since last indexing."""
1478 last_rev = self.get_last_revision()
1479 if last_rev == -1:
1480 changed = self.storage.all_pages()
1481 else:
1482 changed = self.storage.changed_since(last_rev)
1483 self.reindex(wiki, request, changed)
1484 rev = self.storage.repo_revision()
1485 self.set_last_revision(rev)
1487 class WikiResponse(werkzeug.BaseResponse, werkzeug.ETagResponseMixin,
1488 werkzeug.CommonResponseDescriptorsMixin):
1489 """A typical HTTP response class made out of Werkzeug's mixins."""
1491 def make_conditional(self, request):
1492 ret = super(WikiResponse, self).make_conditional(request)
1493 # Remove all headers if it's 304, according to
1494 # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
1495 if self.status.startswith('304'):
1496 self.response = []
1497 try:
1498 del self.content_type
1499 except AttributeError:
1500 pass
1501 try:
1502 del self.content_length
1503 except AttributeError:
1504 pass
1505 try:
1506 del self.headers['Content-length']
1507 except (KeyError, IndexError):
1508 pass
1509 try:
1510 del self.headers['Content-type']
1511 except (KeyError, IndexError):
1512 pass
1513 return ret
1515 class WikiTempFile(object):
1516 """Wrap a file for uploading content."""
1518 def __init__(self, tmppath):
1519 self.tmppath = tempfile.mkdtemp(dir=tmppath)
1520 self.tmpname = os.path.join(self.tmppath, 'saved')
1521 self.f = open(self.tmpname, "wb")
1523 def read(self, *args, **kw):
1524 return self.f.read(*args, **kw)
1526 def readlines(self, *args, **kw):
1527 return self.f.readlines(*args, **kw)
1529 def write(self, *args, **kw):
1530 return self.f.write(*args, **kw)
1532 def seek(self, *args, **kw):
1533 return self.f.seek(*args, **kw)
1535 def truncate(self, *args, **kw):
1536 return self.f.truncate(*args, **kw)
1538 def close(self, *args, **kw):
1539 ret = self.f.close(*args, **kw)
1540 try:
1541 os.unlink(self.tmpname)
1542 except OSError:
1543 pass
1544 try:
1545 os.rmdir(self.tmppath)
1546 except OSError:
1547 pass
1548 return ret
1551 class WikiRequest(werkzeug.BaseRequest, werkzeug.ETagRequestMixin):
1552 """
1553 A Werkzeug's request with additional functions for handling file
1554 uploads and wiki-specific link generation.
1555 """
1557 charset = 'utf-8'
1558 encoding_errors = 'ignore'
1560 def __init__(self, wiki, adapter, environ, **kw):
1561 werkzeug.BaseRequest.__init__(self, environ, shallow=False, **kw)
1562 self.wiki = wiki
1563 self.adapter = adapter
1564 self.tmpfiles = []
1565 self.tmppath = wiki.path
1566 # Whether to print the css for highlighting
1567 self.print_highlight_styles = True
1569 def get_url(self, title=None, view=None, method='GET',
1570 external=False, **kw):
1571 if view is None:
1572 view = self.wiki.view
1573 if title is not None:
1574 kw['title'] = title.strip()
1575 return self.adapter.build(view, kw, method=method,
1576 force_external=external)
1578 def get_download_url(self, title):
1579 return self.get_url(title, view=self.wiki.download)
1581 def get_author(self):
1582 """Try to guess the author name. Use IP address as last resort."""
1584 try:
1585 cookie = werkzeug.url_unquote(self.cookies.get("author", ""))
1586 except UnicodeError:
1587 cookie = None
1588 try:
1589 auth = werkzeug.url_unquote(self.environ.get('REMOTE_USER', ""))
1590 except UnicodeError:
1591 auth = None
1592 author = (self.form.get("author") or cookie or auth or self.remote_addr)
1593 return author
1595 def _get_file_stream(self, total_content_length=None, content_type=None,
1596 filename=None, content_length=None):
1597 """Save all the POSTs to temporary files."""
1599 temp_file = WikiTempFile(self.tmppath)
1600 self.tmpfiles.append(temp_file)
1601 return temp_file
1603 def cleanup(self):
1604 """Clean up the temporary files created by POSTs."""
1606 for temp_file in self.tmpfiles:
1607 temp_file.close()
1608 self.tmpfiles = []
1610 class WikiPage(object):
1611 """Everything needed for rendering a page."""
1613 def __init__(self, wiki, request, title, mime):
1614 self.request = request
1615 self.title = title
1616 self.mime = mime
1617 # for now we just use the globals from wiki object
1618 self.get_url = self.request.get_url
1619 self.get_download_url = self.request.get_download_url
1620 self.wiki = wiki
1621 self.storage = self.wiki.storage
1622 self.index = self.wiki.index
1623 self.config = self.wiki.config
1625 def date_html(self, datetime):
1626 """
1627 Create HTML for a date, according to recommendation at
1628 http://microformats.org/wiki/date
1629 """
1631 text = datetime.strftime('%Y-%m-%d %H:%M')
1632 # We are going for YYYY-MM-DDTHH:MM:SSZ
1633 title = datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
1634 html = werkzeug.html.abbr(text, class_="date", title=title)
1635 return html
1638 def wiki_link(self, addr, label=None, class_=None, image=None, lineno=0):
1639 """Create HTML for a wiki link."""
1641 addr = addr.strip()
1642 text = werkzeug.escape(label or addr)
1643 chunk = ''
1644 if class_ is not None:
1645 classes = [class_]
1646 else:
1647 classes = []
1648 if external_link(addr):
1649 if addr.startswith('mailto:'):
1650 class_ = 'external email'
1651 text = text.replace('@', '@').replace('.', '.')
1652 href = addr.replace('@', '%40').replace('.', '%2E')
1653 else:
1654 classes.append('external')
1655 href = werkzeug.escape(addr, quote=True)
1656 else:
1657 if '#' in addr:
1658 addr, chunk = addr.split('#', 1)
1659 chunk = '#'+chunk
1660 if addr.startswith('+'):
1661 href = '/'.join([self.request.script_root,
1662 '+'+werkzeug.escape(addr[1:], quote=True)])
1663 classes.append('special')
1664 elif addr == u'':
1665 href = chunk
1666 classes.append('anchor')
1667 else:
1668 classes.append('wiki')
1669 href = self.get_url(addr) + chunk
1670 if addr not in self.storage:
1671 classes.append('nonexistent')
1672 class_ = ' '.join(classes) or None
1673 return werkzeug.html.a(image or text, href=href, class_=class_,
1674 title=addr+chunk)
1676 def wiki_image(self, addr, alt, class_='wiki', lineno=0):
1677 """Create HTML for a wiki image."""
1679 addr = addr.strip()
1680 html = werkzeug.html
1681 chunk = ''
1682 if external_link(addr):
1683 return html.img(src=werkzeug.url_fix(addr), class_="external",
1684 alt=alt)
1685 if '#' in addr:
1686 addr, chunk = addr.split('#', 1)
1687 if addr == '':
1688 return html.a(name=chunk)
1689 if addr in self.storage:
1690 mime = self.storage.page_mime(addr)
1691 if mime.startswith('image/'):
1692 return html.img(src=self.get_download_url(addr), class_=class_,
1693 alt=alt)
1694 else:
1695 return html.img(href=self.get_download_url(addr), alt=alt)
1696 else:
1697 return html.a(html(alt), href=self.get_url(addr))
1699 def search_form(self):
1700 html = werkzeug.html
1701 return html.form(html.div(html.input(name="q", class_="search"),
1702 html.input(class_="button", type_="submit", value=_(u'Search')),
1703 ), method="GET", class_="search",
1704 action=self.get_url(None, self.wiki.search))
1706 def logo(self):
1707 html = werkzeug.html
1708 img = html.img(alt=u"[%s]" % self.wiki.front_page,
1709 src=self.get_download_url(self.wiki.logo_page))
1710 return html.a(img, class_='logo', href=self.get_url(self.wiki.front_page))
1712 def menu(self):
1713 """Generate the menu items"""
1715 html = werkzeug.html
1716 if self.wiki.menu_page in self.storage:
1717 items = self.index.page_links_and_labels(self.wiki.menu_page)
1718 else:
1719 items = [
1720 (self.wiki.front_page, self.wiki.front_page),
1721 ('+history', _(u'Recent changes')),
1722 ]
1723 for link, label in items:
1724 if link == self.title:
1725 class_="current"
1726 else:
1727 class_ = None
1728 yield self.wiki_link(link, label, class_=class_)
1730 def header(self, special_title):
1731 html = werkzeug.html
1732 if self.wiki.logo_page in self.storage:
1733 yield self.logo()
1734 yield self.search_form()
1735 yield html.div(u" ".join(self.menu()), class_="menu")
1736 yield html.h1(html(special_title or self.title))
1738 def html_header(self, special_title, edit_url):
1739 e = lambda x: werkzeug.escape(x, quote=True)
1740 h = werkzeug.html
1741 yield h.title(u'%s - %s' % (e(special_title or self.title),
1742 e(self.wiki.site_name)))
1743 yield h.link(rel="stylesheet", type_="text/css",
1744 href=self.get_url(None, self.wiki.pygments_css))
1745 yield h.link(rel="stylesheet", type_="text/css",
1746 href=self.get_url(None, self.wiki.style_css))
1747 if special_title:
1748 yield h.meta(name="robots", content="NOINDEX,NOFOLLOW")
1749 if edit_url:
1750 yield h.link(rel="alternate", type_="application/wiki",
1751 href=edit_url)
1752 yield h.link(rel="shortcut icon", type_="image/x-icon",
1753 href=self.get_url(None, self.wiki.favicon_ico))
1754 yield h.link(rel="alternate", type_="application/rss+xml",
1755 title=e("%s (RSS)" % self.wiki.site_name),
1756 href=self.get_url(None, self.wiki.rss))
1757 yield h.link(rel="alternate", type_="application/rss+xml",
1758 title=e("%s (ATOM)" % self.wiki.site_name),
1759 href=self.get_url(None, self.wiki.atom))
1760 yield h.script(type_="text/javascript",
1761 src=self.get_url(None, self.wiki.scripts_js))
1763 def footer(self, special_title, edit_url):
1764 if special_title:
1765 footer_links = [
1766 (_(u'Changes'), 'changes',
1767 self.get_url(None, self.wiki.recent_changes)),
1768 (_(u'Index'), 'index',
1769 self.get_url(None, self.wiki.all_pages)),
1770 (_(u'Orphaned'), 'orphaned',
1771 self.get_url(None, self.wiki.orphaned)),
1772 (_(u'Wanted'), 'wanted',
1773 self.get_url(None, self.wiki.wanted)),
1774 ]
1775 else:
1776 footer_links = [
1777 (_(u'Edit'), 'edit', edit_url),
1778 (_(u'History'), 'history',
1779 self.get_url(self.title, self.wiki.history)),
1780 (_(u'Backlinks'), 'backlinks',
1781 self.get_url(self.title, self.wiki.backlinks))
1782 ]
1783 for label, class_, url in footer_links:
1784 if url:
1785 yield werkzeug.html.a(werkzeug.html(label), href=url,
1786 class_=class_)
1787 yield u'\n'
1789 def render_content(self, content, special_title=None):
1790 """The main page template."""
1792 edit_url = None
1793 if not special_title:
1794 try:
1795 self.wiki._check_lock(self.title)
1796 edit_url = self.get_url(self.title, self.wiki.edit)
1797 except werkzeug.exceptions.Forbidden:
1798 pass
1800 yield u"""\
1801 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
1802 "http://www.w3.org/TR/html4/strict.dtd">
1803 <html><head>\n"""
1804 for part in self.html_header(special_title, edit_url):
1805 yield part
1806 yield u'\n</head><body><div class="header">\n'
1807 for part in self.header(special_title):
1808 yield part
1809 yield u'\n</div><div class="content">\n'
1810 for part in content:
1811 yield part
1812 if not special_title or not self.title:
1813 yield u'\n<div class="footer">\n'
1814 for part in self.footer(special_title, edit_url):
1815 yield part
1816 yield u'</div>'
1817 yield u'</div></body></html>'
1820 def pages_list(self, pages, message=None, link=None, _class=None):
1821 """Generate the content of a page list page."""
1823 yield werkzeug.html.p(werkzeug.escape(message) % {'link': link})
1824 yield u'<ul class="%s">' % werkzeug.escape(_class or 'pagelist')
1825 for title in pages:
1826 yield werkzeug.html.li(self.wiki_link(title))
1827 yield u'</ul>'
1829 def history_list(self):
1830 """Generate the content of the history page."""
1832 h = werkzeug.html
1833 max_rev = -1;
1834 title = self.title
1835 link = self.wiki_link(title)
1836 yield h.p(h(_(u'History of changes for %(link)s.')) % {'link': link})
1837 url = self.request.get_url(title, self.wiki.undo, method='POST')
1838 yield u'<form action="%s" method="POST"><ul class="history">' % url
1839 try:
1840 self.wiki._check_lock(title)
1841 read_only = False
1842 except werkzeug.exceptions.Forbidden:
1843 read_only = True
1844 for rev, date, author, comment in self.wiki.storage.page_history(title):
1845 if max_rev < rev:
1846 max_rev = rev
1847 if rev > 0:
1848 date_url = self.request.adapter.build(self.wiki.diff, {
1849 'title': title, 'from_rev': rev-1, 'to_rev': rev})
1850 else:
1851 date_url = self.request.adapter.build(self.wiki.revision, {
1852 'title': title, 'rev': rev})
1853 if read_only:
1854 button = u''
1855 else:
1856 button = h.input(type_="submit", name=str(rev),
1857 value=h(_(u'Undo')))
1858 yield h.li(h.a(self.date_html(date), href=date_url),
1859 button, ' . . . . ', h.i(self.wiki_link(author)),
1860 h.div(h(comment), class_="comment"))
1861 yield u'</ul>'
1862 yield h.input(type_="hidden", name="parent", value=max_rev)
1863 yield u'</form>'
1866 def dependencies(self):
1867 """Refresh the page when any of those pages was changed."""
1869 dependencies = set()
1870 for title in [self.wiki.logo_page, self.wiki.menu_page]:
1871 if title not in self.storage:
1872 dependencies.add(werkzeug.url_quote(title))
1873 for title in [self.wiki.menu_page]:
1874 if title in self.storage:
1875 inode, size, mtime = self.storage.page_file_meta(title)
1876 etag = '%s/%d-%d' % (werkzeug.url_quote(title), inode, mtime)
1877 dependencies.add(etag)
1878 return dependencies
1880 class WikiPageText(WikiPage):
1881 """Pages of mime type text/* use this for display."""
1883 def content_iter(self, lines):
1884 yield '<pre>'
1885 for line in lines:
1886 yield werkzeug.html(line)
1887 yield '</pre>'
1889 def view_content(self, lines=None):
1890 """Read the page content from storage or preview and return iterator."""
1892 if lines is None:
1893 f = self.storage.open_page(self.title)
1894 lines = self.storage.page_lines(f)
1895 return self.content_iter(lines)
1897 def editor_form(self, preview=None):
1898 """Generate the HTML for the editor."""
1900 author = self.request.get_author()
1901 lines = []
1902 try:
1903 page_file = self.storage.open_page(self.title)
1904 lines = self.storage.page_lines(page_file)
1905 (rev, old_date, old_author,
1906 old_comment) = self.storage.page_meta(self.title)
1907 comment = _(u'modified')
1908 if old_author == author:
1909 comment = old_comment
1910 except werkzeug.exceptions.NotFound:
1911 comment = _(u'created')
1912 rev = -1
1913 except werkzeug.exceptions.Forbidden:
1914 yield werkzeug.html.p(
1915 werkzeug.html(_(u"Can't edit symbolic links")))
1916 return
1917 if preview:
1918 lines = preview
1919 comment = self.request.form.get('comment', comment)
1920 html = werkzeug.html
1921 yield u'<form action="" method="POST" class="editor"><div>'
1922 yield u'<textarea name="text" cols="80" rows="20" id="editortext">'
1923 for line in lines:
1924 yield werkzeug.escape(line)
1925 yield u"""</textarea>"""
1926 yield html.input(type_="hidden", name="parent", value=rev)
1927 yield html.label(html(_(u'Comment')), html.input(name="comment",
1928 value=comment), class_="comment")
1929 yield html.label(html(_(u'Author')), html.input(name="author",
1930 value=self.request.get_author()), class_="comment")
1931 yield html.div(
1932 html.input(type_="submit", name="save", value=_(u'Save')),
1933 html.input(type_="submit", name="preview", value=_(u'Preview')),
1934 html.input(type_="submit", name="cancel", value=_(u'Cancel')),
1935 class_="buttons")
1936 yield u'</div></form>'
1937 if preview:
1938 yield html.h1(html(_(u'Preview, not saved')), class_="preview")
1939 for part in self.view_content(preview):
1940 yield part
1942 def diff_content(self, from_text, to_text, message=u''):
1943 """Generate the HTML markup for a diff."""
1945 def infiniter(iterator):
1946 """Turn an iterator into an infinite one, padding it with None"""
1948 for i in iterator:
1949 yield i
1950 while True:
1951 yield None
1953 diff = difflib._mdiff(from_text.split('\n'), to_text.split('\n'))
1954 stack = []
1955 mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])')
1956 yield message
1957 yield u'<pre class="diff">'
1958 for old_line, new_line, changed in diff:
1959 old_no, old_text = old_line
1960 new_no, new_text = new_line
1961 line_no = (new_no or old_no or 1)-1
1962 if changed:
1963 yield u'<div class="change" id="line_%d">' % line_no
1964 old_iter = infiniter(mark_re.finditer(old_text))
1965 new_iter = infiniter(mark_re.finditer(new_text))
1966 old = old_iter.next()
1967 new = new_iter.next()
1968 buff = u''
1969 while old or new:
1970 while old and old.group(1):
1971 if buff:
1972 yield werkzeug.escape(buff)
1973 buff = u''
1974 yield u'<del>%s</del>' % werkzeug.escape(old.group(1))
1975 old = old_iter.next()
1976 while new and new.group(1):
1977 if buff:
1978 yield werkzeug.escape(buff)
1979 buff = u''
1980 yield u'<ins>%s</ins>' % werkzeug.escape(new.group(1))
1981 new = new_iter.next()
1982 if new:
1983 buff += new.group(2)
1984 old = old_iter.next()
1985 new = new_iter.next()
1986 if buff:
1987 yield werkzeug.escape(buff)
1988 yield u'</div>'
1989 else:
1990 yield u'<div class="orig" id="line_%d">%s</div>' % (
1991 line_no, werkzeug.escape(old_text))
1992 yield u'</pre>'
1994 class WikiPageColorText(WikiPageText):
1995 """Text pages, but displayed colorized with pygments"""
1997 def view_content(self, lines=None):
1998 """Generate HTML for the content."""
2000 if lines is None:
2001 text = self.storage.page_text(self.title)
2002 else:
2003 text = ''.join(lines)
2004 return self.highlight(text, mime=self.mime)
2006 def highlight(self, text, mime=None, syntax=None, line_no=0):
2007 """Colorize the source code."""
2009 if pygments is None:
2010 yield werkzeug.html.pre(werkzeug.html(text))
2011 return
2013 formatter = pygments.formatters.HtmlFormatter()
2014 formatter.line_no = line_no
2016 def wrapper(source, outfile):
2017 """Wrap each line of formatted output."""
2019 yield 0, '<div class="highlight"><pre>'
2020 for lineno, line in source:
2021 yield (lineno,
2022 werkzeug.html.span(line, id_="line_%d" %
2023 formatter.line_no))
2024 formatter.line_no += 1
2025 yield 0, '</pre></div>'
2027 formatter.wrap = wrapper
2028 try:
2029 if mime:
2030 lexer = pygments.lexers.get_lexer_for_mimetype(mime)
2031 elif syntax:
2032 lexer = pygments.lexers.get_lexer_by_name(syntax)
2033 else:
2034 lexer = pygments.lexers.guess_lexer(text)
2035 except pygments.util.ClassNotFound:
2036 yield werkzeug.html.pre(werkzeug.html(text))
2037 return
2038 html = pygments.highlight(text, lexer, formatter)
2039 yield html
2041 class WikiPageWiki(WikiPageColorText):
2042 """Pages of with wiki markup use this for display."""
2044 def __init__(self, *args, **kw):
2045 super(WikiPageWiki, self).__init__(*args, **kw)
2046 if self.config.get_bool('wiki_words', False):
2047 self.parser = WikiWikiParser
2048 else:
2049 self.parser = WikiParser
2050 if self.config.get_bool('ignore_indent', False):
2051 try:
2052 del self.parser.block['indent']
2053 except KeyError:
2054 pass
2056 def extract_links(self, text=None):
2057 """Extract all links from the page."""
2059 if text is None:
2060 try:
2061 text = self.storage.page_text(self.title)
2062 except werkzeug.exceptions.NotFound:
2063 text = u''
2064 return self.parser.extract_links(text)
2066 def view_content(self, lines=None):
2067 if lines is None:
2068 f = self.storage.open_page(self.title)
2069 lines = self.storage.page_lines(f)
2070 if self.wiki.icon_page and self.wiki.icon_page in self.storage:
2071 icons = self.index.page_links_and_labels(self.wiki.icon_page)
2072 smilies = dict((emo, link) for (link, emo) in icons)
2073 else:
2074 smilies = None
2075 content = self.parser(lines, self.wiki_link, self.wiki_image,
2076 self.highlight, self.wiki_math, smilies)
2077 return content
2079 def wiki_math(self, math):
2080 math_url = self.config.get('math_url',
2081 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2082 if '%s' in math_url:
2083 url = math_url % werkzeug.url_quote(math)
2084 else:
2085 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2086 label = werkzeug.escape(math, quote=True)
2087 return werkzeug.html.img(src=url, alt=label, class_="math")
2089 def dependencies(self):
2090 dependencies = WikiPage.dependencies(self)
2091 for title in [self.wiki.icon_page]:
2092 if title in self.storage:
2093 inode, size, mtime = self.storage.page_file_meta(title)
2094 etag = '%s/%d-%d' % (werkzeug.url_quote(title), inode, mtime)
2095 dependencies.add(etag)
2096 for link in self.index.page_links(self.title):
2097 if link not in self.storage:
2098 dependencies.add(werkzeug.url_quote(link))
2099 return dependencies
2101 class WikiPageFile(WikiPage):
2102 """Pages of all other mime types use this for display."""
2104 def view_content(self, lines=None):
2105 if self.title not in self.storage:
2106 raise werkzeug.exceptions.NotFound()
2107 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2108 (self.request.get_download_url(self.title),
2109 werkzeug.escape(self.title), self.mime)]
2110 return content
2112 def editor_form(self, preview=None):
2113 author = self.request.get_author()
2114 if self.title in self.storage:
2115 comment = _(u'changed')
2116 (rev, old_date, old_author,
2117 old_comment) = self.storage.page_meta(self.title)
2118 if old_author == author:
2119 comment = old_comment
2120 else:
2121 comment = _(u'uploaded')
2122 rev = -1
2123 html = werkzeug.html
2124 yield html.p(html(
2125 _(u"This is a binary file, it can't be edited on a wiki. "
2126 u"Please upload a new version instead.")))
2127 yield html.form(html.div(
2128 html.div(html.input(type_="file", name="data"), class_="upload"),
2129 html.input(type_="hidden", name="parent", value=rev),
2130 html.label(html(_(u'Comment')), html.input(name="comment",
2131 value=comment)),
2132 html.label(html(_(u'Author')), html.input(name="author",
2133 value=author)),
2134 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2135 html.input(type_="submit", name="cancel",
2136 value=_(u'Cancel')),
2137 class_="buttons")), action="", method="POST", class_="editor",
2138 enctype="multipart/form-data")
2140 class WikiPageImage(WikiPageFile):
2141 """Pages of mime type image/* use this for display."""
2143 render_file = '128x128.png'
2145 def view_content(self, lines=None):
2146 if self.title not in self.storage:
2147 raise werkzeug.exceptions.NotFound()
2148 content = ['<img src="%s" alt="%s">'
2149 % (self.request.get_url(self.title, self.wiki.render),
2150 werkzeug.escape(self.title))]
2151 return content
2153 def render_mime(self):
2154 """Give the filename and mime type of the rendered thumbnail."""
2156 if not Image:
2157 raise NotImplementedError('No Image library available')
2158 return self.render_file, 'image/png'
2160 def render_cache(self, cache_dir):
2161 """Render the thumbnail and save in the cache."""
2163 if not Image:
2164 raise NotImplementedError('No Image library available')
2165 page_file = self.storage.open_page(self.title)
2166 cache_path = os.path.join(cache_dir, self.render_file)
2167 cache_file = open(cache_path, 'wb')
2168 try:
2169 im = Image.open(page_file)
2170 im = im.convert('RGBA')
2171 im.thumbnail((128, 128), Image.ANTIALIAS)
2172 im.save(cache_file,'PNG')
2173 except IOError:
2174 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2175 cache_file.close()
2176 return cache_path
2178 class WikiPageCSV(WikiPageFile):
2179 """Display class for type text/csv."""
2181 def content_iter(self, lines=None):
2182 import csv
2183 # XXX Add preview support
2184 csv_file = self.storage.open_page(self.title)
2185 reader = csv.reader(csv_file)
2186 html_title = werkzeug.escape(self.title, quote=True)
2187 yield u'<table id="%s" class="csvfile">' % html_title
2188 try:
2189 for row in reader:
2190 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2191 for cell in row))
2192 except csv.Error, e:
2193 yield u'</table>'
2194 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2195 % (html_title, reader.line_num, e))
2196 finally:
2197 csv_file.close()
2198 yield u'</table>'
2200 def view_content(self, lines=None):
2201 if self.title not in self.storage:
2202 raise werkzeug.exceptions.NotFound()
2203 return self.content_iter(lines)
2205 class WikiPageRST(WikiPageText):
2206 """
2207 Display ReStructured Text.
2208 """
2210 def content_iter(self, lines):
2211 try:
2212 from docutils.core import publish_parts
2213 except ImportError:
2214 return super(WikiPageRST, self).content_iter(lines)
2215 text = ''.join(lines)
2216 SAFE_DOCUTILS = dict(file_insertion_enabled=False, raw_enabled=False)
2217 content = publish_parts(text, writer_name='html',
2218 settings_overrides=SAFE_DOCUTILS)['html_body']
2219 return [content]
2222 class WikiPageBugs(WikiPageText):
2223 """
2224 Display class for type text/x-bugs
2225 Parse the ISSUES file in (roughly) format used by ciss
2226 """
2228 def content_iter(self, lines):
2229 last_lines = []
2230 in_header = False
2231 in_bug = False
2232 attributes = {}
2233 title = None
2234 for line_no, line in enumerate(lines):
2235 if last_lines and line.startswith('----'):
2236 title = ''.join(last_lines)
2237 last_lines = []
2238 in_header = True
2239 attributes = {}
2240 elif in_header and ':' in line:
2241 attribute, value = line.split(':', 1)
2242 attributes[attribute.strip()] = value.strip()
2243 else:
2244 if in_header:
2245 if in_bug:
2246 yield '</div>'
2247 tags = [tag.strip() for tag in
2248 attributes.get('tags', '').split()
2249 if tag.strip()]
2250 yield '<div id="line_%d">' % (line_no)
2251 in_bug = True
2252 if title:
2253 yield werkzeug.html.h2(werkzeug.html(title))
2254 if attributes:
2255 yield '<dl>'
2256 for attribute, value in attributes.iteritems():
2257 yield werkzeug.html.dt(werkzeug.html(attribute))
2258 yield werkzeug.html.dd(werkzeug.html(value))
2259 yield '</dl>'
2260 in_header = False
2261 if not line.strip():
2262 if last_lines:
2263 if last_lines[0][0] in ' \t':
2264 yield werkzeug.html.pre(werkzeug.html(
2265 ''.join(last_lines)))
2266 else:
2267 yield werkzeug.html.p(werkzeug.html(
2268 ''.join(last_lines)))
2269 last_lines = []
2270 else:
2271 last_lines.append(line)
2272 if last_lines:
2273 if last_lines[0][0] in ' \t':
2274 yield werkzeug.html.pre(werkzeug.html(
2275 ''.join(last_lines)))
2276 else:
2277 yield werkzeug.html.p(werkzeug.html(
2278 ''.join(last_lines)))
2279 if in_bug:
2280 yield '</div>'
2283 class WikiTitleConverter(werkzeug.routing.PathConverter):
2284 """Behaves like the path converter, except that it escapes slashes."""
2286 def to_url(self, value):
2287 return werkzeug.url_quote(value.strip(), self.map.charset, safe="")
2289 regex='([^+%]|%[^2]|%2[^Bb]).*'
2291 class WikiAllConverter(werkzeug.routing.BaseConverter):
2292 """Matches everything."""
2294 regex='.*'
2297 class Wiki(object):
2298 """
2299 The main class of the wiki, handling initialization of the whole
2300 application and most of the logic.
2301 """
2302 storage_class = WikiStorage
2303 index_class = WikiSearch
2304 filename_map = {
2305 'README': (WikiPageText, 'text/plain'),
2306 'ISSUES': (WikiPageBugs, 'text/x-bugs'),
2307 'ISSUES.txt': (WikiPageBugs, 'text/x-bugs'),
2308 'COPYING': (WikiPageText, 'text/plain'),
2309 'CHANGES': (WikiPageText, 'text/plain'),
2310 'MANIFEST': (WikiPageText, 'text/plain'),
2311 'favicon.ico': (WikiPageImage, 'image/x-icon'),
2312 }
2313 mime_map = {
2314 'text': WikiPageColorText,
2315 'application/x-javascript': WikiPageColorText,
2316 'application/x-python': WikiPageColorText,
2317 'text/csv': WikiPageCSV,
2318 'text/x-rst': WikiPageRST,
2319 'text/x-wiki': WikiPageWiki,
2320 'image': WikiPageImage,
2321 '': WikiPageFile,
2322 }
2323 icon = base64.b64decode(
2324 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2325 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2326 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2327 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2328 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2329 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2330 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2331 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2332 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2333 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2334 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2335 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2336 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2337 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2338 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2339 scripts = r"""function hatta_dates(){var a=document.getElementsByTagName(
2340 'abbr');var p=function(i){return('00'+i).slice(-2)};for(var i=0;i<a.length;++i)
2341 {var n=a[i];if(n.className==='date'){var m=
2342 /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$/.exec(
2343 n.getAttribute('title'));var d=new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],
2344 +m[5],+m[6]));if(d){var b=-d.getTimezoneOffset()/60;if(b>=0){b="+"+b}
2345 n.textContent=""+d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+
2346 p(d.getHours())+":"+p(d.getMinutes())+" GMT"+b}}}}function hatta_edit(){var b=
2347 document.getElementById('editortext');if(b){var c=0+
2348 document.location.hash.substring(1);var d=b.textContent.match(/(.*\n)/g);var
2349 f='';for(var i=0;i<d.length&&i<c;++i){f+=d[i]}b.focus();if(b.setSelectionRange)
2350 {b.setSelectionRange(f.length,f.length)}else if(b.createTextRange){var g=
2351 b.createTextRange();g.collapse(true);g.moveEnd('character',f.length);
2352 g.moveStart('character',f.length);g.select()}var h=document.createElement('pre'
2353 );b.parentNode.appendChild(h);var k=window.getComputedStyle(b,'');h.style.font=
2354 k.font;h.style.border=k.border;h.style.outline=k.outline;h.style.lineHeight=
2355 k.lineHeight;h.style.letterSpacing=k.letterSpacing;h.style.fontFamily=
2356 k.fontFamily;h.style.fontSize=k.fontSize;h.style.padding=0;h.style.overflow=
2357 'scroll';try{h.style.whiteSpace="-moz-pre-wrap"}catch(e){};try{
2358 h.style.whiteSpace="-o-pre-wrap"}catch(e){};try{h.style.whiteSpace="-pre-wrap"
2359 }catch(e){};try{h.style.whiteSpace="pre-wrap"}catch(e){};h.textContent=f;
2360 b.scrollTop=h.scrollHeight;h.parentNode.removeChild(h)}else{var l='';var m=
2361 document.getElementsByTagName('link');for(var i=0;i<m.length;++i){var n=m[i];
2362 if(n.getAttribute('type')==='application/wiki'){l=n.getAttribute('href')}}if(
2363 l===''){return}var o=['p','h1','h2','h3','h4','h5','h6','pre','ul','div',
2364 'span'];for(var j=0;j<o.length;++j){var m=document.getElementsByTagName(o[j]);
2365 for(var i=0;i<m.length;++i){var n=m[i];if(n.id&&n.id.match(/^line_\d+$/)){
2366 n.ondblclick=function(){var a=l+'#'+this.id.replace('line_','');
2367 document.location.href=a}}}}}}
2368 window.onload=function(){hatta_dates();hatta_edit()}"""
2369 style = """\
2370 html { background: #fff; color: #2e3436;
2371 font-family: sans-serif; font-size: 96% }
2372 body { margin: 1em auto; line-height: 1.3; width: 40em }
2373 a { color: #3465a4; text-decoration: none }
2374 a:hover { text-decoration: underline }
2375 a.wiki:visited { color: #204a87 }
2376 a.nonexistent, a.nonexistent:visited { color: #a40000; }
2377 a.external { color: #3465a4; text-decoration: underline }
2378 a.external:visited { color: #75507b }
2379 a img { border: none }
2380 img.math, img.smiley { vertical-align: middle }
2381 pre { font-size: 100%; white-space: pre-wrap; word-wrap: break-word;
2382 white-space: -moz-pre-wrap; white-space: -pre-wrap;
2383 white-space: -o-pre-wrap; line-height: 1.2; color: #555753 }
2384 div.conflict pre.local { background: #fcaf3e; margin-bottom: 0; color: 000}
2385 div.conflict pre.other { background: #ffdd66; margin-top: 0; color: 000; border-top: #d80 dashed 1px; }
2386 pre.diff div.orig { font-size: 75%; color: #babdb6 }
2387 b.highlight, pre.diff ins { font-weight: bold; background: #fcaf3e;
2388 color: #ce5c00; text-decoration: none }
2389 pre.diff del { background: #eeeeec; color: #888a85; text-decoration: none }
2390 pre.diff div.change { border-left: 2px solid #fcaf3e }
2391 div.footer { border-top: solid 1px #babdb6; text-align: right }
2392 h1, h2, h3, h4 { color: #babdb6; font-weight: normal; letter-spacing: 0.125em}
2393 div.buttons { text-align: center }
2394 input.button, div.buttons input { font-weight: bold; font-size: 100%;
2395 background: #eee; border: solid 1px #babdb6; margin: 0.25em; color: #888a85}
2396 .history input.button { font-size: 75% }
2397 .editor textarea { width: 100%; display: block; font-size: 100%;
2398 border: solid 1px #babdb6; }
2399 .editor label { display:block; text-align: right }
2400 .editor .upload { margin: 2em auto; text-align: center }
2401 form.search input.search, .editor label input { font-size: 100%;
2402 border: solid 1px #babdb6; margin: 0.125em 0 }
2403 .editor label.comment input { width: 32em }
2404 a.logo { float: left; display: block; margin: 0.25em }
2405 div.header h1 { margin: 0; }
2406 div.content { clear: left }
2407 form.search { margin:0; text-align: right; font-size: 80% }
2408 div.snippet { font-size: 80%; color: #888a85 }
2409 div.header div.menu { float: right; margin-top: 1.25em }
2410 div.header div.menu a.current { color: #000 }
2411 hr { background: transparent; border:none; height: 0;
2412 border-bottom: 1px solid #babdb6; clear: both }
2413 blockquote { border-left:.25em solid #ccc; padding-left:.5em; margin-left:0}
2414 abbr.date {border:none}
2415 dt {font-weight: bold; float: left; }
2416 dd {font-style: italic; }
2417 """
2419 def __init__(self, config):
2420 if config.get_bool('show_version', False):
2421 sys.stdout.write("Hatta %s\n" % __version__)
2422 sys.exit()
2423 self.dead = False
2424 self.config = config
2425 self.language = config.get('language', None)
2426 global _
2427 if self.language is not None:
2428 try:
2429 _ = gettext.translation('hatta', 'locale',
2430 languages=[self.language]).ugettext
2431 except IOError:
2432 _ = gettext.translation('hatta', fallback=True,
2433 languages=[self.language]).ugettext
2434 else:
2435 _ = gettext.translation('hatta', fallback=True).ugettext
2436 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2437 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2438 self.page_charset = config.get('page_charset', 'utf-8')
2439 self.menu_page = self.config.get('menu_page', u'Menu')
2440 self.front_page = self.config.get('front_page', u'Home')
2441 self.logo_page = self.config.get('logo_page', u'logo.png')
2442 self.locked_page = self.config.get('locked_page', u'Locked')
2443 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2444 self.read_only = self.config.get_bool('read_only', False)
2445 self.icon_page = self.config.get('icon_page', None)
2446 self.pygments_style = self.config.get('pygments_style', 'tango')
2448 self.storage = self.storage_class(self.path, self.page_charset)
2449 if not os.path.isdir(self.cache):
2450 os.makedirs(self.cache)
2451 reindex = True
2452 else:
2453 reindex = False
2454 self.index = self.index_class(self.cache, self.language, self.storage)
2455 R = werkzeug.routing.Rule
2456 self.url_map = werkzeug.routing.Map([
2457 R('/', defaults={'title': self.front_page},
2458 endpoint=self.view, methods=['GET', 'HEAD']),
2459 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2460 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2461 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2462 R('/+history/', endpoint=self.recent_changes,
2463 methods=['GET', 'HEAD']),
2464 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2465 endpoint=self.diff, methods=['GET', 'HEAD']),
2466 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2467 methods=['GET', 'HEAD']),
2468 R('/+history/<title:title>', endpoint=self.history,
2469 methods=['GET', 'HEAD']),
2470 R('/+version/', endpoint=self.version, methods=['GET', 'HEAD']),
2471 R('/+version/<title:title>', endpoint=self.version,
2472 methods=['GET', 'HEAD']),
2473 R('/+download/<title:title>', endpoint=self.download,
2474 methods=['GET', 'HEAD']),
2475 R('/+render/<title:title>', endpoint=self.render,
2476 methods=['GET', 'HEAD']),
2477 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2478 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2479 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2480 R('/+index', endpoint=self.all_pages, methods=['GET', 'HEAD']),
2481 R('/+orphaned', endpoint=self.orphaned, methods=['GET', 'HEAD']),
2482 R('/+wanted', endpoint=self.wanted, methods=['GET', 'HEAD']),
2483 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2484 R('/+search/<title:title>', endpoint=self.backlinks,
2485 methods=['GET', 'POST']),
2486 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2487 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2488 methods=['GET', 'POST', 'HEAD']),
2489 # Pages with default content
2490 R('/favicon.ico', endpoint=self.favicon_ico,
2491 methods=['GET', 'HEAD']),
2492 R('/robots.txt', endpoint=self.robots_txt, methods=['GET', 'HEAD']),
2493 R('/+download/style.css', endpoint=self.style_css,
2494 methods=['GET', 'HEAD']),
2495 R('/+download/pygments.css', endpoint=self.pygments_css,
2496 methods=['GET', 'HEAD']),
2497 R('/+download/scripts.js', endpoint=self.scripts_js,
2498 methods=['GET', 'HEAD']),
2499 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2501 def get_page(self, request, title):
2502 """Creates a page object based on page's mime type"""
2504 if title:
2505 try:
2506 page_class, mime = self.filename_map[title]
2507 except KeyError:
2508 mime = self.storage.page_mime(title)
2509 major, minor = mime.split('/', 1)
2510 try:
2511 page_class = self.mime_map[mime]
2512 except KeyError:
2513 try:
2514 plus_pos = minor.find('+')
2515 if plus_pos>0:
2516 minor_base = minor[plus_pos:]
2517 else:
2518 minor_base = ''
2519 base_mime = '/'.join([major, minor_base])
2520 page_class = self.mime_map[base_mime]
2521 except KeyError:
2522 try:
2523 page_class = self.mime_map[major]
2524 except KeyError:
2525 page_class = self.mime_map['']
2526 else:
2527 page_class = WikiPage
2528 mime = ''
2529 return page_class(self, request, title, mime)
2531 def view(self, request, title):
2532 page = self.get_page(request, title)
2533 try:
2534 content = page.view_content()
2535 except werkzeug.exceptions.NotFound:
2536 url = request.get_url(title, self.edit, external=True)
2537 return werkzeug.routing.redirect(url, code=303)
2538 html = page.render_content(content)
2539 dependencies = page.dependencies()
2540 etag = '/(%s)' % u','.join(dependencies)
2541 return self.response(request, title, html, etag=etag)
2543 def revision(self, request, title, rev):
2544 text = self.storage.revision_text(title, rev)
2545 link = werkzeug.html.a(werkzeug.html(title),
2546 href=request.get_url(title))
2547 content = [
2548 werkzeug.html.p(
2549 werkzeug.html(
2550 _(u'Content of revision %(rev)d of page %(title)s:'))
2551 % {'rev': rev, 'title': link }),
2552 werkzeug.html.pre(werkzeug.html(text)),
2553 ]
2554 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2555 page = self.get_page(request, title)
2556 html = page.render_content(content, special_title)
2557 response = self.response(request, title, html, rev=rev, etag='/old')
2558 return response
2560 def version(self, request, title=None):
2561 if title is None:
2562 version = self.storage.repo_revision()
2563 else:
2564 try:
2565 version, x, x, x = self.storage.page_history(title).next()
2566 except StopIteration:
2567 version = 0
2568 return werkzeug.Response('%d' % version, mimetype="text/plain")
2570 def _check_lock(self, title):
2571 restricted_pages = [
2572 'scripts.js',
2573 'robots.txt',
2574 ]
2575 if self.read_only:
2576 raise werkzeug.exceptions.Forbidden(_(u"This site is read-only."))
2577 if title in restricted_pages:
2578 raise werkzeug.exceptions.Forbidden(_(u"""Can't edit this page.
2579 It can only be edited by the site admin directly on the disk."""))
2580 if title in self.index.page_links(self.locked_page):
2581 raise werkzeug.exceptions.Forbidden(_(u"This page is locked."))
2583 def save(self, request, title):
2584 self._check_lock(title)
2585 url = request.get_url(title)
2586 if request.form.get('cancel'):
2587 if title not in self.storage:
2588 url = request.get_url(self.front_page)
2589 if request.form.get('preview'):
2590 text = request.form.get("text")
2591 if text is not None:
2592 lines = text.split('\n')
2593 else:
2594 lines = [werkzeug.html.p(werkzeug.html(
2595 _(u'No preview for binaries.')))]
2596 return self.edit(request, title, preview=lines)
2597 elif request.form.get('save'):
2598 comment = request.form.get("comment", "")
2599 author = request.get_author()
2600 text = request.form.get("text")
2601 try:
2602 parent = int(request.form.get("parent"))
2603 except (ValueError, TypeError):
2604 parent = None
2605 self.storage.reopen()
2606 self.index.update(self, request)
2607 page = self.get_page(request, title)
2608 if text is not None:
2609 if title == self.locked_page:
2610 for link, label in page.extract_links(text):
2611 if title == link:
2612 raise werkzeug.exceptions.Forbidden(
2613 _(u"This page is locked."))
2614 if u'href="' in comment or u'http:' in comment:
2615 raise werkzeug.exceptions.Forbidden()
2616 if text.strip() == '':
2617 self.storage.delete_page(title, author, comment)
2618 url = request.get_url(self.front_page)
2619 else:
2620 self.storage.save_text(title, text, author, comment, parent)
2621 else:
2622 text = u''
2623 upload = request.files['data']
2624 f = upload.stream
2625 if f is not None and upload.filename is not None:
2626 try:
2627 self.storage.save_file(title, f.tmpname, author,
2628 comment, parent)
2629 except AttributeError:
2630 self.storage.save_data(title, f.read(), author,
2631 comment, parent)
2632 else:
2633 self.storage.delete_page(title, author, comment)
2634 url = request.get_url(self.front_page)
2635 self.index.update_page(page, title, text=text)
2636 response = werkzeug.routing.redirect(url, code=303)
2637 response.set_cookie('author',
2638 werkzeug.url_quote(request.get_author()),
2639 max_age=604800)
2640 return response
2642 def edit(self, request, title, preview=None):
2643 self._check_lock(title)
2644 exists = title in self.storage
2645 if exists:
2646 self.storage.reopen()
2647 page = self.get_page(request, title)
2648 content = page.editor_form(preview)
2649 special_title = _(u'Editing "%(title)s"') % {'title': title}
2650 html = page.render_content(content, special_title)
2651 if not exists:
2652 response = werkzeug.Response(html, mimetype="text/html",
2653 status='404 Not found')
2655 elif preview:
2656 response = werkzeug.Response(html, mimetype="text/html")
2657 else:
2658 response = self.response(request, title, html, '/edit')
2659 response.headers.add('Cache-Control', 'no-cache')
2660 return response
2662 def atom(self, request):
2663 date_format = "%Y-%m-%dT%H:%M:%SZ"
2664 first_date = datetime.datetime.now()
2665 now = first_date.strftime(date_format)
2666 body = []
2667 first_title = u''
2668 count = 0
2669 unique_titles = {}
2670 for title, rev, date, author, comment in self.storage.history():
2671 if title in unique_titles:
2672 continue
2673 unique_titles[title] = True
2674 count += 1
2675 if count > 10:
2676 break
2677 if not first_title:
2678 first_title = title
2679 first_rev = rev
2680 first_date = date
2681 item = u"""<entry>
2682 <title>%(title)s</title>
2683 <link href="%(page_url)s" />
2684 <content>%(comment)s</content>
2685 <updated>%(date)s</updated>
2686 <author>
2687 <name>%(author)s</name>
2688 <uri>%(author_url)s</uri>
2689 </author>
2690 <id>%(url)s</id>
2691 </entry>""" % {
2692 'title': werkzeug.escape(title),
2693 'page_url': request.adapter.build(self.view, {'title': title},
2694 force_external=True),
2695 'comment': werkzeug.escape(comment),
2696 'date': date.strftime(date_format),
2697 'author': werkzeug.escape(author),
2698 'author_url': request.adapter.build(self.view,
2699 {'title': author},
2700 force_external=True),
2701 'url': request.adapter.build(self.revision,
2702 {'title': title, 'rev': rev},
2703 force_external=True),
2704 }
2705 body.append(item)
2706 content = u"""<?xml version="1.0" encoding="utf-8"?>
2707 <feed xmlns="http://www.w3.org/2005/Atom">
2708 <title>%(title)s</title>
2709 <link rel="self" href="%(atom)s"/>
2710 <link href="%(home)s"/>
2711 <id>%(home)s</id>
2712 <updated>%(date)s</updated>
2713 <logo>%(logo)s</logo>
2714 %(body)s
2715 </feed>""" % {
2716 'title': self.site_name,
2717 'home': request.adapter.build(self.view, force_external=True),
2718 'atom': request.adapter.build(self.atom, force_external=True),
2719 'date': first_date.strftime(date_format),
2720 'logo': request.adapter.build(self.download,
2721 {'title': self.logo_page},
2722 force_external=True),
2723 'body': u''.join(body),
2724 }
2725 response = self.response(request, 'atom', content, '/atom',
2726 'application/xml', first_rev, first_date)
2727 response.set_etag('/atom/%d' % self.storage.repo_revision())
2728 response.make_conditional(request)
2729 return response
2731 def rss(self, request):
2732 """Serve an RSS feed of recent changes."""
2734 first_date = datetime.datetime.now()
2735 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2736 rss_body = []
2737 first_title = u''
2738 count = 0
2739 unique_titles = {}
2740 for title, rev, date, author, comment in self.storage.history():
2741 if title in unique_titles:
2742 continue
2743 unique_titles[title] = True
2744 count += 1
2745 if count > 10:
2746 break
2747 if not first_title:
2748 first_title = title
2749 first_rev = rev
2750 first_date = date
2751 item = (u'<item><title>%s</title><link>%s</link>'
2752 u'<description>%s</description><pubDate>%s</pubDate>'
2753 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2754 werkzeug.escape(title),
2755 request.adapter.build(self.view, {'title': title},
2756 force_external=True),
2757 werkzeug.escape(comment),
2758 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2759 werkzeug.escape(author),
2760 request.adapter.build(self.revision,
2761 {'title': title, 'rev': rev})
2762 ))
2763 rss_body.append(item)
2764 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2765 <rss version="2.0"
2766 xmlns:dc="http://purl.org/dc/elements/1.1/"
2767 xmlns:atom="http://www.w3.org/2005/Atom"
2768 >
2769 <channel>
2770 <title>%s</title>
2771 <atom:link href="%s" rel="self" type="application/rss+xml" />
2772 <link>%s</link>
2773 <description>%s</description>
2774 <generator>Hatta Wiki</generator>
2775 <language>en</language>
2776 <lastBuildDate>%s</lastBuildDate>
2778 """ % (
2779 werkzeug.escape(self.site_name),
2780 request.adapter.build(self.rss),
2781 request.adapter.build(self.recent_changes),
2782 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2783 u'in this feed.')),
2784 first_date,
2785 )
2786 content = [rss_head]+rss_body+[u'</channel></rss>']
2787 response = self.response(request, 'rss', content, '/rss',
2788 'application/xml', first_rev, first_date)
2789 response.set_etag('/rss/%d' % self.storage.repo_revision())
2790 response.make_conditional(request)
2791 return response
2793 def response(self, request, title, content, etag='', mime='text/html',
2794 rev=None, date=None, size=None):
2795 """Create a WikiResponse for a page."""
2797 response = WikiResponse(content, mimetype=mime)
2798 if rev is None:
2799 inode, _size, mtime = self.storage.page_file_meta(title)
2800 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2801 inode, mtime))
2802 if size == -1:
2803 size = _size
2804 else:
2805 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2806 rev))
2807 if size:
2808 response.content_length = size
2809 response.make_conditional(request)
2810 return response
2812 def download(self, request, title):
2813 """Serve the raw content of a page."""
2815 mime = self.storage.page_mime(title)
2816 if mime == 'text/x-wiki':
2817 mime = 'text/plain'
2818 f = self.storage.open_page(title)
2819 response = self.response(request, title, f, '/download', mime, size=-1)
2820 return response
2822 def render(self, request, title):
2823 """Serve a thumbnail or otherwise rendered content."""
2825 def file_time_and_size(file_path):
2826 """Get file's modification timestamp and its size."""
2828 try:
2829 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2830 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2831 except OSError:
2832 st_mtime = 0
2833 st_size = None
2834 return st_mtime, st_size
2836 def rm_temp_dir(dir_path):
2837 """Delete the directory with subdirectories."""
2839 for root, dirs, files in os.walk(dir_path, topdown=False):
2840 for name in files:
2841 try:
2842 os.remove(os.path.join(root, name))
2843 except OSError:
2844 pass
2845 for name in dirs:
2846 try:
2847 os.rmdir(os.path.join(root, name))
2848 except OSError:
2849 pass
2850 try:
2851 os.rmdir(dir_path)
2852 except OSError:
2853 pass
2855 page = self.get_page(request, title)
2856 try:
2857 cache_filename, cache_mime = page.render_mime()
2858 render = page.render_cache
2859 except (AttributeError, NotImplementedError):
2860 return self.download(request, title)
2862 cache_dir = os.path.join(self.cache, 'render',
2863 werkzeug.url_quote(title, safe=''))
2864 cache_file = os.path.join(cache_dir, cache_filename)
2865 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2866 cache_mtime, cache_size = file_time_and_size(cache_file)
2867 if page_mtime > cache_mtime:
2868 if not os.path.exists(cache_dir):
2869 os.makedirs(cache_dir)
2870 try:
2871 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2872 result_file = render(temp_dir)
2873 mercurial.util.rename(result_file, cache_file)
2874 finally:
2875 rm_temp_dir(temp_dir)
2876 f = open(cache_file)
2877 response = self.response(request, title, f, '/render', cache_mime,
2878 size=cache_size)
2879 return response
2881 def undo(self, request, title):
2882 """Revert a change to a page."""
2884 self._check_lock(title)
2885 rev = None
2886 for key in request.form:
2887 try:
2888 rev = int(key)
2889 except ValueError:
2890 pass
2891 author = request.get_author()
2892 if rev is not None:
2893 try:
2894 parent = int(request.form.get("parent"))
2895 except (ValueError, TypeError):
2896 parent = None
2897 self.storage.reopen()
2898 self.index.update(self, request)
2899 if rev == 0:
2900 comment = _(u'Delete page %(title)s') % {'title': title}
2901 data = ''
2902 self.storage.delete_page(title, author, comment)
2903 else:
2904 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2905 'rev': rev, 'title': title}
2906 data = self.storage.page_revision(title, rev-1)
2907 self.storage.save_data(title, data, author, comment, parent)
2908 page = self.get_page(request, title)
2909 self.index.update_page(page, title, data=data)
2910 url = request.adapter.build(self.history, {'title': title},
2911 method='GET', force_external=True)
2912 return werkzeug.redirect(url, 303)
2914 def history(self, request, title):
2915 """Display history of changes of a page."""
2917 page = self.get_page(request, title)
2918 content = page.render_content(page.history_list(),
2919 _(u'History of "%(title)s"') % {'title': title})
2920 response = self.response(request, title, content, '/history')
2921 return response
2924 def recent_changes(self, request):
2925 """Serve the recent changes page."""
2927 def changes_list(page):
2928 """Generate the content of the recent changes page."""
2930 h = werkzeug.html
2931 yield u'<ul>'
2932 last = {}
2933 lastrev = {}
2934 count = 0
2935 for title, rev, date, author, comment in self.storage.history():
2936 if (author, comment) == last.get(title, (None, None)):
2937 continue
2938 count += 1
2939 if count > 100:
2940 break
2941 if rev > 0:
2942 date_url = request.adapter.build(self.diff, {
2943 'title': title,
2944 'from_rev': rev-1,
2945 'to_rev': lastrev.get(title, rev)
2946 })
2947 elif rev == 0:
2948 date_url = request.adapter.build(self.revision, {
2949 'title': title, 'rev': rev})
2950 else:
2951 date_url = request.adapter.build(self.history, {
2952 'title': title})
2953 last[title] = author, comment
2954 lastrev[title] = rev
2956 yield h.li(h.a(page.date_html(date), href=date_url), ' ',
2957 h.b(page.wiki_link(title)), u' . . . . ',
2958 h.i(page.wiki_link(author)),
2959 h.div(h(comment), class_="comment")
2960 )
2961 yield u'</ul>'
2963 page = self.get_page(request, '')
2964 content = page.render_content(changes_list(page), _(u'Recent changes'))
2965 response = WikiResponse(content, mimetype='text/html')
2966 response.set_etag('/history/%d' % self.storage.repo_revision())
2967 response.make_conditional(request)
2968 return response
2970 def diff(self, request, title, from_rev, to_rev):
2971 """Show the differences between specified revisions."""
2973 page = self.get_page(request, title)
2974 build = request.adapter.build
2975 from_url = build(self.revision, {'title': title, 'rev': from_rev})
2976 to_url = build(self.revision, {'title': title, 'rev': to_rev})
2977 a = werkzeug.html.a
2978 links = {
2979 'link1': a(str(from_rev), href=from_url),
2980 'link2': a(str(to_rev), href=to_url),
2981 'link': a(werkzeug.html(title), href=request.get_url(title)),
2982 }
2983 message = werkzeug.html(_(
2984 u'Differences between revisions %(link1)s and %(link2)s '
2985 u'of page %(link)s.')) % links
2986 diff_content = getattr(page, 'diff_content', None)
2987 if diff_content:
2988 from_text = self.storage.revision_text(page.title, from_rev)
2989 to_text = self.storage.revision_text(page.title, to_rev)
2990 content = page.diff_content(from_text, to_text, message)
2991 else:
2992 content = [werkzeug.html.p(werkzeug.html(
2993 _(u"Diff not available for this kind of pages.")))]
2994 special_title = _(u'Diff for "%(title)s"') % {'title': title}
2995 html = page.render_content(content, special_title)
2996 response = werkzeug.Response(html, mimetype='text/html')
2997 return response
3000 def all_pages(self, request):
3001 """Show index of all pages in the wiki."""
3003 page = self.get_page(request, '')
3004 all_pages = sorted(self.storage.all_pages())
3005 content = page.pages_list(all_pages, _(u'Index of all pages'))
3006 html = page.render_content(content, _(u'Page Index'))
3007 response = WikiResponse(html, mimetype='text/html')
3008 response.set_etag('/+index/%d' % self.storage.repo_revision())
3009 response.make_conditional(request)
3010 return response
3012 def orphaned(self, request):
3013 """Show all pages that don't have backlinks."""
3015 page = self.get_page(request, '')
3016 pages = self.index.orphaned_pages()
3017 content = page.pages_list(pages,
3018 _(u'List of pages with no links to them'))
3019 html = page.render_content(content, _(u'Orphaned pages'))
3020 response = WikiResponse(html, mimetype='text/html')
3021 response.set_etag('/+orphaned/%d' % self.storage.repo_revision())
3022 response.make_conditional(request)
3023 return response
3025 def wanted(self, request):
3026 """Show all pages that don't exist yet, but are linked."""
3028 def wanted_pages_list(page):
3029 """Generate the content of wanted pages page."""
3031 h = werkzeug.html
3032 yield h.p(h(
3033 _(u"List of pages that are linked to, but don't exist yet.")))
3034 yield u'<ol class="wanted">'
3035 for refs, title in self.index.wanted_pages():
3036 url = page.get_url(title, self.backlinks)
3037 yield h.li(h.b(page.wiki_link(title)),
3038 h.i(u' (', h.a(h(_(u"%d references") % refs),
3039 href=url, class_="backlinks"), ')'))
3040 yield u'</ol>'
3042 page = self.get_page(request, '')
3043 content = wanted_pages_list(page)
3044 html = page.render_content(content, _(u'Wanted pages'))
3045 response = WikiResponse(html, mimetype='text/html')
3046 response.set_etag('/+wanted/%d' % self.storage.repo_revision())
3047 response.make_conditional(request)
3048 return response
3050 def search(self, request):
3051 """Serve the search results page."""
3053 def search_snippet(title, words):
3054 """Extract a snippet of text for search results."""
3056 try:
3057 text = self.storage.page_text(title)
3058 except werkzeug.exceptions.NotFound:
3059 return u''
3060 regexp = re.compile(u"|".join(re.escape(w) for w in words),
3061 re.U|re.I)
3062 match = regexp.search(text)
3063 if match is None:
3064 return u""
3065 position = match.start()
3066 min_pos = max(position - 60, 0)
3067 max_pos = min(position + 60, len(text))
3068 snippet = werkzeug.escape(text[min_pos:max_pos])
3069 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
3070 html = regexp.sub(highlighted, snippet)
3071 return html
3073 def page_search(words, page, request):
3074 """Display the search results."""
3076 h = werkzeug.html
3077 self.storage.reopen()
3078 self.index.update(self, request)
3079 result = sorted(self.index.find(words), key=lambda x:-x[0])
3080 yield werkzeug.html.p(h(_(u'%d page(s) containing all words:')
3081 % len(result)))
3082 yield u'<ol class="search">'
3083 for number, (score, title) in enumerate(result):
3084 yield h.li(h.b(page.wiki_link(title)), u' ', h.i(str(score)),
3085 h.div(search_snippet(title, words),
3086 _class="snippet"),
3087 id_="search-%d" % (number+1))
3088 yield u'</ol>'
3090 query = request.values.get('q', u'').strip()
3091 page = self.get_page(request, '')
3092 if not query:
3093 url = request.get_url(view=self.all_pages, external=True)
3094 return werkzeug.routing.redirect(url, code=303)
3095 words = tuple(self.index.split_text(query, stop=False))
3096 if not words:
3097 words = (query,)
3098 title = _(u'Searching for "%s"') % u" ".join(words)
3099 content = page_search(words, page, request)
3100 html = page.render_content(content, title)
3101 return WikiResponse(html, mimetype='text/html')
3103 def backlinks(self, request, title):
3104 """Serve the page with backlinks."""
3106 self.storage.reopen()
3107 self.index.update(self, request)
3108 page = self.get_page(request, title)
3109 message = _(u'Pages that contain a link to %(link)s.')
3110 link = page.wiki_link(title)
3111 pages = self.index.page_backlinks(title)
3112 content = page.pages_list(pages, message, link, _class='backlinks')
3113 html = page.render_content(content, _(u'Links to "%s"') % title)
3114 response = WikiResponse(html, mimetype='text/html')
3115 response.set_etag('/+search/%d' % self.storage.repo_revision())
3116 response.make_conditional(request)
3117 return response
3119 def _serve_default(self, request, title, content, mime):
3120 """Some pages have their default content."""
3122 if title in self.storage:
3123 return self.download(request, title)
3124 response = werkzeug.Response(content, mimetype=mime)
3125 response.set_etag('/%s/-1' % title)
3126 response.make_conditional(request)
3127 return response
3129 def scripts_js(self, request):
3130 """Server the default scripts"""
3132 return self._serve_default(request, 'scripts.js', self.scripts,
3133 'text/javascript')
3135 def style_css(self, request):
3136 """Serve the default style"""
3138 return self._serve_default(request, 'style.css', self.style,
3139 'text/css')
3140 def pygments_css(self, request):
3141 """Serve the default pygments style"""
3143 if pygments is None:
3144 raise werkzeug.exceptions.NotFound()
3146 pygments_style = self.pygments_style
3147 if pygments_style not in pygments.styles.STYLE_MAP:
3148 pygments_style = 'default'
3149 formatter = pygments.formatters.HtmlFormatter(style=pygments_style)
3150 style_defs = formatter.get_style_defs('.highlight')
3151 return self._serve_default(request, 'pygments.css', style_defs,
3152 'text/css')
3154 def favicon_ico(self, request):
3155 """Serve the default favicon."""
3157 return self._serve_default(request, 'favicon.ico', self.icon,
3158 'image/x-icon')
3160 def robots_txt(self, request):
3161 """Serve the robots directives."""
3163 robots = ('User-agent: *\r\n'
3164 'Disallow: /+*\r\n'
3165 'Disallow: /%2B*\r\n'
3166 'Disallow: /+edit\r\n'
3167 'Disallow: /+feed\r\n'
3168 'Disallow: /+history\r\n'
3169 'Disallow: /+search\r\n'
3170 'Disallow: /+hg\r\n'
3171 )
3172 return self._serve_default(request, 'robots.txt', robots,
3173 'text/plain')
3175 def hgweb(self, request, path=None):
3176 """Serve the pages repository on the web like a normal hg repository."""
3178 if not self.config.get_bool('hgweb', False):
3179 raise werkzeug.exceptions.Forbidden(
3180 _(u'Repository access disabled.'))
3181 app = mercurial.hgweb.request.wsgiapplication(
3182 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
3183 def hg_app(env, start):
3184 env = request.environ
3185 prefix='/+hg'
3186 if env['PATH_INFO'].startswith(prefix):
3187 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
3188 env["SCRIPT_NAME"] += prefix
3189 return app(env, start)
3190 return hg_app
3192 def die(self, request):
3193 """Terminate the standalone server if invoked from localhost."""
3195 if not request.remote_addr.startswith('127.'):
3196 raise werkzeug.exceptions.Forbidden(
3197 _(u'This URL can only be called locally.'))
3198 def agony():
3199 yield u'Oh dear!'
3200 self.dead = True
3201 return werkzeug.Response(agony(), mimetype='text/plain')
3203 @werkzeug.responder
3204 def application(self, environ, start):
3205 """The main application loop."""
3207 adapter = self.url_map.bind_to_environ(environ)
3208 request = WikiRequest(self, adapter, environ)
3209 try:
3210 try:
3211 endpoint, values = adapter.match()
3212 return endpoint(request, **values)
3213 except werkzeug.exceptions.HTTPException, err:
3214 return err
3215 finally:
3216 request.cleanup()
3217 del request
3218 del adapter
3220 def read_config():
3221 """Read and parse the config."""
3223 config = WikiConfig(
3224 # Here you can modify the configuration: uncomment and change the ones
3225 # you need. Note that it's better use environment variables or command
3226 # line switches.
3228 # interface='',
3229 # port=8080,
3230 # pages_path = 'docs',
3231 # cache_path = 'cache',
3232 # front_page = 'Home',
3233 # site_name = 'Hatta Wiki',
3234 # page_charset = 'UTF-8',
3235 )
3236 config.parse_args()
3237 config.parse_files()
3238 # config.sanitize()
3239 return config
3241 def application(env, start):
3242 """Detect that we are being run as WSGI application."""
3244 global application
3245 config = read_config()
3246 script_dir = os.path.dirname(os.path.abspath(__file__))
3247 if config.get('pages_path') is None:
3248 config.set('pages_path', os.path.join(script_dir, 'docs'))
3249 if config.get('cache_path') is None:
3250 config.set('cache_path', os.path.join(script_dir, 'cache'))
3251 wiki = Wiki(config)
3252 application = wiki.application
3253 return application(env, start)
3255 def main(config=None, wiki=None):
3256 """Start a standalone WSGI server."""
3258 config = config or read_config()
3259 wiki = wiki or Wiki(config)
3260 app = wiki.application
3262 host, port = (config.get('interface', '0.0.0.0'),
3263 int(config.get('port', 8080)))
3264 try:
3265 from cherrypy import wsgiserver
3266 except ImportError:
3267 try:
3268 from cherrypy import _cpwsgiserver as wsgiserver
3269 except ImportError:
3270 import wsgiref.simple_server
3271 server = wsgiref.simple_server.make_server(host, port, app)
3272 try:
3273 server.serve_forever()
3274 except KeyboardInterrupt:
3275 pass
3276 return
3277 apps = [('', app)]
3278 name = wiki.site_name
3279 server = wsgiserver.CherryPyWSGIServer((host, port), apps, server_name=name)
3280 try:
3281 server.start()
3282 except KeyboardInterrupt:
3283 server.stop()
3285 if __name__ == "__main__":
3286 main()
