Nicer format for human readable datestamp
[geekigeeki.git] / geekigeeki.py
index b52d3522c88d0b5b11a5e6c46463b70a1f92ca52..c5b51096a1fbecfe242b189b8d84625b152cdb65 100755 (executable)
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-__version__ = '$Id$'[4:12]
+__version__ = '4.0-' + '$Id$'[4:11]
 
-from time import clock
+from time import clock, localtime, gmtime, strftime
 start_time = clock()
 title_done = False
 
 import cgi, sys, os, re, errno, stat
 
+image_ext = 'png|gif|jpg|jpeg|bmp|ico'
+video_ext = "ogg|ogv|oga" # Not supported by Firefox 3.5: mkv|mpg|mpeg|mp4|avi|asf|flv|wmv|qt
+image_re = re.compile(r".*\.(" + image_ext + "|" +  video_ext + ")", re.IGNORECASE)
+video_re = re.compile(r".*\.(" + video_ext + ")", re.IGNORECASE)
 # FIXME: we accept stuff like foo/../bar and we shouldn't
-file_re = re.compile(r"^\b([A-Za-z0-9_\-][A-Za-z0-9_\.\-/]*)\b$")
-img_re = re.compile(r"^.*\.(png|gif|jpg|jpeg|bmp|ico|ogm|ogg|mkv|mpg|mpeg|mp4|avi|asf|flv|wmv|qt)$", re.IGNORECASE)
-video_re = re.compile(r"^.*\.(ogm|ogg|mkv|mpg|mpeg|mp4|avi|asf|flv|wmv|qt)$", re.IGNORECASE)
-url_re = re.compile(r"^[a-z]{3,8}://[^\s'\"]+\S$")
-ext_re = re.compile(r"\.([^\./]+)$")
+file_re  = re.compile(r"([A-Za-z0-9_\-][A-Za-z0-9_\.\-/]*)")
+url_re   = re.compile(r"[a-z]{3,8}://[^\s'\"]+\S")
+ext_re   = re.compile(r"\.([^\./]+)$")
 
 # CGI stuff ---------------------------------------------------------
 def script_name():
@@ -79,7 +81,7 @@ def permalink(s):
     return re.sub(' ', '-', re.sub('[^a-z0-9_ ]', '', s.lower()).strip())
 
 def humanlink(s):
-    return re.search('([^:/\.]+)(?:\.[^/:]+|)$', s).group(1).replace('_', ' ')
+    return re.sub(r'(?:.*[/:]|)([^:/\.]+)(?:\.[^/:]+|)$', r'\1', s.replace('_', ' '))
 
 # Split arg lists like "blah| blah blah| width=100 | align = center",
 # return a list containing anonymous arguments and a map containing the named arguments
@@ -87,10 +89,10 @@ def parse_args(s):
     args = []
     kwargs = {} 
     for arg in s.strip('<[{}]>').split('|'):
-        try:
-            key, val = arg.split('=', 1)
-            kwargs[key.strip()] = val.strip()
-        except ValueError:
+        m = re.match('\s*(\w+)\s*=\s*(.+)\s*', arg)
+        if m is not None:
+            kwargs[m.group(1)] = m.group(2)
+        else:
             args.append(arg.strip())
     return (args, kwargs)
 
@@ -103,7 +105,9 @@ def url_args(kvargs):
     return ''
 
 # Formatting stuff --------------------------------------------------
-def emit_header(mime_type="text/html"):
+def emit_header(mtime=None, mime_type="text/html"):
+    if mtime:
+        print("Last-Modified: " + strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime(mtime)))
     print("Content-type: " + mime_type + "; charset=utf-8\n")
 
 def send_guru(msg_text, msg_type):
@@ -117,12 +121,12 @@ def send_guru(msg_text, msg_type):
     print('</pre><script language="JavaScript" type="text/javascript" src="%s" defer="defer"></script>' \
         % relative_url('sys/GuruMeditation.js'))
 
-def send_title(name, text="Limbo", msg_text=None, msg_type='error', writable=False):
+def send_title(name, text="Limbo", msg_text=None, msg_type='error', writable=False, mtime=None):
     global title_done
     if title_done: return
 
     # Head
-    emit_header()
+    emit_header(mtime)
     print('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"')
     print('  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">')
     print('<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">')
@@ -173,7 +177,7 @@ def send_title(name, text="Limbo", msg_text=None, msg_type='error', writable=Fal
             print(' | <a href="' + relative_url(history_url + '?a=history;f=' + name) + '" class="navlink">Page History</a>')
 
     if name:
-        print(' | ' + link_tag('?raw=' + name, 'Raw Text', cssclass='navlink'))
+        print(' | ' + link_tag(name + '?a=raw', 'Raw Text', cssclass='navlink'))
         if privileged_url is not None:
             if writable:
                 print(' | ' + link_tag('?a=edit&q=' + name, 'Edit', cssclass='navlink', privileged=True))
@@ -197,7 +201,7 @@ def send_httperror(status="403 Not Found", query=""):
 def link_tag(dest, text=None, privileged=False, **kvargs):
     if text is None:
         text = humanlink(dest)
-    elif img_re.match(text):
+    elif image_re.match(text):
         text = '<img border="0" src="' + relative_url(text) + '" alt="' + text + '" />'
 
     link_class = kvargs.get('class', kvargs.get('cssclass', None))
@@ -222,7 +226,7 @@ def link_inline(name, descr=None, kvargs={}):
     url = relative_url(name)
     if video_re.match(name):
         return '<video controls="1" src="%s">Your browser does not support the HTML5 video tag</video>' % url
-    elif img_re.match(name):
+    elif image_re.match(name):
         return '<a href="%s"><img border="0" src="%s" alt="%s" /></a>' % (url, url + url_args(kvargs), descr)
     elif file_re.match(name) and not ext_re.search(name): # FIXME: this guesses a wiki page
         return Page(name).send_naked()
@@ -324,26 +328,27 @@ def make_index_key():
     links = ['<a href="#%s">%s</a>' % (ch, ch) for ch in 'abcdefghijklmnopqrstuvwxyz']
     return '<p style="text-align: center">' + ' | '.join(links) + '</p>'
 
-def page_list(dirname = None, re = None):
+def page_list(dirname=None, re=None):
     if re is None:
         # FIXME: WikiWord is too restrictive now!
         re = re.compile(r"^\b((([A-Z][a-z0-9]+){2,}/)*([A-Z][a-z0-9]+){2,})\b$")
     return sorted(filter(re.match, os.listdir(dirname or data_dir)))
 
-def send_footer(mod_string=None):
+def send_footer(mtime=None):
     if globals().get('debug_cgi', False):
         cgi.print_arguments()
         cgi.print_form(form)
         cgi.print_environ()
+        #FIXME link_inline("sys/footer")
     print('''
 <div id="footer"><hr />
 <p class="copyright">
 <a rel="license" href="http://creativecommons.org/licenses/by-sa/3.0/"><img class="license" alt="Creative Commons License" src="%s" /></a>
 <span class="benchmark">generated in %0.3fs</span> by <a href="http://www.codewiz.org/wiki/GeekiGeeki">GeekiGeeki</a> version %s
 </p>
-''' % (relative_url('cc-by-sa.png'), clock() - start_time, __version__))
-    if mod_string:
-        print('<p class="modified">last modified %s</p>' % mod_string)
+''' % (relative_url('sys/cc-by-sa.png'), clock() - start_time, __version__))
+    if mtime:
+        print('<p class="modified">last modified %s</p>' % strftime(datetime_fmt, localtime(mtime)))
     print('</div></body></html>')
 
 class WikiFormatter:
@@ -365,6 +370,7 @@ class WikiFormatter:
             "**":  ["b",   False],
             "##":  ["tt",  False],
             "__":  ["u",   False],
+            "--":  ["del", False],
             "^^":  ["sup", False],
             ",,":  ["sub", False],
             "''":  ["em",  False], # LEGACY
@@ -377,6 +383,9 @@ class WikiFormatter:
         style[1] = not style[1]
         return ['</', '<'][style[1]] + style[0] + '>'
 
+    def _glyph_repl(self, word):
+        return '&mdash;'
+
     def _tit_repl(self, word):
         if self.h_level:
             result = '</h%d><p>\n' % self.h_level
@@ -532,29 +541,30 @@ class WikiFormatter:
         print('<div class="wiki"><p>')
 
         scan_re = re.compile(r"""(?:
-            # Styles and formatting
-              (?P<b>     \*\*|'''|//|''|\#\#|``|__|\^\^|,,)
+            # Styles and formatting ("--" must cling to a word to disambiguate it from the dash)
+              (?P<b>     \*\* | // | \#\# | __ | --\b | \b-- | \^\^ | ,, | ''' | '' | `` )
             | (?P<tit>   \={2,6})
             | (?P<br>    \\\\)
             | (?P<rule>  ^-{3,})
             | (?P<hi>    \b( FIXME | TODO | DONE )\b )
+            | (?P<glyph> --)
 
             # Links
             | (?P<macro> \<\<([^\s\|\>]+)(?:\s*\|\s*([^\>]+)|)\>\>)
             | (?P<hurl>  \[\[([^\s\|]+)(?:\s*\|\s*([^\]]+)|)\]\])
 
             # Inline HTML
-            | (?P<html>  <(br|hr|div|span|form|iframe|input|textarea|a|img|h[1-5])\b )
+            | (?P<html>             <(br|hr|div|span|form|iframe|input|textarea|a|img|h[1-5])\b )
             | (?P<htmle> ( /\s*> | </(br|hr|div|span|form|iframe|input|textarea|a|img|h[1-5])> ) )
             | (?P<ent>   [<>&] )
 
             # Auto links (LEGACY)
-            | (?P<img>   \b[a-zA-Z0-9_/-]+\.(png|gif|jpg|jpeg|bmp|ico|ogm|ogg|mkv|mpg|mpeg|mp4|avi|asf|flv|wmv|qt))
+            | (?P<img>   \b[a-zA-Z0-9_/-]+\.(""" + image_ext + "|" + video_ext + r"""))
             | (?P<word>  \b(?:[A-Z][a-z]+){2,}\b)
             | (?P<url>   (http|https|ftp|mailto)\:[^\s'\"]+\S)
             | (?P<email> [-\w._+]+\@[\w.-]+)
 
-            # Lists, divs, spans
+            # Lists, divs, spans and inline objects
             | (?P<li>    ^\s+[\*\#]\s+)
             | (?P<pre>   \{\{\{|\s*\}\}\})
             | (?P<inl>   \{\{([^\s\|]+)(?:\s*\|\s*([^\]]+)|)\}\})
@@ -619,22 +629,26 @@ class Page:
     def _tmp_filename(self):
         return os.path.join(data_dir, ('#' + self.page_name.replace('/','_') + '.' + str(os.getpid()) + '#'))
 
-    def exists(self):
+    def _mtime(self):
         try:
-            os.stat(self._filename())
-            return True
-        except OSError as err:
+            return os.stat(self._filename()).st_mtime
+        except OSError, err:
             if err.errno == errno.ENOENT:
-                return False
+                return None
             raise err
 
+    def exists(self):
+        if self._mtime():
+            return True
+        return False
+
     def get_raw_body(self, default=None):
         try:
             return open(self._filename(), 'rb').read()
-        except IOError as err:
+        except IOError, err:
             if err.errno == errno.ENOENT:
                 if default is None:
-                    default = '//[[%s|Describe %s|action=edit]]//' % (self.page_name, self.page_name)
+                    default = '//[[?a=edit&q=%s|Describe %s]]//' % (self.page_name, self.page_name)
                 return default
             if err.errno == errno.EISDIR:
                 return self.format_dir()
@@ -643,13 +657,13 @@ class Page:
     def format_dir(self):
         out = '== '
         pathname = ''
-        for dirname in self.page_name.split('/'):
+        for dirname in self.page_name.strip('/').split('/'):
             pathname = (pathname + '/' + dirname) if pathname else dirname
             out += '[[' + pathname + '|' + dirname + ']]/'
         out += ' ==\n'
  
         for filename in page_list(self._filename(), file_re):
-            if img_re.match(filename):
+            if image_re.match(filename):
                 if image_maxwidth:
                     maxwidth_arg = ' | maxwidth=' + str(image_maxwidth)
                 out += '{{' + self.page_name + '/' + filename + ' | ' + humanlink(filename) + maxwidth_arg + ' | class=thumbleft}}\n'
@@ -669,7 +683,7 @@ class Page:
                         break
                     self._pragmas[m.group(1)] = m.group(2).strip()
                     #print "bernie: pragmas[" + m.group(1) + "] = " + m.group(2) + "<br>\n"
-            except IOError as err:
+            except IOError, err:
                 if err.errno != errno.ENOENT and err.errno != errno.EISDIR:
                     raise er
         return self._pragmas
@@ -712,19 +726,9 @@ class Page:
             link_urls += [ [ "stylesheet", value ] ]
 
         send_title(self.page_name, self.split_title(),
-            msg_text=self.msg_text, msg_type=self.msg_type, writable=self.can_write())
+            msg_text=self.msg_text, msg_type=self.msg_type, writable=self.can_write(), mtime=self._mtime())
         self.send_naked()
-        send_footer(self._last_modified())
-
-    def _last_modified(self):
-        try:
-            from time import localtime, strftime
-            modtime = localtime(os.stat(self._filename())[stat.ST_MTIME])
-        except OSError as err:
-            if err.errno != errno.ENOENT:
-                raise err
-            return None
-        return strftime(datetime_fmt, modtime)
+        send_footer(mtime=self._mtime())
 
     def send_editor(self, preview=None):
         send_title(None, 'Edit ' + self.split_title(), msg_text=self.msg_text, msg_type=self.msg_type)
@@ -741,7 +745,7 @@ class Page:
             + ' from ' + cgi.escape(get_hostname(remote_host()))
             + '</b></p>'))
         print('<div class="editor"><form name="editform" method="post" enctype="multipart/form-data" action="%s">' % relative_url(self.page_name))
-        print('<input type="hidden" name="edit" value="%s">' % (self.page_name))
+        print('<input type="hidden" name="a" value="edit" /><input type="hidden" name="q" value="' + self.page_name + '" />')
         print('<input type="input" id="editor" name="changelog" value="Edit page %s" accesskey="c" /><br />' % (self.page_name))
         print('<textarea wrap="off" spellcheck="true" id="editor" name="savetext" rows="17" cols="100" accesskey="e">%s</textarea>' \
             % cgi.escape(preview or self.get_raw_body(default='')))
@@ -769,18 +773,17 @@ class Page:
 
     def send_raw(self, mimetype='text/plain', args=[]):
         if not self.can_read():
-            send_title(None, msg_text='Read access denied by ACLs', msg_type='notice')
+            send_title(None, msg_text='Read access denied by ACLs', msg_type='notice', mtime=self._mtime())
             return
 
+        emit_header(self._mtime(), mimetype)
         if 'maxwidth' in args:
             import subprocess
-            emit_header(mimetype)
             sys.stdout.flush()
             subprocess.check_call(['gm', 'convert', self._filename(),
                 '-scale', args['maxwidth'].value + ' >', '-'])
         else:
             body = self.get_raw_body()
-            emit_header(mimetype)
             print(body)
 
     def _write_file(self, data):
@@ -791,8 +794,11 @@ class Page:
             # Bad Bill!  POSIX rename ought to replace. :-(
             try:
                 os.remove(name)
-            except OSError as err:
+            except OSError, err:
                 if err.errno != errno.ENOENT: raise err
+        path = os.path.split(name)[0]
+        if not os.path.exists(path):
+            os.makedirs(path)
         os.rename(tmp_filename, name)
 
     def save(self, newdata, changelog):