Hatta Devel
view hatta.py @ 723:3b5177cf4780
make WikiStorage thread-safe
| author | Radomir Dopieralski <devel@sheep.art.pl> |
|---|---|
| date | Sun Jan 10 20:13:06 2010 +0100 (2010-01-10) |
| parents | 600d5ac3e719 |
| children | 22b21a40da4e |
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(werkzeug.html(_(u"Can't edit a symbolic link.")))
1915 return
1916 if preview:
1917 lines = preview
1918 comment = self.request.form.get('comment', comment)
1919 html = werkzeug.html
1920 yield u'<form action="" method="POST" class="editor"><div>'
1921 yield u'<textarea name="text" cols="80" rows="20" id="editortext">'
1922 for line in lines:
1923 yield werkzeug.escape(line)
1924 yield u"""</textarea>"""
1925 yield html.input(type_="hidden", name="parent", value=rev)
1926 yield html.label(html(_(u'Comment')), html.input(name="comment",
1927 value=comment), class_="comment")
1928 yield html.label(html(_(u'Author')), html.input(name="author",
1929 value=self.request.get_author()), class_="comment")
1930 yield html.div(
1931 html.input(type_="submit", name="save", value=_(u'Save')),
1932 html.input(type_="submit", name="preview", value=_(u'Preview')),
1933 html.input(type_="submit", name="cancel", value=_(u'Cancel')),
1934 class_="buttons")
1935 yield u'</div></form>'
1936 if preview:
1937 yield html.h1(html(_(u'Preview, not saved')), class_="preview")
1938 for part in self.view_content(preview):
1939 yield part
1941 def diff_content(self, from_text, to_text, message=u''):
1942 """Generate the HTML markup for a diff."""
1944 def infiniter(iterator):
1945 """Turn an iterator into an infinite one, padding it with None"""
1947 for i in iterator:
1948 yield i
1949 while True:
1950 yield None
1952 diff = difflib._mdiff(from_text.split('\n'), to_text.split('\n'))
1953 stack = []
1954 mark_re = re.compile('\0[-+^]([^\1\0]*)\1|([^\0\1])')
1955 yield message
1956 yield u'<pre class="diff">'
1957 for old_line, new_line, changed in diff:
1958 old_no, old_text = old_line
1959 new_no, new_text = new_line
1960 line_no = (new_no or old_no or 1)-1
1961 if changed:
1962 yield u'<div class="change" id="line_%d">' % line_no
1963 old_iter = infiniter(mark_re.finditer(old_text))
1964 new_iter = infiniter(mark_re.finditer(new_text))
1965 old = old_iter.next()
1966 new = new_iter.next()
1967 buff = u''
1968 while old or new:
1969 while old and old.group(1):
1970 if buff:
1971 yield werkzeug.escape(buff)
1972 buff = u''
1973 yield u'<del>%s</del>' % werkzeug.escape(old.group(1))
1974 old = old_iter.next()
1975 while new and new.group(1):
1976 if buff:
1977 yield werkzeug.escape(buff)
1978 buff = u''
1979 yield u'<ins>%s</ins>' % werkzeug.escape(new.group(1))
1980 new = new_iter.next()
1981 if new:
1982 buff += new.group(2)
1983 old = old_iter.next()
1984 new = new_iter.next()
1985 if buff:
1986 yield werkzeug.escape(buff)
1987 yield u'</div>'
1988 else:
1989 yield u'<div class="orig" id="line_%d">%s</div>' % (
1990 line_no, werkzeug.escape(old_text))
1991 yield u'</pre>'
1993 class WikiPageColorText(WikiPageText):
1994 """Text pages, but displayed colorized with pygments"""
1996 def view_content(self, lines=None):
1997 """Generate HTML for the content."""
1999 if lines is None:
2000 text = self.storage.page_text(self.title)
2001 else:
2002 text = ''.join(lines)
2003 return self.highlight(text, mime=self.mime)
2005 def highlight(self, text, mime=None, syntax=None, line_no=0):
2006 """Colorize the source code."""
2008 if pygments is None:
2009 yield werkzeug.html.pre(werkzeug.html(text))
2010 return
2012 formatter = pygments.formatters.HtmlFormatter()
2013 formatter.line_no = line_no
2015 def wrapper(source, outfile):
2016 """Wrap each line of formatted output."""
2018 yield 0, '<div class="highlight"><pre>'
2019 for lineno, line in source:
2020 if line.strip():
2021 yield (lineno,
2022 werkzeug.html.div(line.strip('\n'), id_="line_%d" %
2023 formatter.line_no))
2024 else:
2025 yield (lineno,
2026 werkzeug.html.div(' ', id_="line_%d" %
2027 formatter.line_no))
2028 formatter.line_no += 1
2029 yield 0, '</pre></div>'
2031 formatter.wrap = wrapper
2032 try:
2033 if mime:
2034 lexer = pygments.lexers.get_lexer_for_mimetype(mime)
2035 elif syntax:
2036 lexer = pygments.lexers.get_lexer_by_name(syntax)
2037 else:
2038 lexer = pygments.lexers.guess_lexer(text)
2039 except pygments.util.ClassNotFound:
2040 yield werkzeug.html.pre(werkzeug.html(text))
2041 return
2042 html = pygments.highlight(text, lexer, formatter)
2043 yield html
2045 class WikiPageWiki(WikiPageColorText):
2046 """Pages of with wiki markup use this for display."""
2048 def __init__(self, *args, **kw):
2049 super(WikiPageWiki, self).__init__(*args, **kw)
2050 if self.config.get_bool('wiki_words', False):
2051 self.parser = WikiWikiParser
2052 else:
2053 self.parser = WikiParser
2054 if self.config.get_bool('ignore_indent', False):
2055 try:
2056 del self.parser.block['indent']
2057 except KeyError:
2058 pass
2060 def extract_links(self, text=None):
2061 """Extract all links from the page."""
2063 if text is None:
2064 try:
2065 text = self.storage.page_text(self.title)
2066 except werkzeug.exceptions.NotFound:
2067 text = u''
2068 return self.parser.extract_links(text)
2070 def view_content(self, lines=None):
2071 if lines is None:
2072 f = self.storage.open_page(self.title)
2073 lines = self.storage.page_lines(f)
2074 if self.wiki.icon_page and self.wiki.icon_page in self.storage:
2075 icons = self.index.page_links_and_labels(self.wiki.icon_page)
2076 smilies = dict((emo, link) for (link, emo) in icons)
2077 else:
2078 smilies = None
2079 content = self.parser(lines, self.wiki_link, self.wiki_image,
2080 self.highlight, self.wiki_math, smilies)
2081 return content
2083 def wiki_math(self, math):
2084 math_url = self.config.get('math_url',
2085 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2086 if '%s' in math_url:
2087 url = math_url % werkzeug.url_quote(math)
2088 else:
2089 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2090 label = werkzeug.escape(math, quote=True)
2091 return werkzeug.html.img(src=url, alt=label, class_="math")
2093 def dependencies(self):
2094 dependencies = WikiPage.dependencies(self)
2095 for title in [self.wiki.icon_page]:
2096 if title in self.storage:
2097 inode, size, mtime = self.storage.page_file_meta(title)
2098 etag = '%s/%d-%d' % (werkzeug.url_quote(title), inode, mtime)
2099 dependencies.add(etag)
2100 for link in self.index.page_links(self.title):
2101 if link not in self.storage:
2102 dependencies.add(werkzeug.url_quote(link))
2103 return dependencies
2105 class WikiPageFile(WikiPage):
2106 """Pages of all other mime types use this for display."""
2108 def view_content(self, lines=None):
2109 if self.title not in self.storage:
2110 raise werkzeug.exceptions.NotFound()
2111 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2112 (self.request.get_download_url(self.title),
2113 werkzeug.escape(self.title), self.mime)]
2114 return content
2116 def editor_form(self, preview=None):
2117 author = self.request.get_author()
2118 if self.title in self.storage:
2119 comment = _(u'changed')
2120 (rev, old_date, old_author,
2121 old_comment) = self.storage.page_meta(self.title)
2122 if old_author == author:
2123 comment = old_comment
2124 else:
2125 comment = _(u'uploaded')
2126 rev = -1
2127 html = werkzeug.html
2128 yield html.p(html(
2129 _(u"This is a binary file, it can't be edited on a wiki. "
2130 u"Please upload a new version instead.")))
2131 yield html.form(html.div(
2132 html.div(html.input(type_="file", name="data"), class_="upload"),
2133 html.input(type_="hidden", name="parent", value=rev),
2134 html.label(html(_(u'Comment')), html.input(name="comment",
2135 value=comment)),
2136 html.label(html(_(u'Author')), html.input(name="author",
2137 value=author)),
2138 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2139 html.input(type_="submit", name="cancel",
2140 value=_(u'Cancel')),
2141 class_="buttons")), action="", method="POST", class_="editor",
2142 enctype="multipart/form-data")
2144 class WikiPageImage(WikiPageFile):
2145 """Pages of mime type image/* use this for display."""
2147 render_file = '128x128.png'
2149 def view_content(self, lines=None):
2150 if self.title not in self.storage:
2151 raise werkzeug.exceptions.NotFound()
2152 content = ['<img src="%s" alt="%s">'
2153 % (self.request.get_url(self.title, self.wiki.render),
2154 werkzeug.escape(self.title))]
2155 return content
2157 def render_mime(self):
2158 """Give the filename and mime type of the rendered thumbnail."""
2160 if not Image:
2161 raise NotImplementedError('No Image library available')
2162 return self.render_file, 'image/png'
2164 def render_cache(self, cache_dir):
2165 """Render the thumbnail and save in the cache."""
2167 if not Image:
2168 raise NotImplementedError('No Image library available')
2169 page_file = self.storage.open_page(self.title)
2170 cache_path = os.path.join(cache_dir, self.render_file)
2171 cache_file = open(cache_path, 'wb')
2172 try:
2173 im = Image.open(page_file)
2174 im = im.convert('RGBA')
2175 im.thumbnail((128, 128), Image.ANTIALIAS)
2176 im.save(cache_file,'PNG')
2177 except IOError:
2178 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2179 cache_file.close()
2180 return cache_path
2182 class WikiPageCSV(WikiPageFile):
2183 """Display class for type text/csv."""
2185 def content_iter(self, lines=None):
2186 import csv
2187 # XXX Add preview support
2188 csv_file = self.storage.open_page(self.title)
2189 reader = csv.reader(csv_file)
2190 html_title = werkzeug.escape(self.title, quote=True)
2191 yield u'<table id="%s" class="csvfile">' % html_title
2192 try:
2193 for row in reader:
2194 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2195 for cell in row))
2196 except csv.Error, e:
2197 yield u'</table>'
2198 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2199 % (html_title, reader.line_num, e))
2200 finally:
2201 csv_file.close()
2202 yield u'</table>'
2204 def view_content(self, lines=None):
2205 if self.title not in self.storage:
2206 raise werkzeug.exceptions.NotFound()
2207 return self.content_iter(lines)
2209 class WikiPageRST(WikiPageText):
2210 """
2211 Display ReStructured Text.
2212 """
2214 def content_iter(self, lines):
2215 try:
2216 from docutils.core import publish_parts
2217 except ImportError:
2218 return super(WikiPageRST, self).content_iter(lines)
2219 text = ''.join(lines)
2220 SAFE_DOCUTILS = dict(file_insertion_enabled=False, raw_enabled=False)
2221 content = publish_parts(text, writer_name='html',
2222 settings_overrides=SAFE_DOCUTILS)['html_body']
2223 return [content]
2226 class WikiPageBugs(WikiPageText):
2227 """
2228 Display class for type text/x-bugs
2229 Parse the ISSUES file in (roughly) format used by ciss
2230 """
2232 def content_iter(self, lines):
2233 last_lines = []
2234 in_header = False
2235 in_bug = False
2236 attributes = {}
2237 title = None
2238 for line_no, line in enumerate(lines):
2239 if last_lines and line.startswith('----'):
2240 title = ''.join(last_lines)
2241 last_lines = []
2242 in_header = True
2243 attributes = {}
2244 elif in_header and ':' in line:
2245 attribute, value = line.split(':', 1)
2246 attributes[attribute.strip()] = value.strip()
2247 else:
2248 if in_header:
2249 if in_bug:
2250 yield '</div>'
2251 tags = [tag.strip() for tag in
2252 attributes.get('tags', '').split()
2253 if tag.strip()]
2254 yield '<div id="line_%d">' % (line_no)
2255 in_bug = True
2256 if title:
2257 yield werkzeug.html.h2(werkzeug.html(title))
2258 if attributes:
2259 yield '<dl>'
2260 for attribute, value in attributes.iteritems():
2261 yield werkzeug.html.dt(werkzeug.html(attribute))
2262 yield werkzeug.html.dd(werkzeug.html(value))
2263 yield '</dl>'
2264 in_header = False
2265 if not line.strip():
2266 if last_lines:
2267 if last_lines[0][0] in ' \t':
2268 yield werkzeug.html.pre(werkzeug.html(
2269 ''.join(last_lines)))
2270 else:
2271 yield werkzeug.html.p(werkzeug.html(
2272 ''.join(last_lines)))
2273 last_lines = []
2274 else:
2275 last_lines.append(line)
2276 if last_lines:
2277 if last_lines[0][0] in ' \t':
2278 yield werkzeug.html.pre(werkzeug.html(
2279 ''.join(last_lines)))
2280 else:
2281 yield werkzeug.html.p(werkzeug.html(
2282 ''.join(last_lines)))
2283 if in_bug:
2284 yield '</div>'
2287 class WikiTitleConverter(werkzeug.routing.PathConverter):
2288 """Behaves like the path converter, except that it escapes slashes."""
2290 def to_url(self, value):
2291 return werkzeug.url_quote(value.strip(), self.map.charset, safe="")
2293 regex='([^+%]|%[^2]|%2[^Bb]).*'
2295 class WikiAllConverter(werkzeug.routing.BaseConverter):
2296 """Matches everything."""
2298 regex='.*'
2301 class Wiki(object):
2302 """
2303 The main class of the wiki, handling initialization of the whole
2304 application and most of the logic.
2305 """
2306 storage_class = WikiStorage
2307 index_class = WikiSearch
2308 filename_map = {
2309 'README': (WikiPageText, 'text/plain'),
2310 'ISSUES': (WikiPageBugs, 'text/x-bugs'),
2311 'ISSUES.txt': (WikiPageBugs, 'text/x-bugs'),
2312 'COPYING': (WikiPageText, 'text/plain'),
2313 'CHANGES': (WikiPageText, 'text/plain'),
2314 'MANIFEST': (WikiPageText, 'text/plain'),
2315 'favicon.ico': (WikiPageImage, 'image/x-icon'),
2316 }
2317 mime_map = {
2318 'text': WikiPageColorText,
2319 'application/x-javascript': WikiPageColorText,
2320 'application/x-python': WikiPageColorText,
2321 'text/csv': WikiPageCSV,
2322 'text/x-rst': WikiPageRST,
2323 'text/x-wiki': WikiPageWiki,
2324 'image': WikiPageImage,
2325 '': WikiPageFile,
2326 }
2327 icon = base64.b64decode(
2328 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2329 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2330 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2331 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2332 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2333 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2334 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2335 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2336 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2337 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2338 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2339 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2340 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2341 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2342 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2343 scripts = r"""function hatta_dates(){var a=document.getElementsByTagName(
2344 'abbr');var p=function(i){return('00'+i).slice(-2)};for(var i=0;i<a.length;++i)
2345 {var n=a[i];if(n.className==='date'){var m=
2346 /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$/.exec(
2347 n.getAttribute('title'));var d=new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],
2348 +m[5],+m[6]));if(d){var b=-d.getTimezoneOffset()/60;if(b>=0){b="+"+b}
2349 n.textContent=""+d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+
2350 p(d.getHours())+":"+p(d.getMinutes())+" GMT"+b}}}}function hatta_edit(){var b=
2351 document.getElementById('editortext');if(b){var c=0+
2352 document.location.hash.substring(1);var d=b.textContent.match(/(.*\n)/g);var
2353 f='';for(var i=0;i<d.length&&i<c;++i){f+=d[i]}b.focus();if(b.setSelectionRange)
2354 {b.setSelectionRange(f.length,f.length)}else if(b.createTextRange){var g=
2355 b.createTextRange();g.collapse(true);g.moveEnd('character',f.length);
2356 g.moveStart('character',f.length);g.select()}var h=document.createElement('pre'
2357 );b.parentNode.appendChild(h);var k=window.getComputedStyle(b,'');h.style.font=
2358 k.font;h.style.border=k.border;h.style.outline=k.outline;h.style.lineHeight=
2359 k.lineHeight;h.style.letterSpacing=k.letterSpacing;h.style.fontFamily=
2360 k.fontFamily;h.style.fontSize=k.fontSize;h.style.padding=0;h.style.overflow=
2361 'scroll';try{h.style.whiteSpace="-moz-pre-wrap"}catch(e){};try{
2362 h.style.whiteSpace="-o-pre-wrap"}catch(e){};try{h.style.whiteSpace="-pre-wrap"
2363 }catch(e){};try{h.style.whiteSpace="pre-wrap"}catch(e){};h.textContent=f;
2364 b.scrollTop=h.scrollHeight;h.parentNode.removeChild(h)}else{var l='';var m=
2365 document.getElementsByTagName('link');for(var i=0;i<m.length;++i){var n=m[i];
2366 if(n.getAttribute('type')==='application/wiki'){l=n.getAttribute('href')}}if(
2367 l===''){return}var o=['p','h1','h2','h3','h4','h5','h6','pre','ul','div'];for(
2368 var j=0;j<o.length;++j){var m=document.getElementsByTagName(o[j]);for(var i=0;
2369 i<m.length;++i){var n=m[i];if(n.id&&n.id.match(/^line_\d+$/)){n.ondblclick=
2370 function(){var a=l+'#'+this.id.replace('line_','');document.location.href=a
2371 }}}}}}window.onload=function(){hatta_dates();hatta_edit()}"""
2372 style = """\
2373 html { background: #fff; color: #2e3436;
2374 font-family: sans-serif; font-size: 96% }
2375 body { margin: 1em auto; line-height: 1.3; width: 40em }
2376 a { color: #3465a4; text-decoration: none }
2377 a:hover { text-decoration: underline }
2378 a.wiki:visited { color: #204a87 }
2379 a.nonexistent, a.nonexistent:visited { color: #a40000; }
2380 a.external { color: #3465a4; text-decoration: underline }
2381 a.external:visited { color: #75507b }
2382 a img { border: none }
2383 img.math, img.smiley { vertical-align: middle }
2384 pre { font-size: 100%; white-space: pre-wrap; word-wrap: break-word;
2385 white-space: -moz-pre-wrap; white-space: -pre-wrap;
2386 white-space: -o-pre-wrap; line-height: 1.2; color: #555753 }
2387 div.conflict pre.local { background: #fcaf3e; margin-bottom: 0; color: 000}
2388 div.conflict pre.other { background: #ffdd66; margin-top: 0; color: 000; border-top: #d80 dashed 1px; }
2389 pre.diff div.orig { font-size: 75%; color: #babdb6 }
2390 b.highlight, pre.diff ins { font-weight: bold; background: #fcaf3e;
2391 color: #ce5c00; text-decoration: none }
2392 pre.diff del { background: #eeeeec; color: #888a85; text-decoration: none }
2393 pre.diff div.change { border-left: 2px solid #fcaf3e }
2394 div.footer { border-top: solid 1px #babdb6; text-align: right }
2395 h1, h2, h3, h4 { color: #babdb6; font-weight: normal; letter-spacing: 0.125em}
2396 div.buttons { text-align: center }
2397 input.button, div.buttons input { font-weight: bold; font-size: 100%;
2398 background: #eee; border: solid 1px #babdb6; margin: 0.25em; color: #888a85}
2399 .history input.button { font-size: 75% }
2400 .editor textarea { width: 100%; display: block; font-size: 100%;
2401 border: solid 1px #babdb6; }
2402 .editor label { display:block; text-align: right }
2403 .editor .upload { margin: 2em auto; text-align: center }
2404 form.search input.search, .editor label input { font-size: 100%;
2405 border: solid 1px #babdb6; margin: 0.125em 0 }
2406 .editor label.comment input { width: 32em }
2407 a.logo { float: left; display: block; margin: 0.25em }
2408 div.header h1 { margin: 0; }
2409 div.content { clear: left }
2410 form.search { margin:0; text-align: right; font-size: 80% }
2411 div.snippet { font-size: 80%; color: #888a85 }
2412 div.header div.menu { float: right; margin-top: 1.25em }
2413 div.header div.menu a.current { color: #000 }
2414 hr { background: transparent; border:none; height: 0;
2415 border-bottom: 1px solid #babdb6; clear: both }
2416 blockquote { border-left:.25em solid #ccc; padding-left:.5em; margin-left:0}
2417 abbr.date {border:none}
2418 dt {font-weight: bold; float: left; }
2419 dd {font-style: italic; }
2420 """
2422 def __init__(self, config):
2423 if config.get_bool('show_version', False):
2424 sys.stdout.write("Hatta %s\n" % __version__)
2425 sys.exit()
2426 self.dead = False
2427 self.config = config
2428 self.language = config.get('language', None)
2429 global _
2430 if self.language is not None:
2431 try:
2432 _ = gettext.translation('hatta', 'locale',
2433 languages=[self.language]).ugettext
2434 except IOError:
2435 _ = gettext.translation('hatta', fallback=True,
2436 languages=[self.language]).ugettext
2437 else:
2438 _ = gettext.translation('hatta', fallback=True).ugettext
2439 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2440 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2441 self.page_charset = config.get('page_charset', 'utf-8')
2442 self.menu_page = self.config.get('menu_page', u'Menu')
2443 self.front_page = self.config.get('front_page', u'Home')
2444 self.logo_page = self.config.get('logo_page', u'logo.png')
2445 self.locked_page = self.config.get('locked_page', u'Locked')
2446 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2447 self.read_only = self.config.get_bool('read_only', False)
2448 self.icon_page = self.config.get('icon_page', None)
2449 self.pygments_style = self.config.get('pygments_style', 'tango')
2451 self.storage = self.storage_class(self.path, self.page_charset)
2452 if not os.path.isdir(self.cache):
2453 os.makedirs(self.cache)
2454 reindex = True
2455 else:
2456 reindex = False
2457 self.index = self.index_class(self.cache, self.language, self.storage)
2458 R = werkzeug.routing.Rule
2459 self.url_map = werkzeug.routing.Map([
2460 R('/', defaults={'title': self.front_page},
2461 endpoint=self.view, methods=['GET', 'HEAD']),
2462 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2463 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2464 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2465 R('/+history/', endpoint=self.recent_changes,
2466 methods=['GET', 'HEAD']),
2467 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2468 endpoint=self.diff, methods=['GET', 'HEAD']),
2469 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2470 methods=['GET', 'HEAD']),
2471 R('/+history/<title:title>', endpoint=self.history,
2472 methods=['GET', 'HEAD']),
2473 R('/+version/', endpoint=self.version, methods=['GET', 'HEAD']),
2474 R('/+version/<title:title>', endpoint=self.version,
2475 methods=['GET', 'HEAD']),
2476 R('/+download/<title:title>', endpoint=self.download,
2477 methods=['GET', 'HEAD']),
2478 R('/+render/<title:title>', endpoint=self.render,
2479 methods=['GET', 'HEAD']),
2480 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2481 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2482 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2483 R('/+index', endpoint=self.all_pages, methods=['GET', 'HEAD']),
2484 R('/+orphaned', endpoint=self.orphaned, methods=['GET', 'HEAD']),
2485 R('/+wanted', endpoint=self.wanted, methods=['GET', 'HEAD']),
2486 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2487 R('/+search/<title:title>', endpoint=self.backlinks,
2488 methods=['GET', 'POST']),
2489 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2490 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2491 methods=['GET', 'POST', 'HEAD']),
2492 # Pages with default content
2493 R('/favicon.ico', endpoint=self.favicon_ico,
2494 methods=['GET', 'HEAD']),
2495 R('/robots.txt', endpoint=self.robots_txt, methods=['GET', 'HEAD']),
2496 R('/+download/style.css', endpoint=self.style_css,
2497 methods=['GET', 'HEAD']),
2498 R('/+download/pygments.css', endpoint=self.pygments_css,
2499 methods=['GET', 'HEAD']),
2500 R('/+download/scripts.js', endpoint=self.scripts_js,
2501 methods=['GET', 'HEAD']),
2502 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2504 def get_page(self, request, title):
2505 """Creates a page object based on page's mime type"""
2507 if title:
2508 try:
2509 page_class, mime = self.filename_map[title]
2510 except KeyError:
2511 mime = self.storage.page_mime(title)
2512 major, minor = mime.split('/', 1)
2513 try:
2514 page_class = self.mime_map[mime]
2515 except KeyError:
2516 try:
2517 plus_pos = minor.find('+')
2518 if plus_pos>0:
2519 minor_base = minor[plus_pos:]
2520 else:
2521 minor_base = ''
2522 base_mime = '/'.join([major, minor_base])
2523 page_class = self.mime_map[base_mime]
2524 except KeyError:
2525 try:
2526 page_class = self.mime_map[major]
2527 except KeyError:
2528 page_class = self.mime_map['']
2529 else:
2530 page_class = WikiPage
2531 mime = ''
2532 return page_class(self, request, title, mime)
2534 def view(self, request, title):
2535 page = self.get_page(request, title)
2536 try:
2537 content = page.view_content()
2538 except werkzeug.exceptions.NotFound:
2539 url = request.get_url(title, self.edit, external=True)
2540 return werkzeug.routing.redirect(url, code=303)
2541 html = page.render_content(content)
2542 dependencies = page.dependencies()
2543 etag = '/(%s)' % u','.join(dependencies)
2544 return self.response(request, title, html, etag=etag)
2546 def revision(self, request, title, rev):
2547 text = self.storage.revision_text(title, rev)
2548 link = werkzeug.html.a(werkzeug.html(title),
2549 href=request.get_url(title))
2550 content = [
2551 werkzeug.html.p(
2552 werkzeug.html(
2553 _(u'Content of revision %(rev)d of page %(title)s:'))
2554 % {'rev': rev, 'title': link }),
2555 werkzeug.html.pre(werkzeug.html(text)),
2556 ]
2557 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2558 page = self.get_page(request, title)
2559 html = page.render_content(content, special_title)
2560 response = self.response(request, title, html, rev=rev, etag='/old')
2561 return response
2563 def version(self, request, title=None):
2564 if title is None:
2565 version = self.storage.repo_revision()
2566 else:
2567 try:
2568 version, x, x, x = self.storage.page_history(title).next()
2569 except StopIteration:
2570 version = 0
2571 return werkzeug.Response('%d' % version, mimetype="text/plain")
2573 def _check_lock(self, title):
2574 restricted_pages = [
2575 'scripts.js',
2576 'robots.txt',
2577 ]
2578 if self.read_only:
2579 raise werkzeug.exceptions.Forbidden(_("This site is read-only."))
2580 if title in restricted_pages:
2581 raise werkzeug.exceptions.Forbidden(_("""Can't edit this page.
2582 It can only be edited by the site admin directly on the disk."""))
2583 if title in self.index.page_links(self.locked_page):
2584 raise werkzeug.exceptions.Forbidden(_("This page is locked."))
2586 def save(self, request, title):
2587 self._check_lock(title)
2588 url = request.get_url(title)
2589 if request.form.get('cancel'):
2590 if title not in self.storage:
2591 url = request.get_url(self.front_page)
2592 if request.form.get('preview'):
2593 text = request.form.get("text")
2594 if text is not None:
2595 lines = text.split('\n')
2596 else:
2597 lines = [werkzeug.html.p(werkzeug.html(
2598 _(u'No preview for binaries.')))]
2599 return self.edit(request, title, preview=lines)
2600 elif request.form.get('save'):
2601 comment = request.form.get("comment", "")
2602 author = request.get_author()
2603 text = request.form.get("text")
2604 try:
2605 parent = int(request.form.get("parent"))
2606 except (ValueError, TypeError):
2607 parent = None
2608 self.storage.reopen()
2609 self.index.update(self, request)
2610 page = self.get_page(request, title)
2611 if text is not None:
2612 if title == self.locked_page:
2613 for link, label in page.extract_links(text):
2614 if title == link:
2615 raise werkzeug.exceptions.Forbidden()
2616 if u'href="' in comment or u'http:' in comment:
2617 raise werkzeug.exceptions.Forbidden()
2618 if text.strip() == '':
2619 self.storage.delete_page(title, author, comment)
2620 url = request.get_url(self.front_page)
2621 else:
2622 self.storage.save_text(title, text, author, comment, parent)
2623 else:
2624 text = u''
2625 upload = request.files['data']
2626 f = upload.stream
2627 if f is not None and upload.filename is not None:
2628 try:
2629 self.storage.save_file(title, f.tmpname, author,
2630 comment, parent)
2631 except AttributeError:
2632 self.storage.save_data(title, f.read(), author,
2633 comment, parent)
2634 else:
2635 self.storage.delete_page(title, author, comment)
2636 url = request.get_url(self.front_page)
2637 self.index.update_page(page, title, text=text)
2638 response = werkzeug.routing.redirect(url, code=303)
2639 response.set_cookie('author',
2640 werkzeug.url_quote(request.get_author()),
2641 max_age=604800)
2642 return response
2644 def edit(self, request, title, preview=None):
2645 self._check_lock(title)
2646 exists = title in self.storage
2647 if exists:
2648 self.storage.reopen()
2649 page = self.get_page(request, title)
2650 content = page.editor_form(preview)
2651 special_title = _(u'Editing "%(title)s"') % {'title': title}
2652 html = page.render_content(content, special_title)
2653 if not exists:
2654 response = werkzeug.Response(html, mimetype="text/html",
2655 status='404 Not found')
2657 elif preview:
2658 response = werkzeug.Response(html, mimetype="text/html")
2659 else:
2660 response = self.response(request, title, html, '/edit')
2661 response.headers.add('Cache-Control', 'no-cache')
2662 return response
2664 def atom(self, request):
2665 date_format = "%Y-%m-%dT%H:%M:%SZ"
2666 first_date = datetime.datetime.now()
2667 now = first_date.strftime(date_format)
2668 body = []
2669 first_title = u''
2670 count = 0
2671 unique_titles = {}
2672 for title, rev, date, author, comment in self.storage.history():
2673 if title in unique_titles:
2674 continue
2675 unique_titles[title] = True
2676 count += 1
2677 if count > 10:
2678 break
2679 if not first_title:
2680 first_title = title
2681 first_rev = rev
2682 first_date = date
2683 item = u"""<entry>
2684 <title>%(title)s</title>
2685 <link href="%(page_url)s" />
2686 <content>%(comment)s</content>
2687 <updated>%(date)s</updated>
2688 <author>
2689 <name>%(author)s</name>
2690 <uri>%(author_url)s</uri>
2691 </author>
2692 <id>%(url)s</id>
2693 </entry>""" % {
2694 'title': werkzeug.escape(title),
2695 'page_url': request.adapter.build(self.view, {'title': title},
2696 force_external=True),
2697 'comment': werkzeug.escape(comment),
2698 'date': date.strftime(date_format),
2699 'author': werkzeug.escape(author),
2700 'author_url': request.adapter.build(self.view,
2701 {'title': author},
2702 force_external=True),
2703 'url': request.adapter.build(self.revision,
2704 {'title': title, 'rev': rev},
2705 force_external=True),
2706 }
2707 body.append(item)
2708 content = u"""<?xml version="1.0" encoding="utf-8"?>
2709 <feed xmlns="http://www.w3.org/2005/Atom">
2710 <title>%(title)s</title>
2711 <link rel="self" href="%(atom)s"/>
2712 <link href="%(home)s"/>
2713 <id>%(home)s</id>
2714 <updated>%(date)s</updated>
2715 <logo>%(logo)s</logo>
2716 %(body)s
2717 </feed>""" % {
2718 'title': self.site_name,
2719 'home': request.adapter.build(self.view, force_external=True),
2720 'atom': request.adapter.build(self.atom, force_external=True),
2721 'date': first_date.strftime(date_format),
2722 'logo': request.adapter.build(self.download,
2723 {'title': self.logo_page},
2724 force_external=True),
2725 'body': u''.join(body),
2726 }
2727 response = self.response(request, 'atom', content, '/atom',
2728 'application/xml', first_rev, first_date)
2729 response.set_etag('/atom/%d' % self.storage.repo_revision())
2730 response.make_conditional(request)
2731 return response
2733 def rss(self, request):
2734 """Serve an RSS feed of recent changes."""
2736 first_date = datetime.datetime.now()
2737 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2738 rss_body = []
2739 first_title = u''
2740 count = 0
2741 unique_titles = {}
2742 for title, rev, date, author, comment in self.storage.history():
2743 if title in unique_titles:
2744 continue
2745 unique_titles[title] = True
2746 count += 1
2747 if count > 10:
2748 break
2749 if not first_title:
2750 first_title = title
2751 first_rev = rev
2752 first_date = date
2753 item = (u'<item><title>%s</title><link>%s</link>'
2754 u'<description>%s</description><pubDate>%s</pubDate>'
2755 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2756 werkzeug.escape(title),
2757 request.adapter.build(self.view, {'title': title},
2758 force_external=True),
2759 werkzeug.escape(comment),
2760 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2761 werkzeug.escape(author),
2762 request.adapter.build(self.revision,
2763 {'title': title, 'rev': rev})
2764 ))
2765 rss_body.append(item)
2766 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2767 <rss version="2.0"
2768 xmlns:dc="http://purl.org/dc/elements/1.1/"
2769 xmlns:atom="http://www.w3.org/2005/Atom"
2770 >
2771 <channel>
2772 <title>%s</title>
2773 <atom:link href="%s" rel="self" type="application/rss+xml" />
2774 <link>%s</link>
2775 <description>%s</description>
2776 <generator>Hatta Wiki</generator>
2777 <language>en</language>
2778 <lastBuildDate>%s</lastBuildDate>
2780 """ % (
2781 werkzeug.escape(self.site_name),
2782 request.adapter.build(self.rss),
2783 request.adapter.build(self.recent_changes),
2784 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2785 u'in this feed.')),
2786 first_date,
2787 )
2788 content = [rss_head]+rss_body+[u'</channel></rss>']
2789 response = self.response(request, 'rss', content, '/rss',
2790 'application/xml', first_rev, first_date)
2791 response.set_etag('/rss/%d' % self.storage.repo_revision())
2792 response.make_conditional(request)
2793 return response
2795 def response(self, request, title, content, etag='', mime='text/html',
2796 rev=None, date=None, size=None):
2797 """Create a WikiResponse for a page."""
2799 response = WikiResponse(content, mimetype=mime)
2800 if rev is None:
2801 inode, _size, mtime = self.storage.page_file_meta(title)
2802 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2803 inode, mtime))
2804 if size == -1:
2805 size = _size
2806 else:
2807 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2808 rev))
2809 if size:
2810 response.content_length = size
2811 response.make_conditional(request)
2812 return response
2814 def download(self, request, title):
2815 """Serve the raw content of a page."""
2817 mime = self.storage.page_mime(title)
2818 if mime == 'text/x-wiki':
2819 mime = 'text/plain'
2820 f = self.storage.open_page(title)
2821 response = self.response(request, title, f, '/download', mime, size=-1)
2822 return response
2824 def render(self, request, title):
2825 """Serve a thumbnail or otherwise rendered content."""
2827 def file_time_and_size(file_path):
2828 """Get file's modification timestamp and its size."""
2830 try:
2831 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2832 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2833 except OSError:
2834 st_mtime = 0
2835 st_size = None
2836 return st_mtime, st_size
2838 def rm_temp_dir(dir_path):
2839 """Delete the directory with subdirectories."""
2841 for root, dirs, files in os.walk(dir_path, topdown=False):
2842 for name in files:
2843 try:
2844 os.remove(os.path.join(root, name))
2845 except OSError:
2846 pass
2847 for name in dirs:
2848 try:
2849 os.rmdir(os.path.join(root, name))
2850 except OSError:
2851 pass
2852 try:
2853 os.rmdir(dir_path)
2854 except OSError:
2855 pass
2857 page = self.get_page(request, title)
2858 try:
2859 cache_filename, cache_mime = page.render_mime()
2860 render = page.render_cache
2861 except (AttributeError, NotImplementedError):
2862 return self.download(request, title)
2864 cache_dir = os.path.join(self.cache, 'render',
2865 werkzeug.url_quote(title, safe=''))
2866 cache_file = os.path.join(cache_dir, cache_filename)
2867 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2868 cache_mtime, cache_size = file_time_and_size(cache_file)
2869 if page_mtime > cache_mtime:
2870 if not os.path.exists(cache_dir):
2871 os.makedirs(cache_dir)
2872 try:
2873 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2874 result_file = render(temp_dir)
2875 mercurial.util.rename(result_file, cache_file)
2876 finally:
2877 rm_temp_dir(temp_dir)
2878 f = open(cache_file)
2879 response = self.response(request, title, f, '/render', cache_mime,
2880 size=cache_size)
2881 return response
2883 def undo(self, request, title):
2884 """Revert a change to a page."""
2886 self._check_lock(title)
2887 rev = None
2888 for key in request.form:
2889 try:
2890 rev = int(key)
2891 except ValueError:
2892 pass
2893 author = request.get_author()
2894 if rev is not None:
2895 try:
2896 parent = int(request.form.get("parent"))
2897 except (ValueError, TypeError):
2898 parent = None
2899 self.storage.reopen()
2900 self.index.update(self, request)
2901 if rev == 0:
2902 comment = _(u'Delete page %(title)s') % {'title': title}
2903 data = ''
2904 self.storage.delete_page(title, author, comment)
2905 else:
2906 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2907 'rev': rev, 'title': title}
2908 data = self.storage.page_revision(title, rev-1)
2909 self.storage.save_data(title, data, author, comment, parent)
2910 page = self.get_page(request, title)
2911 self.index.update_page(page, title, data=data)
2912 url = request.adapter.build(self.history, {'title': title},
2913 method='GET', force_external=True)
2914 return werkzeug.redirect(url, 303)
2916 def history(self, request, title):
2917 """Display history of changes of a page."""
2919 page = self.get_page(request, title)
2920 content = page.render_content(page.history_list(),
2921 _(u'History of "%(title)s"') % {'title': title})
2922 response = self.response(request, title, content, '/history')
2923 return response
2926 def recent_changes(self, request):
2927 """Serve the recent changes page."""
2929 def changes_list(page):
2930 """Generate the content of the recent changes page."""
2932 h = werkzeug.html
2933 yield u'<ul>'
2934 last = {}
2935 lastrev = {}
2936 count = 0
2937 for title, rev, date, author, comment in self.storage.history():
2938 if (author, comment) == last.get(title, (None, None)):
2939 continue
2940 count += 1
2941 if count > 100:
2942 break
2943 if rev > 0:
2944 date_url = request.adapter.build(self.diff, {
2945 'title': title,
2946 'from_rev': rev-1,
2947 'to_rev': lastrev.get(title, rev)
2948 })
2949 elif rev == 0:
2950 date_url = request.adapter.build(self.revision, {
2951 'title': title, 'rev': rev})
2952 else:
2953 date_url = request.adapter.build(self.history, {
2954 'title': title})
2955 last[title] = author, comment
2956 lastrev[title] = rev
2958 yield h.li(h.a(page.date_html(date), href=date_url), ' ',
2959 h.b(page.wiki_link(title)), u' . . . . ',
2960 h.i(page.wiki_link(author)),
2961 h.div(h(comment), class_="comment")
2962 )
2963 yield u'</ul>'
2965 page = self.get_page(request, '')
2966 content = page.render_content(changes_list(page), _(u'Recent changes'))
2967 response = WikiResponse(content, mimetype='text/html')
2968 response.set_etag('/history/%d' % self.storage.repo_revision())
2969 response.make_conditional(request)
2970 return response
2972 def diff(self, request, title, from_rev, to_rev):
2973 """Show the differences between specified revisions."""
2975 page = self.get_page(request, title)
2976 build = request.adapter.build
2977 from_url = build(self.revision, {'title': title, 'rev': from_rev})
2978 to_url = build(self.revision, {'title': title, 'rev': to_rev})
2979 a = werkzeug.html.a
2980 links = {
2981 'link1': a(str(from_rev), href=from_url),
2982 'link2': a(str(to_rev), href=to_url),
2983 'link': a(werkzeug.html(title), href=request.get_url(title)),
2984 }
2985 message = werkzeug.html(_(
2986 u'Differences between revisions %(link1)s and %(link2)s '
2987 u'of page %(link)s.')) % links
2988 diff_content = getattr(page, 'diff_content', None)
2989 if diff_content:
2990 from_text = self.storage.revision_text(page.title, from_rev)
2991 to_text = self.storage.revision_text(page.title, to_rev)
2992 content = page.diff_content(from_text, to_text, message)
2993 else:
2994 content = [werkzeug.html.p(werkzeug.html(
2995 _(u"Diff not available for this kind of pages.")))]
2996 special_title = _(u'Diff for "%(title)s"') % {'title': title}
2997 html = page.render_content(content, special_title)
2998 response = werkzeug.Response(html, mimetype='text/html')
2999 return response
3002 def all_pages(self, request):
3003 """Show index of all pages in the wiki."""
3005 page = self.get_page(request, '')
3006 all_pages = sorted(self.storage.all_pages())
3007 content = page.pages_list(all_pages, _(u'Index of all pages'))
3008 html = page.render_content(content, _(u'Page Index'))
3009 response = WikiResponse(html, mimetype='text/html')
3010 response.set_etag('/+index/%d' % self.storage.repo_revision())
3011 response.make_conditional(request)
3012 return response
3014 def orphaned(self, request):
3015 """Show all pages that don't have backlinks."""
3017 page = self.get_page(request, '')
3018 pages = self.index.orphaned_pages()
3019 content = page.pages_list(pages,
3020 _(u'List of pages with no links to them'))
3021 html = page.render_content(content, _(u'Orphaned pages'))
3022 response = WikiResponse(html, mimetype='text/html')
3023 response.set_etag('/+orphaned/%d' % self.storage.repo_revision())
3024 response.make_conditional(request)
3025 return response
3027 def wanted(self, request):
3028 """Show all pages that don't exist yet, but are linked."""
3030 def wanted_pages_list(page):
3031 """Generate the content of wanted pages page."""
3033 h = werkzeug.html
3034 yield h.p(h(
3035 _(u"List of pages that are linked to, but don't exist yet.")))
3036 yield u'<ol class="wanted">'
3037 for refs, title in self.index.wanted_pages():
3038 url = page.get_url(title, self.backlinks)
3039 yield h.li(h.b(page.wiki_link(title)),
3040 h.i(u' (', h.a(h(_(u"%d references") % refs),
3041 href=url, class_="backlinks"), ')'))
3042 yield u'</ol>'
3044 page = self.get_page(request, '')
3045 content = wanted_pages_list(page)
3046 html = page.render_content(content, _(u'Wanted pages'))
3047 response = WikiResponse(html, mimetype='text/html')
3048 response.set_etag('/+wanted/%d' % self.storage.repo_revision())
3049 response.make_conditional(request)
3050 return response
3052 def search(self, request):
3053 """Serve the search results page."""
3055 def search_snippet(title, words):
3056 """Extract a snippet of text for search results."""
3058 try:
3059 text = self.storage.page_text(title)
3060 except werkzeug.exceptions.NotFound:
3061 return u''
3062 regexp = re.compile(u"|".join(re.escape(w) for w in words),
3063 re.U|re.I)
3064 match = regexp.search(text)
3065 if match is None:
3066 return u""
3067 position = match.start()
3068 min_pos = max(position - 60, 0)
3069 max_pos = min(position + 60, len(text))
3070 snippet = werkzeug.escape(text[min_pos:max_pos])
3071 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
3072 html = regexp.sub(highlighted, snippet)
3073 return html
3075 def page_search(words, page, request):
3076 """Display the search results."""
3078 h = werkzeug.html
3079 self.storage.reopen()
3080 self.index.update(self, request)
3081 result = sorted(self.index.find(words), key=lambda x:-x[0])
3082 yield werkzeug.html.p(h(_(u'%d page(s) containing all words:')
3083 % len(result)))
3084 yield u'<ol class="search">'
3085 for number, (score, title) in enumerate(result):
3086 yield h.li(h.b(page.wiki_link(title)), u' ', h.i(str(score)),
3087 h.div(search_snippet(title, words),
3088 _class="snippet"),
3089 id_="search-%d" % (number+1))
3090 yield u'</ol>'
3092 query = request.values.get('q', u'').strip()
3093 page = self.get_page(request, '')
3094 if not query:
3095 url = request.get_url(view=self.all_pages, external=True)
3096 return werkzeug.routing.redirect(url, code=303)
3097 words = tuple(self.index.split_text(query, stop=False))
3098 if not words:
3099 words = (query,)
3100 title = _(u'Searching for "%s"') % u" ".join(words)
3101 content = page_search(words, page, request)
3102 html = page.render_content(content, title)
3103 return WikiResponse(html, mimetype='text/html')
3105 def backlinks(self, request, title):
3106 """Serve the page with backlinks."""
3108 self.storage.reopen()
3109 self.index.update(self, request)
3110 page = self.get_page(request, title)
3111 message = _(u'Pages that contain a link to %(link)s.')
3112 link = page.wiki_link(title)
3113 pages = self.index.page_backlinks(title)
3114 content = page.pages_list(pages, message, link, _class='backlinks')
3115 html = page.render_content(content, _(u'Links to "%s"') % title)
3116 response = WikiResponse(html, mimetype='text/html')
3117 response.set_etag('/+search/%d' % self.storage.repo_revision())
3118 response.make_conditional(request)
3119 return response
3121 def _serve_default(self, request, title, content, mime):
3122 """Some pages have their default content."""
3124 if title in self.storage:
3125 return self.download(request, title)
3126 response = werkzeug.Response(content, mimetype=mime)
3127 response.set_etag('/%s/-1' % title)
3128 response.make_conditional(request)
3129 return response
3131 def scripts_js(self, request):
3132 """Server the default scripts"""
3134 return self._serve_default(request, 'scripts.js', self.scripts,
3135 'text/javascript')
3137 def style_css(self, request):
3138 """Serve the default style"""
3140 return self._serve_default(request, 'style.css', self.style,
3141 'text/css')
3142 def pygments_css(self, request):
3143 """Serve the default pygments style"""
3145 if pygments is None:
3146 raise werkzeug.exceptions.NotFound()
3148 pygments_style = self.pygments_style
3149 if pygments_style not in pygments.styles.STYLE_MAP:
3150 pygments_style = 'default'
3151 formatter = pygments.formatters.HtmlFormatter(style=pygments_style)
3152 style_defs = formatter.get_style_defs('.highlight')
3153 return self._serve_default(request, 'pygments.css', style_defs,
3154 'text/css')
3156 def favicon_ico(self, request):
3157 """Serve the default favicon."""
3159 return self._serve_default(request, 'favicon.ico', self.icon,
3160 'image/x-icon')
3162 def robots_txt(self, request):
3163 """Serve the robots directives."""
3165 robots = ('User-agent: *\r\n'
3166 'Disallow: /+*\r\n'
3167 'Disallow: /%2B*\r\n'
3168 'Disallow: /+edit\r\n'
3169 'Disallow: /+feed\r\n'
3170 'Disallow: /+history\r\n'
3171 'Disallow: /+search\r\n'
3172 'Disallow: /+hg\r\n'
3173 )
3174 return self._serve_default(request, 'robots.txt', robots,
3175 'text/plain')
3177 def hgweb(self, request, path=None):
3178 """Serve the pages repository on the web like a normal hg repository."""
3180 if not self.config.get_bool('hgweb', False):
3181 raise werkzeug.exceptions.Forbidden('Repository access disabled.')
3182 app = mercurial.hgweb.request.wsgiapplication(
3183 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
3184 def hg_app(env, start):
3185 env = request.environ
3186 prefix='/+hg'
3187 if env['PATH_INFO'].startswith(prefix):
3188 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
3189 env["SCRIPT_NAME"] += prefix
3190 return app(env, start)
3191 return hg_app
3193 def die(self, request):
3194 """Terminate the standalone server if invoked from localhost."""
3196 if not request.remote_addr.startswith('127.'):
3197 raise werkzeug.exceptions.Forbidden()
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()
