Hatta Numbered Lists Branch

view hatta.py @ 742:d6e380970ba0

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