"""Generic FAQ Wizard.

This is a CGI program that maintains a user-editable FAQ.  It uses RCS
to keep track of changes to individual FAQ entries.  It is fully
configurable; everything you might want to change when using this
program to maintain some other FAQ than the Python FAQ is contained in
the configuration module, faqconf.py.

Note that this is not an executable script; it's an importable module.
The actual script to place in cgi-bin is faqw.py.

"""

import sys, time, os, stat, re, cgi, faqconf
from faqconf import *                   # This imports all uppercase names
now = time.time()

class FileError:
   def __init__(self, file):
       self.file = file

class InvalidFile(FileError):
   pass

class NoSuchSection(FileError):
   def __init__(self, section):
       FileError.__init__(self, NEWFILENAME %(section, 1))
       self.section = section

class NoSuchFile(FileError):
   def __init__(self, file, why=None):
       FileError.__init__(self, file)
       self.why = why

def escape(s):
   s = s.replace('&', '&')
   s = s.replace('<', '&lt;')
   s = s.replace('>', '&gt;')
   return s

def escapeq(s):
   s = escape(s)
   s = s.replace('"', '&quot;')
   return s

def _interpolate(format, args, kw):
   try:
       quote = kw['_quote']
   except KeyError:
       quote = 1
   d = (kw,) + args + (faqconf.__dict__,)
   m = MagicDict(d, quote)
   return format % m

def interpolate(format, *args, **kw):
   return _interpolate(format, args, kw)

def emit(format, *args, **kw):
   try:
       f = kw['_file']
   except KeyError:
       f = sys.stdout
   f.write(_interpolate(format, args, kw))

translate_prog = None

def translate(text, pre=0):
   global translate_prog
   if not translate_prog:
       translate_prog = prog = re.compile(
           r'\b(http|ftp|https)://\S+(\b|/)|\b[-.\w]+@[-.\w]+')
   else:
       prog = translate_prog
   i = 0
   list = []
   while 1:
       m = prog.search(text, i)
       if not m:
           break
       j = m.start()
       list.append(escape(text[i:j]))
       i = j
       url = m.group(0)
       while url[-1] in '();:,.?\'"<>':
           url = url[:-1]
       i = i + len(url)
       url = escape(url)
       if not pre or (pre and PROCESS_PREFORMAT):
           if ':' in url:
               repl = '<A HREF="%s">%s</A>' % (url, url)
           else:
               repl = '<A HREF="mailto:%s">%s</A>' % (url, url)
       else:
           repl = url
       list.append(repl)
   j = len(text)
   list.append(escape(text[i:j]))
   return ''.join(list)

def emphasize(line):
   return re.sub(r'\*([a-zA-Z]+)\*', r'<I>\1</I>', line)

revparse_prog = None

def revparse(rev):
   global revparse_prog
   if not revparse_prog:
       revparse_prog = re.compile(r'^(\d{1,3})\.(\d{1,4})$')
   m = revparse_prog.match(rev)
   if not m:
       return None
   [major, minor] = map(int, m.group(1, 2))
   return major, minor

logon = 0
def log(text):
   if logon:
       logfile = open("logfile", "a")
       logfile.write(text + "\n")
       logfile.close()

def load_cookies():
   if not os.environ.has_key('HTTP_COOKIE'):
       return {}
   raw = os.environ['HTTP_COOKIE']
   words = [s.strip() for s in raw.split(';')]
   cookies = {}
   for word in words:
       i = word.find('=')
       if i >= 0:
           key, value = word[:i], word[i+1:]
           cookies[key] = value
   return cookies

def load_my_cookie():
   cookies = load_cookies()
   try:
       value = cookies[COOKIE_NAME]
   except KeyError:
       return {}
   import urllib
   value = urllib.unquote(value)
   words = value.split('/')
   while len(words) < 3:
       words.append('')
   author = '/'.join(words[:-2])
   email = words[-2]
   password = words[-1]
   return {'author': author,
           'email': email,
           'password': password}

def send_my_cookie(ui):
   name = COOKIE_NAME
   value = "%s/%s/%s" % (ui.author, ui.email, ui.password)
   import urllib
   value = urllib.quote(value)
   then = now + COOKIE_LIFETIME
   gmt = time.gmtime(then)
   path = os.environ.get('SCRIPT_NAME', '/cgi-bin/')
   print "Set-Cookie: %s=%s; path=%s;" % (name, value, path),
   print time.strftime("expires=%a, %d-%b-%y %X GMT", gmt)

class MagicDict:

   def __init__(self, d, quote):
       self.__d = d
       self.__quote = quote

   def __getitem__(self, key):
       for d in self.__d:
           try:
               value = d[key]
               if value:
                   value = str(value)
                   if self.__quote:
                       value = escapeq(value)
                   return value
           except KeyError:
               pass
       return ''

class UserInput:

   def __init__(self):
       self.__form = cgi.FieldStorage()
       #log("\n\nbody: " + self.body)

   def __getattr__(self, name):
       if name[0] == '_':
           raise AttributeError
       try:
           value = self.__form[name].value
       except (TypeError, KeyError):
           value = ''
       else:
           value = value.strip()
       setattr(self, name, value)
       return value

   def __getitem__(self, key):
       return getattr(self, key)

class FaqEntry:

   def __init__(self, fp, file, sec_num):
       self.file = file
       self.sec, self.num = sec_num
       if fp:
           import rfc822
           self.__headers = rfc822.Message(fp)
           self.body = fp.read().strip()
       else:
           self.__headers = {'title': "%d.%d. " % sec_num}
           self.body = ''

   def __getattr__(self, name):
       if name[0] == '_':
           raise AttributeError
       key = '-'.join(name.split('_'))
       try:
           value = self.__headers[key]
       except KeyError:
           value = ''
       setattr(self, name, value)
       return value

   def __getitem__(self, key):
       return getattr(self, key)

   def load_version(self):
       command = interpolate(SH_RLOG_H, self)
       p = os.popen(command)
       version = ''
       while 1:
           line = p.readline()
           if not line:
               break
           if line[:5] == 'head:':
               version = line[5:].strip()
       p.close()
       self.version = version

   def getmtime(self):
       if not self.last_changed_date:
           return 0
       try:
           return os.stat(self.file)[stat.ST_MTIME]
       except os.error:
           return 0

   def emit_marks(self):
       mtime = self.getmtime()
       if mtime >= now - DT_VERY_RECENT:
           emit(MARK_VERY_RECENT, self)
       elif mtime >= now - DT_RECENT:
           emit(MARK_RECENT, self)

   def show(self, edit=1):
       emit(ENTRY_HEADER1, self)
       self.emit_marks()
       emit(ENTRY_HEADER2, self)
       pre = 0
       raw = 0
       for line in self.body.split('\n'):
           # Allow the user to insert raw html into a FAQ answer
           # (Skip Montanaro, with changes by Guido)
           tag = line.rstrip().lower()
           if tag == '<html>':
               raw = 1
               continue
           if tag == '</html>':
               raw = 0
               continue
           if raw:
               print line
               continue
           if not line.strip():
               if pre:
                   print '</PRE>'
                   pre = 0
               else:
                   print '<P>'
           else:
               if not line[0].isspace():
                   if pre:
                       print '</PRE>'
                       pre = 0
               else:
                   if not pre:
                       print '<PRE>'
                       pre = 1
               if '/' in line or '@' in line:
                   line = translate(line, pre)
               elif '<' in line or '&' in line:
                   line = escape(line)
               if not pre and '*' in line:
                   line = emphasize(line)
               print line
       if pre:
           print '</PRE>'
           pre = 0
       if edit:
           print '<P>'
           emit(ENTRY_FOOTER, self)
           if self.last_changed_date:
               emit(ENTRY_LOGINFO, self)
       print '<P>'

class FaqDir:

   entryclass = FaqEntry

   __okprog = re.compile(OKFILENAME)

   def __init__(self, dir=os.curdir):
       self.__dir = dir
       self.__files = None

   def __fill(self):
       if self.__files is not None:
           return
       self.__files = files = []
       okprog = self.__okprog
       for file in os.listdir(self.__dir):
           if self.__okprog.match(file):
               files.append(file)
       files.sort()

   def good(self, file):
       return self.__okprog.match(file)

   def parse(self, file):
       m = self.good(file)
       if not m:
           return None
       sec, num = m.group(1, 2)
       return int(sec), int(num)

   def list(self):
       # XXX Caller shouldn't modify result
       self.__fill()
       return self.__files

   def open(self, file):
       sec_num = self.parse(file)
       if not sec_num:
           raise InvalidFile(file)
       try:
           fp = open(file)
       except IOError, msg:
           raise NoSuchFile(file, msg)
       try:
           return self.entryclass(fp, file, sec_num)
       finally:
           fp.close()

   def show(self, file, edit=1):
       self.open(file).show(edit=edit)

   def new(self, section):
       if not SECTION_TITLES.has_key(section):
           raise NoSuchSection(section)
       maxnum = 0
       for file in self.list():
           sec, num = self.parse(file)
           if sec == section:
               maxnum = max(maxnum, num)
       sec_num = (section, maxnum+1)
       file = NEWFILENAME % sec_num
       return self.entryclass(None, file, sec_num)

class FaqWizard:

   def __init__(self):
       self.ui = UserInput()
       self.dir = FaqDir()

   def go(self):
       print 'Content-type: text/html'
       req = self.ui.req or 'home'
       mname = 'do_%s' % req
       try:
           meth = getattr(self, mname)
       except AttributeError:
           self.error("Bad request type %r." % (req,))
       else:
           try:
               meth()
           except InvalidFile, exc:
               self.error("Invalid entry file name %s" % exc.file)
           except NoSuchFile, exc:
               self.error("No entry with file name %s" % exc.file)
           except NoSuchSection, exc:
               self.error("No section number %s" % exc.section)
       self.epilogue()

   def error(self, message, **kw):
       self.prologue(T_ERROR)
       emit(message, kw)

   def prologue(self, title, entry=None, **kw):
       emit(PROLOGUE, entry, kwdict=kw, title=escape(title))

   def epilogue(self):
       emit(EPILOGUE)

   def do_home(self):
       self.prologue(T_HOME)
       emit(HOME)

   def do_debug(self):
       self.prologue("FAQ Wizard Debugging")
       form = cgi.FieldStorage()
       cgi.print_form(form)
       cgi.print_environ(os.environ)
       cgi.print_directory()
       cgi.print_arguments()

   def do_search(self):
       query = self.ui.query
       if not query:
           self.error("Empty query string!")
           return
       if self.ui.querytype == 'simple':
           query = re.escape(query)
           queries = [query]
       elif self.ui.querytype in ('anykeywords', 'allkeywords'):
           words = filter(None, re.split('\W+', query))
           if not words:
               self.error("No keywords specified!")
               return
           words = map(lambda w: r'\b%s\b' % w, words)
           if self.ui.querytype[:3] == 'any':
               queries = ['|'.join(words)]
           else:
               # Each of the individual queries must match
               queries = words
       else:
           # Default to regular expression
           queries = [query]
       self.prologue(T_SEARCH)
       progs = []
       for query in queries:
           if self.ui.casefold == 'no':
               p = re.compile(query)
           else:
               p = re.compile(query, re.IGNORECASE)
           progs.append(p)
       hits = []
       for file in self.dir.list():
           try:
               entry = self.dir.open(file)
           except FileError:
               constants
           for p in progs:
               if not p.search(entry.title) and not p.search(entry.body):
                   break
           else:
               hits.append(file)
       if not hits:
           emit(NO_HITS, self.ui, count=0)
       elif len(hits) <= MAXHITS:
           if len(hits) == 1:
               emit(ONE_HIT, count=1)
           else:
               emit(FEW_HITS, count=len(hits))
           self.format_all(hits, headers=0)
       else:
           emit(MANY_HITS, count=len(hits))
           self.format_index(hits)

   def do_all(self):
       self.prologue(T_ALL)
       files = self.dir.list()
       self.last_changed(files)
       self.format_index(files, localrefs=1)
       self.format_all(files)

   def do_compat(self):
       files = self.dir.list()
       emit(COMPAT)
       self.last_changed(files)
       self.format_index(files, localrefs=1)
       self.format_all(files, edit=0)
       sys.exit(0)                     # XXX Hack to suppress epilogue

   def last_changed(self, files):
       latest = 0
       for file in files:
           entry = self.dir.open(file)
           if entry:
               mtime = mtime = entry.getmtime()
               if mtime > latest:
                   latest = mtime
       print time.strftime(LAST_CHANGED, time.localtime(latest))
       emit(EXPLAIN_MARKS)

   def format_all(self, files, edit=1, headers=1):
       sec = 0
       for file in files:
           try:
               entry = self.dir.open(file)
           except NoSuchFile:
               continue
           if headers and entry.sec != sec:
               sec = entry.sec
               try:
                   title = SECTION_TITLES[sec]
               except KeyError:
                   title = "Untitled"
               emit("\n<HR>\n<H1>%(sec)s. %(title)s</H1>\n",
                    sec=sec, title=title)
           entry.show(edit=edit)

   def do_index(self):
       self.prologue(T_INDEX)
       files = self.dir.list()
       self.last_changed(files)
       self.format_index(files, add=1)

   def format_index(self, files, add=0, localrefs=0):
       sec = 0
       for file in files:
           try:
               entry = self.dir.open(file)
           except NoSuchFile:
               continue
           if entry.sec != sec:
               if sec:
                   if add:
                       emit(INDEX_ADDSECTION, sec=sec)
                   emit(INDEX_ENDSECTION, sec=sec)
               sec = entry.sec
               try:
                   title = SECTION_TITLES[sec]
               except KeyError:
                   title = "Untitled"
               emit(INDEX_SECTION, sec=sec, title=title)
           if localrefs:
               emit(LOCAL_ENTRY, entry)
           else:
               emit(INDEX_ENTRY, entry)
           entry.emit_marks()
       if sec:
           if add:
               emit(INDEX_ADDSECTION, sec=sec)
           emit(INDEX_ENDSECTION, sec=sec)

   def do_recent(self):
       if not self.ui.days:
           days = 1
       else:
           days = float(self.ui.days)
       try:
           cutoff = now - days * 24 * 3600
       except OverflowError:
           cutoff = 0
       list = []
       for file in self.dir.list():
           entry = self.dir.open(file)
           if not entry:
               continue
           mtime = entry.getmtime()
           if mtime >= cutoff:
               list.append((mtime, file))
       list.sort()
       list.reverse()
       self.prologue(T_RECENT)
       if days <= 1:
           period = "%.2g hours" % (days*24)
       else:
           period = "%.6g days" % days
       if not list:
           emit(NO_RECENT, period=period)
       elif len(list) == 1:
           emit(ONE_RECENT, period=period)
       else:
           emit(SOME_RECENT, period=period, count=len(list))
       self.format_all(map(lambda (mtime, file): file, list), headers=0)
       emit(TAIL_RECENT)

   def do_roulette(self):
       import random
       files = self.dir.list()
       if not files:
           self.error("No entries.")
           return
       file = random.choice(files)
       self.prologue(T_ROULETTE)
       emit(ROULETTE)
       self.dir.show(file)

   def do_help(self):
       self.prologue(T_HELP)
       emit(HELP)

   def do_show(self):
       entry = self.dir.open(self.ui.file)
       self.prologue(T_SHOW)
       entry.show()

   def do_add(self):
       self.prologue(T_ADD)
       emit(ADD_HEAD)
       sections = SECTION_TITLES.items()
       sections.sort()
       for section, title in sections:
           emit(ADD_SECTION, section=section, title=title)
       emit(ADD_TAIL)

   def do_delete(self):
       self.prologue(T_DELETE)
       emit(DELETE)

   def do_log(self):
       entry = self.dir.open(self.ui.file)
       self.prologue(T_LOG, entry)
       emit(LOG, entry)
       self.rlog(interpolate(SH_RLOG, entry), entry)

   def rlog(self, command, entry=None):
       output = os.popen(command).read()
       sys.stdout.write('<PRE>')
       athead = 0
       lines = output.split('\n')
       while lines and not lines[-1]:
           del lines[-1]
       if lines:
           line = lines[-1]
           if line[:1] == '=' and len(line) >= 40 and \
              line == line[0]*len(line):
               del lines[-1]
       headrev = None
       for line in lines:
           if entry and athead and line[:9] == 'revision ':
               rev = line[9:].split()
               mami = revparse(rev)
               if not mami:
                   print line
               else:
                   emit(REVISIONLINK, entry, rev=rev, line=line)
                   if mami[1] > 1:
                       prev = "%d.%d" % (mami[0], mami[1]-1)
                       emit(DIFFLINK, entry, prev=prev, rev=rev)
                   if headrev:
                       emit(DIFFLINK, entry, prev=rev, rev=headrev)
                   else:
                       headrev = rev
                   print
               athead = 0
           else:
               athead = 0
               if line[:1] == '-' and len(line) >= 20 and \
                  line == len(line) * line[0]:
                   athead = 1
                   sys.stdout.write('<HR>')
               else:
                   print line
       print '</PRE>'

   def do_revision(self):
       entry = self.dir.open(self.ui.file)
       rev = self.ui.rev
       mami = revparse(rev)
       if not mami:
           self.error("Invalid revision number: %r." % (rev,))
       self.prologue(T_REVISION, entry)
       self.shell(interpolate(SH_REVISION, entry, rev=rev))

   def do_diff(self):
       entry = self.dir.open(self.ui.file)
       prev = self.ui.prev
       rev = self.ui.rev
       mami = revparse(rev)
       if not mami:
           self.error("Invalid revision number: %r." % (rev,))
       if prev:
           if not revparse(prev):
               self.error("Invalid previous revision number: %r." % (prev,))
       else:
           prev = '%d.%d' % (mami[0], mami[1])
       self.prologue(T_DIFF, entry)
       self.shell(interpolate(SH_RDIFF, entry, rev=rev, prev=prev))

   def shell(self, command):
       output = os.popen(command).read()
       sys.stdout.write('<PRE>')
       print escape(output)
       print '</PRE>'

   def do_new(self):
       entry = self.dir.new(section=int(self.ui.section))
       entry.version = '*new*'
       self.prologue(T_EDIT)
       emit(EDITHEAD)
       emit(EDITFORM1, entry, editversion=entry.version)
       emit(EDITFORM2, entry, load_my_cookie())
       emit(EDITFORM3)
       entry.show(edit=0)

   def do_edit(self):
       entry = self.dir.open(self.ui.file)
       entry.load_version()
       self.prologue(T_EDIT)
       emit(EDITHEAD)
       emit(EDITFORM1, entry, editversion=entry.version)
       emit(EDITFORM2, entry, load_my_cookie())
       emit(EDITFORM3)
       entry.show(edit=0)

   def do_review(self):
       send_my_cookie(self.ui)
       if self.ui.editversion == '*new*':
           sec, num = self.dir.parse(self.ui.file)
           entry = self.dir.new(section=sec)
           entry.version = "*new*"
           if entry.file != self.ui.file:
               self.error("Commit version conflict!")
               emit(NEWCONFLICT, self.ui, sec=sec, num=num)
               return
       else:
           entry = self.dir.open(self.ui.file)
           entry.load_version()
       # Check that the FAQ entry number didn't change
       if self.ui.title.split()[:1] != entry.title.split()[:1]:
           self.error("Don't change the entry number please!")
           return
       # Check that the edited version is the current version
       if entry.version != self.ui.editversion:
           self.error("Commit version conflict!")
           emit(VERSIONCONFLICT, entry, self.ui)
           return
       commit_ok = ((not PASSWORD
                     or self.ui.password == PASSWORD)
                    and self.ui.author
                    and '@' in self.ui.email
                    and self.ui.log)
       if self.ui.commit:
           if not commit_ok:
               self.cantcommit()
           else:
               self.commit(entry)
           return
       self.prologue(T_REVIEW)
       emit(REVIEWHEAD)
       entry.body = self.ui.body
       entry.title = self.ui.title
       entry.show(edit=0)
       emit(EDITFORM1, self.ui, entry)
       if commit_ok:
           emit(COMMIT)
       else:
           emit(NOCOMMIT_HEAD)
           self.errordetail()
           emit(NOCOMMIT_TAIL)
       emit(EDITFORM2, self.ui, entry, load_my_cookie())
       emit(EDITFORM3)

   def cantcommit(self):
       self.prologue(T_CANTCOMMIT)
       print CANTCOMMIT_HEAD
       self.errordetail()
       print CANTCOMMIT_TAIL

   def errordetail(self):
       if PASSWORD and self.ui.password != PASSWORD:
           emit(NEED_PASSWD)
       if not self.ui.log:
           emit(NEED_LOG)
       if not self.ui.author:
           emit(NEED_AUTHOR)
       if not self.ui.email:
           emit(NEED_EMAIL)

   def commit(self, entry):
       file = entry.file
       # Normalize line endings in body
       if '\r' in self.ui.body:
           self.ui.body = re.sub('\r\n?', '\n', self.ui.body)
       # Normalize whitespace in title
       self.ui.title = ' '.join(self.ui.title.split())
       # Check that there were any changes
       if self.ui.body == entry.body and self.ui.title == entry.title:
           self.error("You didn't make any changes!")
           return

       # need to lock here because otherwise the file exists and is not writable (on NT)
       command = interpolate(SH_LOCK, file=file)
       p = os.popen(command)
       output = p.read()

       try:
           os.unlink(file)
       except os.error:
           pass
       try:
           f = open(file, 'w')
       except IOError, why:
           self.error(CANTWRITE, file=file, why=why)
           return
       date = time.ctime(now)
       emit(FILEHEADER, self.ui, os.environ, date=date, _file=f, _quote=0)
       f.write('\n')
       f.write(self.ui.body)
       f.write('\n')
       f.close()

       import tempfile
       tf = tempfile.NamedTemporaryFile()
       emit(LOGHEADER, self.ui, os.environ, date=date, _file=tf)
       tf.flush()
       tf.seek(0)

       command = interpolate(SH_CHECKIN, file=file, tfn=tf.name)
       log("\n\n" + command)
       p = os.popen(command)
       output = p.read()
       sts = p.close()
       log("output: " + output)
       log("done: " + str(sts))
       log("TempFile:\n" + tf.read() + "end")

       if not sts:
           self.prologue(T_COMMITTED)
           emit(COMMITTED)
       else:
           self.error(T_COMMITFAILED)
           emit(COMMITFAILED, sts=sts)
       print '<PRE>%s</PRE>' % escape(output)

       try:
           os.unlink(tf.name)
       except os.error:
           pass

       entry = self.dir.open(file)
       entry.show()

wiz = FaqWizard()
wiz.go()