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