Hatta Numbered Lists Branch

view hatta.py @ 741:7a31aaea2d2f

Cascading CSS, proof of concept
author Ben
date Thu Dec 24 22:30:04 2009 +0100 (2009-12-24)
parents 020f6d49a9f5
children d6e380970ba0
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 if 'tango' in pygments.styles.STYLE_MAP:
2023 style = 'tango'
2024 else:
2025 style = 'friendly'
2026 formatter = pygments.formatters.HtmlFormatter(style=style)
2027 formatter.line_no = line_no
2029 def wrapper(source, outfile):
2030 """Wrap each line of formatted output."""
2032 yield 0, '<div class="highlight"><pre>'
2033 for lineno, line in source:
2034 if line.strip():
2035 yield (lineno,
2036 werkzeug.html.div(line.strip('\n'), id_="line_%d" %
2037 formatter.line_no))
2038 else:
2039 yield (lineno,
2040 werkzeug.html.div('&nbsp;', id_="line_%d" %
2041 formatter.line_no))
2042 formatter.line_no += 1
2043 yield 0, '</pre></div>'
2045 formatter.wrap = wrapper
2046 try:
2047 if mime:
2048 lexer = pygments.lexers.get_lexer_for_mimetype(mime)
2049 elif syntax:
2050 lexer = pygments.lexers.get_lexer_by_name(syntax)
2051 else:
2052 lexer = pygments.lexers.guess_lexer(text)
2053 except pygments.util.ClassNotFound:
2054 yield werkzeug.html.pre(werkzeug.html(text))
2055 return
2056 html = pygments.highlight(text, lexer, formatter)
2057 yield html
2059 class WikiPageWiki(WikiPageColorText):
2060 """Pages of with wiki markup use this for display."""
2062 def __init__(self, *args, **kw):
2063 super(WikiPageWiki, self).__init__(*args, **kw)
2064 if self.config.get_bool('wiki_words', False):
2065 self.parser = WikiWikiParser
2066 else:
2067 self.parser = WikiParser
2068 if self.config.get_bool('ignore_indent', False):
2069 try:
2070 del self.parser.block['indent']
2071 except KeyError:
2072 pass
2074 def extract_links(self, text=None):
2075 """Extract all links from the page."""
2077 if text is None:
2078 try:
2079 text = self.storage.page_text(self.title)
2080 except werkzeug.exceptions.NotFound:
2081 text = u''
2082 return self.parser.extract_links(text)
2084 def view_content(self, lines=None):
2085 if lines is None:
2086 f = self.storage.open_page(self.title)
2087 lines = self.storage.page_lines(f)
2088 if self.wiki.icon_page and self.wiki.icon_page in self.storage:
2089 icons = self.index.page_links_and_labels(self.wiki.icon_page)
2090 smilies = dict((emo, link) for (link, emo) in icons)
2091 else:
2092 smilies = None
2093 content = self.parser(lines, self.wiki_link, self.wiki_image,
2094 self.highlight, self.wiki_math, smilies)
2095 return content
2097 def wiki_math(self, math):
2098 math_url = self.config.get('math_url',
2099 'http://www.mathtran.org/cgi-bin/mathtran?tex=')
2100 if '%s' in math_url:
2101 url = math_url % werkzeug.url_quote(math)
2102 else:
2103 url = '%s%s' % (math_url, werkzeug.url_quote(math))
2104 label = werkzeug.escape(math, quote=True)
2105 return werkzeug.html.img(src=url, alt=label, class_="math")
2107 def dependencies(self):
2108 dependencies = WikiPage.dependencies(self)
2109 for title in [self.wiki.icon_page]:
2110 if title in self.storage:
2111 inode, size, mtime = self.storage.page_file_meta(title)
2112 etag = '%s/%d-%d' % (werkzeug.url_quote(title), inode, mtime)
2113 dependencies.add(etag)
2114 for link in self.index.page_links(self.title):
2115 if link not in self.storage:
2116 dependencies.add(werkzeug.url_quote(link))
2117 return dependencies
2119 class WikiPageFile(WikiPage):
2120 """Pages of all other mime types use this for display."""
2122 def view_content(self, lines=None):
2123 if self.title not in self.storage:
2124 raise werkzeug.exceptions.NotFound()
2125 content = ['<p>Download <a href="%s">%s</a> as <i>%s</i>.</p>' %
2126 (self.request.get_download_url(self.title),
2127 werkzeug.escape(self.title), self.mime)]
2128 return content
2130 def editor_form(self, preview=None):
2131 author = self.request.get_author()
2132 if self.title in self.storage:
2133 comment = _(u'changed')
2134 (rev, old_date, old_author,
2135 old_comment) = self.storage.page_meta(self.title)
2136 if old_author == author:
2137 comment = old_comment
2138 else:
2139 comment = _(u'uploaded')
2140 rev = -1
2141 html = werkzeug.html
2142 yield html.p(html(
2143 _(u"This is a binary file, it can't be edited on a wiki. "
2144 u"Please upload a new version instead.")))
2145 yield html.form(html.div(
2146 html.div(html.input(type_="file", name="data"), class_="upload"),
2147 html.input(type_="hidden", name="parent", value=rev),
2148 html.label(html(_(u'Comment')), html.input(name="comment",
2149 value=comment)),
2150 html.label(html(_(u'Author')), html.input(name="author",
2151 value=author)),
2152 html.div(html.input(type_="submit", name="save", value=_(u'Save')),
2153 html.input(type_="submit", name="cancel",
2154 value=_(u'Cancel')),
2155 class_="buttons")), action="", method="POST", class_="editor",
2156 enctype="multipart/form-data")
2158 class WikiPageImage(WikiPageFile):
2159 """Pages of mime type image/* use this for display."""
2161 render_file = '128x128.png'
2163 def view_content(self, lines=None):
2164 if self.title not in self.storage:
2165 raise werkzeug.exceptions.NotFound()
2166 content = ['<img src="%s" alt="%s">'
2167 % (self.request.get_url(self.title, self.wiki.render),
2168 werkzeug.escape(self.title))]
2169 return content
2171 def render_mime(self):
2172 """Give the filename and mime type of the rendered thumbnail."""
2174 if not Image:
2175 raise NotImplementedError('No Image library available')
2176 return self.render_file, 'image/png'
2178 def render_cache(self, cache_dir):
2179 """Render the thumbnail and save in the cache."""
2181 if not Image:
2182 raise NotImplementedError('No Image library available')
2183 page_file = self.storage.open_page(self.title)
2184 cache_path = os.path.join(cache_dir, self.render_file)
2185 cache_file = open(cache_path, 'wb')
2186 try:
2187 im = Image.open(page_file)
2188 im = im.convert('RGBA')
2189 im.thumbnail((128, 128), Image.ANTIALIAS)
2190 im.save(cache_file,'PNG')
2191 except IOError:
2192 raise werkzeug.exceptions.UnsupportedMediaType('Image corrupted')
2193 cache_file.close()
2194 return cache_path
2196 class WikiPageCSV(WikiPageFile):
2197 """Display class for type text/csv."""
2199 def content_iter(self, lines=None):
2200 import csv
2201 # XXX Add preview support
2202 csv_file = self.storage.open_page(self.title)
2203 reader = csv.reader(csv_file)
2204 html_title = werkzeug.escape(self.title, quote=True)
2205 yield u'<table id="%s" class="csvfile">' % html_title
2206 try:
2207 for row in reader:
2208 yield u'<tr>%s</tr>' % (u''.join(u'<td>%s</td>' % cell
2209 for cell in row))
2210 except csv.Error, e:
2211 yield u'</table>'
2212 yield _(u'<p class="error">Error parsing CSV file %s on line %d: %s'
2213 % (html_title, reader.line_num, e))
2214 finally:
2215 csv_file.close()
2216 yield u'</table>'
2218 def view_content(self, lines=None):
2219 if self.title not in self.storage:
2220 raise werkzeug.exceptions.NotFound()
2221 return self.content_iter(lines)
2223 class WikiPageRST(WikiPageText):
2224 """
2225 Display ReStructured Text.
2226 """
2228 def content_iter(self, lines):
2229 try:
2230 from docutils.core import publish_parts
2231 except ImportError:
2232 return super(WikiPageRST, self).content_iter(lines)
2233 text = ''.join(lines)
2234 SAFE_DOCUTILS = dict(file_insertion_enabled=False, raw_enabled=False)
2235 content = publish_parts(text, writer_name='html',
2236 settings_overrides=SAFE_DOCUTILS)['html_body']
2237 return [content]
2240 class WikiPageBugs(WikiPageText):
2241 """
2242 Display class for type text/x-bugs
2243 Parse the ISSUES file in (roughly) format used by ciss
2244 """
2246 def content_iter(self, lines):
2247 last_lines = []
2248 in_header = False
2249 in_bug = False
2250 attributes = {}
2251 title = None
2252 for line_no, line in enumerate(lines):
2253 if last_lines and line.startswith('----'):
2254 title = ''.join(last_lines)
2255 last_lines = []
2256 in_header = True
2257 attributes = {}
2258 elif in_header and ':' in line:
2259 attribute, value = line.split(':', 1)
2260 attributes[attribute.strip()] = value.strip()
2261 else:
2262 if in_header:
2263 if in_bug:
2264 yield '</div>'
2265 tags = [tag.strip() for tag in
2266 attributes.get('tags', '').split()
2267 if tag.strip()]
2268 yield '<div id="line_%d">' % (line_no)
2269 in_bug = True
2270 if title:
2271 yield werkzeug.html.h2(werkzeug.html(title))
2272 if attributes:
2273 yield '<dl>'
2274 for attribute, value in attributes.iteritems():
2275 yield werkzeug.html.dt(werkzeug.html(attribute))
2276 yield werkzeug.html.dd(werkzeug.html(value))
2277 yield '</dl>'
2278 in_header = False
2279 if not line.strip():
2280 if last_lines:
2281 if last_lines[0][0] in ' \t':
2282 yield werkzeug.html.pre(werkzeug.html(
2283 ''.join(last_lines)))
2284 else:
2285 yield werkzeug.html.p(werkzeug.html(
2286 ''.join(last_lines)))
2287 last_lines = []
2288 else:
2289 last_lines.append(line)
2290 if last_lines:
2291 if last_lines[0][0] in ' \t':
2292 yield werkzeug.html.pre(werkzeug.html(
2293 ''.join(last_lines)))
2294 else:
2295 yield werkzeug.html.p(werkzeug.html(
2296 ''.join(last_lines)))
2297 if in_bug:
2298 yield '</div>'
2301 class WikiTitleConverter(werkzeug.routing.PathConverter):
2302 """Behaves like the path converter, except that it escapes slashes."""
2304 def to_url(self, value):
2305 return werkzeug.url_quote(value.strip(), self.map.charset, safe="")
2307 regex='([^+%]|%[^2]|%2[^Bb]).*'
2309 class WikiAllConverter(werkzeug.routing.BaseConverter):
2310 """Matches everything."""
2312 regex='.*'
2315 class Wiki(object):
2316 """
2317 The main class of the wiki, handling initialization of the whole
2318 application and most of the logic.
2319 """
2320 storage_class = WikiStorage
2321 index_class = WikiSearch
2322 filename_map = {
2323 'README': (WikiPageText, 'text/plain'),
2324 'ISSUES': (WikiPageBugs, 'text/x-bugs'),
2325 'ISSUES.txt': (WikiPageBugs, 'text/x-bugs'),
2326 'COPYING': (WikiPageText, 'text/plain'),
2327 'CHANGES': (WikiPageText, 'text/plain'),
2328 'MANIFEST': (WikiPageText, 'text/plain'),
2329 'favicon.ico': (WikiPageImage, 'image/x-icon'),
2331 mime_map = {
2332 'text': WikiPageText,
2333 'application/x-javascript': WikiPageText,
2334 'application/x-python': WikiPageText,
2335 'text/css' : WikiPageColorText,
2336 'text/csv': WikiPageCSV,
2337 'text/x-rst': WikiPageRST,
2338 'text/x-wiki': WikiPageWiki,
2339 'image': WikiPageImage,
2340 '': WikiPageFile,
2342 icon = base64.b64decode(
2343 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhki'
2344 'AAAAAlwSFlzAAAEnQAABJ0BfDRroQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBo'
2345 'AAALWSURBVDiNbdNLaFxlFMDx//fd19x5JdNJm0lIImPaYm2MfSUggrssXBVaChUfi1JwpQtxK7gqu'
2346 'LMbQQQ3bipU0G3Rgg98DBpraWob00kzM6Z5TF7tdObm3vvd46K0TBo/OLtzfnychxIRut+Zo2/19vT'
2347 'kLxXze6biONbGJMRipL39MJyt33rvp+rVT7rzVTfw2vFzLxwcLf/V7oSq1W4hACIkIigUtnaoNecXG'
2348 '2u14T8blQRAd2v7yyN/RLFR6IRM1iedSeFnUvhpDydlI9ow0lcedG3348c1djeQz+WcThjgYZMgGBG'
2349 'SJMEYgzGGODLEoTBYGH4DeHcXoDSSzaRVogQjyaMwhtgYcoUco+Nl5qbnubFw7fr//uB2tXp78uj4c'
2350 '0YJsSTESUxsDCemjjH6YhnbtbA8xaVv7n/0uGZHDx48aH8+17iLJQrf9vCdFL7tkcn7/Pb7r8zdmWP'
2351 '2zqwopa7sAl4/cV4NlvrPbgch7aBN1vUIOw9ZWmmw2dqkb18fQSegOrOgfD9zahfQ37/3su+ljj1T6'
2352 'uCnAyxtoZVGa41tWSilULWfCZdaPD986MsjQxOHdwC9PdmT2tLk0oozpxfYf2SZwp4Iz1X4UZWBe1+'
2353 'z9+5X+OkiruWpYr744ZMmvjn5dvrwoVHLdRzWtobY2Kwx9soyz5ZXuV9fQ5pXCBabXKuXcBwbYwxYe'
2354 'kIppTXAF5VP2xutrVYmm8bzM1z9foSZik1z1SWMNLW1AtMrB/gnnMJxbSxbUV2a/QHQT8Y4c+vvC8V'
2355 'C74VCoZcodvnxux5Msg+THCSKHy2R48YgIb/crITrreZlEYl33MKrYycvvnx88p2BUkkpRyGSEBmDi'
2356 'WI6QcC95UUqM9PBzdqN99fbzc9EJNwBKKUoFw+8NDY8/sFQ/8CE57l5pZRdX6kHqxurW43mv98urM9'
2357 'fjJPouohE8NQ1dkEayAJ5wAe2gRawJSKmO/c/aERMn5m9/ksAAAAASUVORK5CYII=')
2358 scripts = r"""function hatta_dates(){var a=document.getElementsByTagName(
2359 'abbr');var p=function(i){return('00'+i).slice(-2)};for(var i=0;i<a.length;++i)
2360 {var n=a[i];if(n.className==='date'){var m=
2361 /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$/.exec(
2362 n.getAttribute('title'));var d=new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],
2363 +m[5],+m[6]));if(d){var b=-d.getTimezoneOffset()/60;if(b>=0){b="+"+b}
2364 n.textContent=""+d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+
2365 p(d.getHours())+":"+p(d.getMinutes())+" GMT"+b}}}}function hatta_edit(){var b=
2366 document.getElementById('editortext');if(b){var c=0+
2367 document.location.hash.substring(1);var d=b.textContent.match(/(.*\n)/g);var
2368 f='';for(var i=0;i<d.length&&i<c;++i){f+=d[i]}b.focus();if(b.setSelectionRange)
2369 {b.setSelectionRange(f.length,f.length)}else if(b.createTextRange){var g=
2370 b.createTextRange();g.collapse(true);g.moveEnd('character',f.length);
2371 g.moveStart('character',f.length);g.select()}var h=document.createElement('pre'
2372 );b.parentNode.appendChild(h);var k=window.getComputedStyle(b,'');h.style.font=
2373 k.font;h.style.border=k.border;h.style.outline=k.outline;h.style.lineHeight=
2374 k.lineHeight;h.style.letterSpacing=k.letterSpacing;h.style.fontFamily=
2375 k.fontFamily;h.style.fontSize=k.fontSize;h.style.padding=0;h.style.overflow=
2376 'scroll';try{h.style.whiteSpace="-moz-pre-wrap"}catch(e){};try{
2377 h.style.whiteSpace="-o-pre-wrap"}catch(e){};try{h.style.whiteSpace="-pre-wrap"
2378 }catch(e){};try{h.style.whiteSpace="pre-wrap"}catch(e){};h.textContent=f;
2379 b.scrollTop=h.scrollHeight;h.parentNode.removeChild(h)}else{var l='';var m=
2380 document.getElementsByTagName('link');for(var i=0;i<m.length;++i){var n=m[i];
2381 if(n.getAttribute('type')==='application/wiki'){l=n.getAttribute('href')}}if(
2382 l===''){return}var o=['p','h1','h2','h3','h4','h5','h6','pre','ul','div'];for(
2383 var j=0;j<o.length;++j){var m=document.getElementsByTagName(o[j]);for(var i=0;
2384 i<m.length;++i){var n=m[i];if(n.id&&n.id.match(/^line_\d+$/)){n.ondblclick=
2385 function(){var a=l+'#'+this.id.replace('line_','');document.location.href=a
2386 }}}}}}window.onload=function(){hatta_dates();hatta_edit()}"""
2387 style = """\
2388 html { background: #fff; color: #2e3436;
2389 font-family: sans-serif; font-size: 96% }
2390 body { margin: 1em auto; line-height: 1.3; width: 40em }
2391 a { color: #3465a4; text-decoration: none }
2392 a:hover { text-decoration: underline }
2393 a.wiki:visited { color: #204a87 }
2394 a.nonexistent, a.nonexistent:visited { color: #a40000; }
2395 a.external { color: #3465a4; text-decoration: underline }
2396 a.external:visited { color: #75507b }
2397 a img { border: none }
2398 img.math, img.smiley { vertical-align: middle }
2399 pre { font-size: 100%; white-space: pre-wrap; word-wrap: break-word;
2400 white-space: -moz-pre-wrap; white-space: -pre-wrap;
2401 white-space: -o-pre-wrap; line-height: 1.2; color: #555753 }
2402 div.conflict pre.local { background: #fcaf3e; margin-bottom: 0; color: 000}
2403 div.conflict pre.other { background: #ffdd66; margin-top: 0; color: 000; border-top: #d80 dashed 1px; }
2404 pre.diff div.orig { font-size: 75%; color: #babdb6 }
2405 b.highlight, pre.diff ins { font-weight: bold; background: #fcaf3e;
2406 color: #ce5c00; text-decoration: none }
2407 pre.diff del { background: #eeeeec; color: #888a85; text-decoration: none }
2408 pre.diff div.change { border-left: 2px solid #fcaf3e }
2409 div.footer { border-top: solid 1px #babdb6; text-align: right }
2410 h1, h2, h3, h4 { color: #babdb6; font-weight: normal; letter-spacing: 0.125em}
2411 div.buttons { text-align: center }
2412 input.button, div.buttons input { font-weight: bold; font-size: 100%;
2413 background: #eee; border: solid 1px #babdb6; margin: 0.25em; color: #888a85}
2414 .history input.button { font-size: 75% }
2415 .editor textarea { width: 100%; display: block; font-size: 100%;
2416 border: solid 1px #babdb6; }
2417 .editor label { display:block; text-align: right }
2418 .editor .upload { margin: 2em auto; text-align: center }
2419 form.search input.search, .editor label input { font-size: 100%;
2420 border: solid 1px #babdb6; margin: 0.125em 0 }
2421 .editor label.comment input { width: 32em }
2422 a.logo { float: left; display: block; margin: 0.25em }
2423 div.header h1 { margin: 0; }
2424 div.content { clear: left }
2425 form.search { margin:0; text-align: right; font-size: 80% }
2426 div.snippet { font-size: 80%; color: #888a85 }
2427 div.header div.menu { float: right; margin-top: 1.25em }
2428 div.header div.menu a.current { color: #000 }
2429 hr { background: transparent; border:none; height: 0;
2430 border-bottom: 1px solid #babdb6; clear: both }
2431 blockquote { border-left:.25em solid #ccc; padding-left:.5em; margin-left:0}
2432 abbr.date {border:none}
2433 dt {font-weight: bold; float: left; }
2434 dd {font-style: italic; }
2435 """
2437 def __init__(self, config):
2438 if config.get_bool('show_version', False):
2439 sys.stdout.write("Hatta %s\n" % __version__)
2440 sys.exit()
2441 if pygments is not None:
2442 if 'tango' in pygments.styles.STYLE_MAP:
2443 style = 'tango'
2444 else:
2445 style = 'friendly'
2446 formatter = pygments.formatters.HtmlFormatter(style=style)
2447 self.style += formatter.get_style_defs('.highlight')
2448 self.dead = False
2449 self.config = config
2450 self.language = config.get('language', None)
2451 global _
2452 if self.language is not None:
2453 try:
2454 _ = gettext.translation('hatta', 'locale',
2455 languages=[self.language]).ugettext
2456 except IOError:
2457 _ = gettext.translation('hatta', fallback=True,
2458 languages=[self.language]).ugettext
2459 else:
2460 _ = gettext.translation('hatta', fallback=True).ugettext
2461 self.path = os.path.abspath(config.get('pages_path', 'docs'))
2462 self.cache = os.path.abspath(config.get('cache_path', 'cache'))
2463 self.page_charset = config.get('page_charset', 'utf-8')
2464 self.menu_page = self.config.get('menu_page', u'Menu')
2465 self.front_page = self.config.get('front_page', u'Home')
2466 self.logo_page = self.config.get('logo_page', u'logo.png')
2467 self.locked_page = self.config.get('locked_page', u'Locked')
2468 self.site_name = self.config.get('site_name', u'Hatta Wiki')
2469 self.read_only = self.config.get_bool('read_only', False)
2470 self.icon_page = self.config.get('icon_page', None)
2472 self.storage = self.storage_class(self.path, self.page_charset)
2473 if not os.path.isdir(self.cache):
2474 os.makedirs(self.cache)
2475 reindex = True
2476 else:
2477 reindex = False
2478 self.index = self.index_class(self.cache, self.language, self.storage)
2479 R = werkzeug.routing.Rule
2480 self.url_map = werkzeug.routing.Map([
2481 R('/', defaults={'title': self.front_page},
2482 endpoint=self.view, methods=['GET', 'HEAD']),
2483 R('/+edit/<title:title>', endpoint=self.edit, methods=['GET']),
2484 R('/+edit/<title:title>', endpoint=self.save, methods=['POST']),
2485 R('/+undo/<title:title>', endpoint=self.undo, methods=['POST']),
2486 R('/+history/', endpoint=self.recent_changes,
2487 methods=['GET', 'HEAD']),
2488 R('/+history/<title:title>/<int:from_rev>:<int:to_rev>',
2489 endpoint=self.diff, methods=['GET', 'HEAD']),
2490 R('/+history/<title:title>/<int:rev>', endpoint=self.revision,
2491 methods=['GET', 'HEAD']),
2492 R('/+history/<title:title>', endpoint=self.history,
2493 methods=['GET', 'HEAD']),
2494 R('/+version/', endpoint=self.version, methods=['GET', 'HEAD']),
2495 R('/+version/<title:title>', endpoint=self.version,
2496 methods=['GET', 'HEAD']),
2497 R('/+download/<title:title>', endpoint=self.download,
2498 methods=['GET', 'HEAD']),
2499 R('/+render/<title:title>', endpoint=self.render,
2500 methods=['GET', 'HEAD']),
2501 R('/<title:title>', endpoint=self.view, methods=['GET', 'HEAD']),
2502 R('/+feed/rss', endpoint=self.rss, methods=['GET', 'HEAD']),
2503 R('/+feed/atom', endpoint=self.atom, methods=['GET', 'HEAD']),
2504 R('/+index', endpoint=self.all_pages, methods=['GET', 'HEAD']),
2505 R('/+orphaned', endpoint=self.orphaned, methods=['GET', 'HEAD']),
2506 R('/+wanted', endpoint=self.wanted, methods=['GET', 'HEAD']),
2507 R('/+search', endpoint=self.search, methods=['GET', 'POST']),
2508 R('/+search/<title:title>', endpoint=self.backlinks,
2509 methods=['GET', 'POST']),
2510 R('/off-with-his-head', endpoint=self.die, methods=['GET']),
2511 R('/+hg<all:path>', endpoint=self.hgweb, strict_slashes=False,
2512 methods=['GET', 'POST', 'HEAD']),
2513 R('/+style', endpoint=self.default_css, methods=['GET', 'HEAD']),
2514 # Pages with default content
2515 R('/favicon.ico', endpoint=self.favicon_ico,
2516 methods=['GET', 'HEAD']),
2517 R('/robots.txt', endpoint=self.robots_txt, methods=['GET', 'HEAD']),
2518 R('/+download/style.css', endpoint=self.style_css,
2519 methods=['GET', 'HEAD']),
2520 R('/+download/scripts.js', endpoint=self.scripts_js,
2521 methods=['GET', 'HEAD']),
2522 ], converters={'title':WikiTitleConverter, 'all':WikiAllConverter})
2524 def get_page(self, request, title):
2525 """Creates a page object based on page's mime type"""
2527 if title:
2528 try:
2529 page_class, mime = self.filename_map[title]
2530 except KeyError:
2531 mime = self.storage.page_mime(title)
2532 major, minor = mime.split('/', 1)
2533 try:
2534 page_class = self.mime_map[mime]
2535 except KeyError:
2536 try:
2537 plus_pos = minor.find('+')
2538 if plus_pos>0:
2539 minor_base = minor[plus_pos:]
2540 else:
2541 minor_base = ''
2542 base_mime = '/'.join([major, minor_base])
2543 page_class = self.mime_map[base_mime]
2544 except KeyError:
2545 try:
2546 page_class = self.mime_map[major]
2547 except KeyError:
2548 page_class = self.mime_map['']
2549 else:
2550 page_class = WikiPage
2551 mime = ''
2552 return page_class(self, request, title, mime)
2554 def view(self, request, title):
2555 page = self.get_page(request, title)
2556 try:
2557 content = page.view_content()
2558 except werkzeug.exceptions.NotFound:
2559 url = request.get_url(title, self.edit, external=True)
2560 return werkzeug.routing.redirect(url, code=303)
2561 html = page.render_content(content)
2562 dependencies = page.dependencies()
2563 etag = '/(%s)' % u','.join(dependencies)
2564 return self.response(request, title, html, etag=etag)
2566 def revision(self, request, title, rev):
2567 text = self.storage.revision_text(title, rev)
2568 link = werkzeug.html.a(werkzeug.html(title),
2569 href=request.get_url(title))
2570 content = [
2571 werkzeug.html.p(
2572 werkzeug.html(
2573 _(u'Content of revision %(rev)d of page %(title)s:'))
2574 % {'rev': rev, 'title': link }),
2575 werkzeug.html.pre(werkzeug.html(text)),
2577 special_title = _(u'Revision of "%(title)s"') % {'title': title}
2578 page = self.get_page(request, title)
2579 html = page.render_content(content, special_title)
2580 response = self.response(request, title, html, rev=rev, etag='/old')
2581 return response
2583 def version(self, request, title=None):
2584 if title is None:
2585 version = self.storage.repo_revision()
2586 else:
2587 try:
2588 version, x, x, x = self.storage.page_history(title).next()
2589 except StopIteration:
2590 version = 0
2591 return werkzeug.Response('%d' % version, mimetype="text/plain")
2593 def _check_lock(self, title):
2594 restricted_pages = [
2595 'scripts.js',
2596 'robots.txt',
2598 if self.read_only:
2599 raise werkzeug.exceptions.Forbidden(_("This site is read-only."))
2600 if title in restricted_pages:
2601 raise werkzeug.exceptions.Forbidden(_("""Can't edit this page.
2602 It can only be edited by the site admin directly on the disk."""))
2603 if title in self.index.page_links(self.locked_page):
2604 raise werkzeug.exceptions.Forbidden(_("This page is locked."))
2606 def save(self, request, title):
2607 self._check_lock(title)
2608 url = request.get_url(title)
2609 if request.form.get('cancel'):
2610 if title not in self.storage:
2611 url = request.get_url(self.front_page)
2612 if request.form.get('preview'):
2613 text = request.form.get("text")
2614 if text is not None:
2615 lines = text.split('\n')
2616 else:
2617 lines = [werkzeug.html.p(werkzeug.html(
2618 _(u'No preview for binaries.')))]
2619 return self.edit(request, title, preview=lines)
2620 elif request.form.get('save'):
2621 comment = request.form.get("comment", "")
2622 author = request.get_author()
2623 text = request.form.get("text")
2624 try:
2625 parent = int(request.form.get("parent"))
2626 except (ValueError, TypeError):
2627 parent = None
2628 self.storage.reopen()
2629 self.index.update(self, request)
2630 page = self.get_page(request, title)
2631 if text is not None:
2632 if title == self.locked_page:
2633 for link, label in page.extract_links(text):
2634 if title == link:
2635 raise werkzeug.exceptions.Forbidden()
2636 if u'href="' in comment or u'http:' in comment:
2637 raise werkzeug.exceptions.Forbidden()
2638 if text.strip() == '':
2639 self.storage.delete_page(title, author, comment)
2640 url = request.get_url(self.front_page)
2641 else:
2642 self.storage.save_text(title, text, author, comment, parent)
2643 else:
2644 text = u''
2645 upload = request.files['data']
2646 f = upload.stream
2647 if f is not None and upload.filename is not None:
2648 try:
2649 self.storage.save_file(title, f.tmpname, author,
2650 comment, parent)
2651 except AttributeError:
2652 self.storage.save_data(title, f.read(), author,
2653 comment, parent)
2654 else:
2655 self.storage.delete_page(title, author, comment)
2656 url = request.get_url(self.front_page)
2657 self.index.update_page(page, title, text=text)
2658 response = werkzeug.routing.redirect(url, code=303)
2659 response.set_cookie('author',
2660 werkzeug.url_quote(request.get_author()),
2661 max_age=604800)
2662 return response
2664 def edit(self, request, title, preview=None):
2665 self._check_lock(title)
2666 exists = title in self.storage
2667 if exists:
2668 self.storage.reopen()
2669 page = self.get_page(request, title)
2670 content = page.editor_form(preview)
2671 special_title = _(u'Editing "%(title)s"') % {'title': title}
2672 html = page.render_content(content, special_title)
2673 if not exists:
2674 response = werkzeug.Response(html, mimetype="text/html",
2675 status='404 Not found')
2677 elif preview:
2678 response = werkzeug.Response(html, mimetype="text/html")
2679 else:
2680 response = self.response(request, title, html, '/edit')
2681 response.headers.add('Cache-Control', 'no-cache')
2682 return response
2684 def atom(self, request):
2685 date_format = "%Y-%m-%dT%H:%M:%SZ"
2686 first_date = datetime.datetime.now()
2687 now = first_date.strftime(date_format)
2688 body = []
2689 first_title = u''
2690 count = 0
2691 unique_titles = {}
2692 for title, rev, date, author, comment in self.storage.history():
2693 if title in unique_titles:
2694 continue
2695 unique_titles[title] = True
2696 count += 1
2697 if count > 10:
2698 break
2699 if not first_title:
2700 first_title = title
2701 first_rev = rev
2702 first_date = date
2703 item = u"""<entry>
2704 <title>%(title)s</title>
2705 <link href="%(page_url)s" />
2706 <content>%(comment)s</content>
2707 <updated>%(date)s</updated>
2708 <author>
2709 <name>%(author)s</name>
2710 <uri>%(author_url)s</uri>
2711 </author>
2712 <id>%(url)s</id>
2713 </entry>""" % {
2714 'title': werkzeug.escape(title),
2715 'page_url': request.adapter.build(self.view, {'title': title},
2716 force_external=True),
2717 'comment': werkzeug.escape(comment),
2718 'date': date.strftime(date_format),
2719 'author': werkzeug.escape(author),
2720 'author_url': request.adapter.build(self.view,
2721 {'title': author},
2722 force_external=True),
2723 'url': request.adapter.build(self.revision,
2724 {'title': title, 'rev': rev},
2725 force_external=True),
2727 body.append(item)
2728 content = u"""<?xml version="1.0" encoding="utf-8"?>
2729 <feed xmlns="http://www.w3.org/2005/Atom">
2730 <title>%(title)s</title>
2731 <link rel="self" href="%(atom)s"/>
2732 <link href="%(home)s"/>
2733 <id>%(home)s</id>
2734 <updated>%(date)s</updated>
2735 <logo>%(logo)s</logo>
2736 %(body)s
2737 </feed>""" % {
2738 'title': self.site_name,
2739 'home': request.adapter.build(self.view, force_external=True),
2740 'atom': request.adapter.build(self.atom, force_external=True),
2741 'date': first_date.strftime(date_format),
2742 'logo': request.adapter.build(self.download,
2743 {'title': self.logo_page},
2744 force_external=True),
2745 'body': u''.join(body),
2747 response = self.response(request, 'atom', content, '/atom',
2748 'application/xml', first_rev, first_date)
2749 response.set_etag('/atom/%d' % self.storage.repo_revision())
2750 response.make_conditional(request)
2751 return response
2753 def rss(self, request):
2754 """Serve an RSS feed of recent changes."""
2756 first_date = datetime.datetime.now()
2757 now = first_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
2758 rss_body = []
2759 first_title = u''
2760 count = 0
2761 unique_titles = {}
2762 for title, rev, date, author, comment in self.storage.history():
2763 if title in unique_titles:
2764 continue
2765 unique_titles[title] = True
2766 count += 1
2767 if count > 10:
2768 break
2769 if not first_title:
2770 first_title = title
2771 first_rev = rev
2772 first_date = date
2773 item = (u'<item><title>%s</title><link>%s</link>'
2774 u'<description>%s</description><pubDate>%s</pubDate>'
2775 u'<dc:creator>%s</dc:creator><guid>%s</guid></item>' % (
2776 werkzeug.escape(title),
2777 request.adapter.build(self.view, {'title': title},
2778 force_external=True),
2779 werkzeug.escape(comment),
2780 date.strftime("%a, %d %b %Y %H:%M:%S GMT"),
2781 werkzeug.escape(author),
2782 request.adapter.build(self.revision,
2783 {'title': title, 'rev': rev})
2784 ))
2785 rss_body.append(item)
2786 rss_head = u"""<?xml version="1.0" encoding="utf-8"?>
2787 <rss version="2.0"
2788 xmlns:dc="http://purl.org/dc/elements/1.1/"
2789 xmlns:atom="http://www.w3.org/2005/Atom"
2791 <channel>
2792 <title>%s</title>
2793 <atom:link href="%s" rel="self" type="application/rss+xml" />
2794 <link>%s</link>
2795 <description>%s</description>
2796 <generator>Hatta Wiki</generator>
2797 <language>en</language>
2798 <lastBuildDate>%s</lastBuildDate>
2800 """ % (
2801 werkzeug.escape(self.site_name),
2802 request.adapter.build(self.rss),
2803 request.adapter.build(self.recent_changes),
2804 werkzeug.escape(_(u'Track the most recent changes to the wiki '
2805 u'in this feed.')),
2806 first_date,
2808 content = [rss_head]+rss_body+[u'</channel></rss>']
2809 response = self.response(request, 'rss', content, '/rss',
2810 'application/xml', first_rev, first_date)
2811 response.set_etag('/rss/%d' % self.storage.repo_revision())
2812 response.make_conditional(request)
2813 return response
2815 def response(self, request, title, content, etag='', mime='text/html',
2816 rev=None, date=None, size=None):
2817 """Create a WikiResponse for a page."""
2819 response = WikiResponse(content, mimetype=mime)
2820 if rev is None:
2821 inode, _size, mtime = self.storage.page_file_meta(title)
2822 response.set_etag(u'%s/%s/%d-%d' % (etag, werkzeug.url_quote(title),
2823 inode, mtime))
2824 if size == -1:
2825 size = _size
2826 else:
2827 response.set_etag(u'%s/%s/%s' % (etag, werkzeug.url_quote(title),
2828 rev))
2829 if size:
2830 response.content_length = size
2831 response.make_conditional(request)
2832 return response
2834 def download(self, request, title):
2835 """Serve the raw content of a page."""
2837 mime = self.storage.page_mime(title)
2838 if mime == 'text/x-wiki':
2839 mime = 'text/plain'
2840 f = self.storage.open_page(title)
2841 response = self.response(request, title, f, '/download', mime, size=-1)
2842 return response
2844 def render(self, request, title):
2845 """Serve a thumbnail or otherwise rendered content."""
2847 def file_time_and_size(file_path):
2848 """Get file's modification timestamp and its size."""
2850 try:
2851 (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
2852 st_atime, st_mtime, st_ctime) = os.stat(file_path)
2853 except OSError:
2854 st_mtime = 0
2855 st_size = None
2856 return st_mtime, st_size
2858 def rm_temp_dir(dir_path):
2859 """Delete the directory with subdirectories."""
2861 for root, dirs, files in os.walk(dir_path, topdown=False):
2862 for name in files:
2863 try:
2864 os.remove(os.path.join(root, name))
2865 except OSError:
2866 pass
2867 for name in dirs:
2868 try:
2869 os.rmdir(os.path.join(root, name))
2870 except OSError:
2871 pass
2872 try:
2873 os.rmdir(dir_path)
2874 except OSError:
2875 pass
2877 page = self.get_page(request, title)
2878 try:
2879 cache_filename, cache_mime = page.render_mime()
2880 render = page.render_cache
2881 except (AttributeError, NotImplementedError):
2882 return self.download(request, title)
2884 cache_dir = os.path.join(self.cache, 'render',
2885 werkzeug.url_quote(title, safe=''))
2886 cache_file = os.path.join(cache_dir, cache_filename)
2887 page_inode, page_size, page_mtime = self.storage.page_file_meta(title)
2888 cache_mtime, cache_size = file_time_and_size(cache_file)
2889 if page_mtime > cache_mtime:
2890 if not os.path.exists(cache_dir):
2891 os.makedirs(cache_dir)
2892 try:
2893 temp_dir = tempfile.mkdtemp(dir=cache_dir)
2894 result_file = render(temp_dir)
2895 mercurial.util.rename(result_file, cache_file)
2896 finally:
2897 rm_temp_dir(temp_dir)
2898 f = open(cache_file)
2899 response = self.response(request, title, f, '/render', cache_mime,
2900 size=cache_size)
2901 return response
2903 def undo(self, request, title):
2904 """Revert a change to a page."""
2906 self._check_lock(title)
2907 rev = None
2908 for key in request.form:
2909 try:
2910 rev = int(key)
2911 except ValueError:
2912 pass
2913 author = request.get_author()
2914 if rev is not None:
2915 try:
2916 parent = int(request.form.get("parent"))
2917 except (ValueError, TypeError):
2918 parent = None
2919 self.storage.reopen()
2920 self.index.update(self, request)
2921 if rev == 0:
2922 comment = _(u'Delete page %(title)s') % {'title': title}
2923 data = ''
2924 self.storage.delete_page(title, author, comment)
2925 else:
2926 comment = _(u'Undo of change %(rev)d of page %(title)s') % {
2927 'rev': rev, 'title': title}
2928 data = self.storage.page_revision(title, rev-1)
2929 self.storage.save_data(title, data, author, comment, parent)
2930 page = self.get_page(request, title)
2931 self.index.update_page(page, title, data=data)
2932 url = request.adapter.build(self.history, {'title': title},
2933 method='GET', force_external=True)
2934 return werkzeug.redirect(url, 303)
2936 def history(self, request, title):
2937 """Display history of changes of a page."""
2939 page = self.get_page(request, title)
2940 content = page.render_content(page.history_list(),
2941 _(u'History of "%(title)s"') % {'title': title})
2942 response = self.response(request, title, content, '/history')
2943 return response
2946 def recent_changes(self, request):
2947 """Serve the recent changes page."""
2949 def changes_list(page):
2950 """Generate the content of the recent changes page."""
2952 h = werkzeug.html
2953 yield u'<ul>'
2954 last = {}
2955 lastrev = {}
2956 count = 0
2957 for title, rev, date, author, comment in self.storage.history():
2958 if (author, comment) == last.get(title, (None, None)):
2959 continue
2960 count += 1
2961 if count > 100:
2962 break
2963 if rev > 0:
2964 date_url = request.adapter.build(self.diff, {
2965 'title': title,
2966 'from_rev': rev-1,
2967 'to_rev': lastrev.get(title, rev)
2968 })
2969 elif rev == 0:
2970 date_url = request.adapter.build(self.revision, {
2971 'title': title, 'rev': rev})
2972 else:
2973 date_url = request.adapter.build(self.history, {
2974 'title': title})
2975 last[title] = author, comment
2976 lastrev[title] = rev
2978 yield h.li(h.a(page.date_html(date), href=date_url), ' ',
2979 h.b(page.wiki_link(title)), u' . . . . ',
2980 h.i(page.wiki_link(author)),
2981 h.div(h(comment), class_="comment")
2983 yield u'</ul>'
2985 page = self.get_page(request, '')
2986 content = page.render_content(changes_list(page), _(u'Recent changes'))
2987 response = WikiResponse(content, mimetype='text/html')
2988 response.set_etag('/history/%d' % self.storage.repo_revision())
2989 response.make_conditional(request)
2990 return response
2992 def diff(self, request, title, from_rev, to_rev):
2993 """Show the differences between specified revisions."""
2995 page = self.get_page(request, title)
2996 build = request.adapter.build
2997 from_url = build(self.revision, {'title': title, 'rev': from_rev})
2998 to_url = build(self.revision, {'title': title, 'rev': to_rev})
2999 a = werkzeug.html.a
3000 links = {
3001 'link1': a(str(from_rev), href=from_url),
3002 'link2': a(str(to_rev), href=to_url),
3003 'link': a(werkzeug.html(title), href=request.get_url(title)),
3005 message = werkzeug.html(_(
3006 u'Differences between revisions %(link1)s and %(link2)s '
3007 u'of page %(link)s.')) % links
3008 diff_content = getattr(page, 'diff_content', None)
3009 if diff_content:
3010 from_text = self.storage.revision_text(page.title, from_rev)
3011 to_text = self.storage.revision_text(page.title, to_rev)
3012 content = page.diff_content(from_text, to_text, message)
3013 else:
3014 content = [werkzeug.html.p(werkzeug.html(
3015 _(u"Diff not available for this kind of pages.")))]
3016 special_title = _(u'Diff for "%(title)s"') % {'title': title}
3017 html = page.render_content(content, special_title)
3018 response = werkzeug.Response(html, mimetype='text/html')
3019 return response
3022 def all_pages(self, request):
3023 """Show index of all pages in the wiki."""
3025 page = self.get_page(request, '')
3026 all_pages = sorted(self.storage.all_pages())
3027 content = page.pages_list(all_pages, _(u'Index of all pages'))
3028 html = page.render_content(content, _(u'Page Index'))
3029 response = WikiResponse(html, mimetype='text/html')
3030 response.set_etag('/+index/%d' % self.storage.repo_revision())
3031 response.make_conditional(request)
3032 return response
3034 def orphaned(self, request):
3035 """Show all pages that don't have backlinks."""
3037 page = self.get_page(request, '')
3038 pages = self.index.orphaned_pages()
3039 content = page.pages_list(pages,
3040 _(u'List of pages with no links to them'))
3041 html = page.render_content(content, _(u'Orphaned pages'))
3042 response = WikiResponse(html, mimetype='text/html')
3043 response.set_etag('/+orphaned/%d' % self.storage.repo_revision())
3044 response.make_conditional(request)
3045 return response
3047 def wanted(self, request):
3048 """Show all pages that don't exist yet, but are linked."""
3050 def wanted_pages_list(page):
3051 """Generate the content of wanted pages page."""
3053 h = werkzeug.html
3054 yield h.p(h(
3055 _(u"List of pages that are linked to, but don't exist yet.")))
3056 yield u'<ol class="wanted">'
3057 for refs, title in self.index.wanted_pages():
3058 url = page.get_url(title, self.backlinks)
3059 yield h.li(h.b(page.wiki_link(title)),
3060 h.i(u' (', h.a(h(_(u"%d references") % refs),
3061 href=url, class_="backlinks"), ')'))
3062 yield u'</ol>'
3064 page = self.get_page(request, '')
3065 content = wanted_pages_list(page)
3066 html = page.render_content(content, _(u'Wanted pages'))
3067 response = WikiResponse(html, mimetype='text/html')
3068 response.set_etag('/+wanted/%d' % self.storage.repo_revision())
3069 response.make_conditional(request)
3070 return response
3072 def search(self, request):
3073 """Serve the search results page."""
3075 def search_snippet(title, words):
3076 """Extract a snippet of text for search results."""
3078 try:
3079 text = self.storage.page_text(title)
3080 except werkzeug.exceptions.NotFound:
3081 return u''
3082 regexp = re.compile(u"|".join(re.escape(w) for w in words),
3083 re.U|re.I)
3084 match = regexp.search(text)
3085 if match is None:
3086 return u""
3087 position = match.start()
3088 min_pos = max(position - 60, 0)
3089 max_pos = min(position + 60, len(text))
3090 snippet = werkzeug.escape(text[min_pos:max_pos])
3091 highlighted = werkzeug.html.b(match.group(0), class_="highlight")
3092 html = regexp.sub(highlighted, snippet)
3093 return html
3095 def page_search(words, page, request):
3096 """Display the search results."""
3098 h = werkzeug.html
3099 self.storage.reopen()
3100 self.index.update(self, request)
3101 result = sorted(self.index.find(words), key=lambda x:-x[0])
3102 yield werkzeug.html.p(h(_(u'%d page(s) containing all words:')
3103 % len(result)))
3104 yield u'<ol class="search">'
3105 for number, (score, title) in enumerate(result):
3106 yield h.li(h.b(page.wiki_link(title)), u' ', h.i(str(score)),
3107 h.div(search_snippet(title, words),
3108 _class="snippet"),
3109 id_="search-%d" % (number+1))
3110 yield u'</ol>'
3112 query = request.values.get('q', u'').strip()
3113 page = self.get_page(request, '')
3114 if not query:
3115 url = request.get_url(view=self.all_pages, external=True)
3116 return werkzeug.routing.redirect(url, code=303)
3117 words = tuple(self.index.split_text(query, stop=False))
3118 if not words:
3119 words = (query,)
3120 title = _(u'Searching for "%s"') % u" ".join(words)
3121 content = page_search(words, page, request)
3122 html = page.render_content(content, title)
3123 return WikiResponse(html, mimetype='text/html')
3125 def backlinks(self, request, title):
3126 """Serve the page with backlinks."""
3128 self.storage.reopen()
3129 self.index.update(self, request)
3130 page = self.get_page(request, title)
3131 message = _(u'Pages that contain a link to %(link)s.')
3132 link = page.wiki_link(title)
3133 pages = self.index.page_backlinks(title)
3134 content = page.pages_list(pages, message, link, _class='backlinks')
3135 html = page.render_content(content, _(u'Links to "%s"') % title)
3136 response = WikiResponse(html, mimetype='text/html')
3137 response.set_etag('/+search/%d' % self.storage.repo_revision())
3138 response.make_conditional(request)
3139 return response
3141 def _serve_default(self, request, title, content, mime):
3142 """Some pages have their default content."""
3144 if title in self.storage:
3145 return self.download(request, title)
3146 response = werkzeug.Response(content, mimetype=mime)
3147 response.set_etag('/%s/-1' % title)
3148 response.make_conditional(request)
3149 return response
3151 def scripts_js(self, request):
3152 """Server the default scripts"""
3154 return self._serve_default(request, 'scripts.js', self.scripts,
3155 'text/javascript')
3157 def default_css(self, request):
3158 response = werkzeug.Response(self.style, mimetype="text/css")
3159 response.set_etag("/+style/-1")
3160 response.make_conditional(request)
3161 return response
3163 def style_css(self, request):
3164 """Serve the default style"""
3166 return self._serve_default(request, 'style.css', "",
3167 'text/css')
3169 def favicon_ico(self, request):
3170 """Serve the default favicon."""
3172 return self._serve_default(request, 'favicon.ico', self.icon,
3173 'image/x-icon')
3175 def robots_txt(self, request):
3176 """Serve the robots directives."""
3178 robots = ('User-agent: *\r\n'
3179 'Disallow: /+*\r\n'
3180 'Disallow: /%2B*\r\n'
3181 'Disallow: /+edit\r\n'
3182 'Disallow: /+feed\r\n'
3183 'Disallow: /+history\r\n'
3184 'Disallow: /+search\r\n'
3185 'Disallow: /+hg\r\n'
3187 return self._serve_default(request, 'robots.txt', robots,
3188 'text/plain')
3190 def hgweb(self, request, path=None):
3191 """Serve the pages repository on the web like a normal hg repository."""
3193 if not self.config.get_bool('hgweb', False):
3194 raise werkzeug.exceptions.Forbidden('Repository access disabled.')
3195 app = mercurial.hgweb.request.wsgiapplication(
3196 lambda: mercurial.hgweb.hgweb(self.storage.repo, self.site_name))
3197 def hg_app(env, start):
3198 env = request.environ
3199 prefix='/+hg'
3200 if env['PATH_INFO'].startswith(prefix):
3201 env["PATH_INFO"] = env["PATH_INFO"][len(prefix):]
3202 env["SCRIPT_NAME"] += prefix
3203 return app(env, start)
3204 return hg_app
3206 def die(self, request):
3207 """Terminate the standalone server if invoked from localhost."""
3209 if not request.remote_addr.startswith('127.'):
3210 raise werkzeug.exceptions.Forbidden()
3211 def agony():
3212 yield u'Oh dear!'
3213 self.dead = True
3214 return werkzeug.Response(agony(), mimetype='text/plain')
3216 @werkzeug.responder
3217 def application(self, environ, start):
3218 """The main application loop."""
3220 adapter = self.url_map.bind_to_environ(environ)
3221 request = WikiRequest(self, adapter, environ)
3222 try:
3223 try:
3224 endpoint, values = adapter.match()
3225 return endpoint(request, **values)
3226 except werkzeug.exceptions.HTTPException, err:
3227 return err
3228 finally:
3229 request.cleanup()
3230 del request
3231 del adapter
3233 def read_config():
3234 """Read and parse the config."""
3236 config = WikiConfig(
3237 # Here you can modify the configuration: uncomment and change the ones
3238 # you need. Note that it's better use environment variables or command
3239 # line switches.
3241 # interface='',
3242 # port=8080,
3243 # pages_path = 'docs',
3244 # cache_path = 'cache',
3245 # front_page = 'Home',
3246 # site_name = 'Hatta Wiki',
3247 # page_charset = 'UTF-8',
3249 config.parse_args()
3250 config.parse_files()
3251 # config.sanitize()
3252 return config
3254 def application(env, start):
3255 """Detect that we are being run as WSGI application."""
3257 global application
3258 config = read_config()
3259 script_dir = os.path.dirname(os.path.abspath(__file__))
3260 if config.get('pages_path') is None:
3261 config.set('pages_path', os.path.join(script_dir, 'docs'))
3262 if config.get('cache_path') is None:
3263 config.set('cache_path', os.path.join(script_dir, 'cache'))
3264 wiki = Wiki(config)
3265 application = wiki.application
3266 return application(env, start)
3268 def main(config=None, wiki=None):
3269 """Start a standalone WSGI server."""
3271 config = config or read_config()
3272 wiki = wiki or Wiki(config)
3273 app = wiki.application
3275 host, port = (config.get('interface', '0.0.0.0'),
3276 int(config.get('port', 8080)))
3277 try:
3278 from cherrypy import wsgiserver
3279 except ImportError:
3280 try:
3281 from cherrypy import _cpwsgiserver as wsgiserver
3282 except ImportError:
3283 import wsgiref.simple_server
3284 server = wsgiref.simple_server.make_server(host, port, app)
3285 try:
3286 server.serve_forever()
3287 except KeyboardInterrupt:
3288 pass
3289 return
3290 apps = [('', app)]
3291 name = wiki.site_name
3292 server = wsgiserver.CherryPyWSGIServer((host, port), apps, server_name=name)
3293 try:
3294 server.start()
3295 except KeyboardInterrupt:
3296 server.stop()
3298 if __name__ == "__main__":
3299 main()