3 This is a CGI program that maintains a user-editable FAQ. It uses RCS
4 to keep track of changes to individual FAQ entries. It is fully
5 configurable; everything you might want to change when using this
6 program to maintain some other FAQ than the Python FAQ is contained in
7 the configuration module, faqconf.py.
9 Note that this is not an executable script; it's an importable module.
10 The actual script to place in cgi-bin is faqw.py.
14 import sys
, string
, time
, os
, stat
, re
, cgi
, faqconf
15 from faqconf
import * # This imports all uppercase names
19 def __init__(self
, file):
22 class InvalidFile(FileError
):
25 class NoSuchSection(FileError
):
26 def __init__(self
, section
):
27 FileError
.__init
__(self
, NEWFILENAME
%(section
, 1))
28 self
.section
= section
30 class NoSuchFile(FileError
):
31 def __init__(self
, file, why
=None):
32 FileError
.__init
__(self
, file)
36 s
= string
.replace(s
, '&', '&')
37 s
= string
.replace(s
, '<', '<')
38 s
= string
.replace(s
, '>', '>')
43 s
= string
.replace(s
, '"', '"')
46 def _interpolate(format
, args
, kw
):
51 d
= (kw
,) + args
+ (faqconf
.__dict
__,)
52 m
= MagicDict(d
, quote
)
55 def interpolate(format
, *args
, **kw
):
56 return _interpolate(format
, args
, kw
)
58 def emit(format
, *args
, **kw
):
63 f
.write(_interpolate(format
, args
, kw
))
67 def translate(text
, pre
=0):
69 if not translate_prog
:
70 translate_prog
= prog
= re
.compile(
71 r
'\b(http|ftp|https)://\S+(\b|/)|\b[-.\w]+@[-.\w]+')
77 m
= prog
.search(text
, i
)
81 list.append(escape(text
[i
:j
]))
84 while url
[-1] in '();:,.?\'"<>':
88 if not pre
or (pre
and PROCESS_PREFORMAT
):
90 repl
= '<A HREF="%s">%s</A>' % (url
, url
)
92 repl
= '<A HREF="mailto:%s">%s</A>' % (url
, url
)
97 list.append(escape(text
[i
:j
]))
98 return string
.join(list, '')
101 return re
.sub(r
'\*([a-zA-Z]+)\*', r
'<I>\1</I>', line
)
107 if not revparse_prog
:
108 revparse_prog
= re
.compile(r
'^(\d{1,3})\.(\d{1,4})$')
109 m
= revparse_prog
.match(rev
)
112 [major
, minor
] = map(string
.atoi
, m
.group(1, 2))
116 if not os
.environ
.has_key('HTTP_COOKIE'):
118 raw
= os
.environ
['HTTP_COOKIE']
119 words
= map(string
.strip
, string
.split(raw
, ';'))
122 i
= string
.find(word
, '=')
124 key
, value
= word
[:i
], word
[i
+1:]
128 def load_my_cookie():
129 cookies
= load_cookies()
131 value
= cookies
[COOKIE_NAME
]
135 value
= urllib
.unquote(value
)
136 words
= string
.split(value
, '/')
137 while len(words
) < 3:
139 author
= string
.join(words
[:-2], '/')
142 return {'author': author
,
144 'password': password
}
146 def send_my_cookie(ui
):
148 value
= "%s/%s/%s" % (ui
.author
, ui
.email
, ui
.password
)
150 value
= urllib
.quote(value
)
151 then
= now
+ COOKIE_LIFETIME
152 gmt
= time
.gmtime(then
)
153 path
= os
.environ
.get('SCRIPT_NAME', '/cgi-bin/')
154 print "Set-Cookie: %s=%s; path=%s;" % (name
, value
, path
),
155 print time
.strftime("expires=%a, %d-%b-%y %X GMT", gmt
)
159 def __init__(self
, d
, quote
):
163 def __getitem__(self
, key
):
170 value
= escapeq(value
)
179 self
.__form
= cgi
.FieldStorage()
181 def __getattr__(self
, name
):
185 value
= self
.__form
[name
].value
186 except (TypeError, KeyError):
189 value
= string
.strip(value
)
190 setattr(self
, name
, value
)
193 def __getitem__(self
, key
):
194 return getattr(self
, key
)
198 def __init__(self
, fp
, file, sec_num
):
200 self
.sec
, self
.num
= sec_num
203 self
.__headers
= rfc822
.Message(fp
)
204 self
.body
= string
.strip(fp
.read())
206 self
.__headers
= {'title': "%d.%d. " % sec_num
}
209 def __getattr__(self
, name
):
212 key
= string
.join(string
.split(name
, '_'), '-')
214 value
= self
.__headers
[key
]
217 setattr(self
, name
, value
)
220 def __getitem__(self
, key
):
221 return getattr(self
, key
)
223 def load_version(self
):
224 command
= interpolate(SH_RLOG_H
, self
)
225 p
= os
.popen(command
)
231 if line
[:5] == 'head:':
232 version
= string
.strip(line
[5:])
234 self
.version
= version
237 if not self
.last_changed_date
:
240 return os
.stat(self
.file)[stat
.ST_MTIME
]
244 def emit_marks(self
):
245 mtime
= self
.getmtime()
246 if mtime
>= now
- DT_VERY_RECENT
:
247 emit(MARK_VERY_RECENT
, self
)
248 elif mtime
>= now
- DT_RECENT
:
249 emit(MARK_RECENT
, self
)
251 def show(self
, edit
=1):
252 emit(ENTRY_HEADER1
, self
)
254 emit(ENTRY_HEADER2
, self
)
257 for line
in string
.split(self
.body
, '\n'):
258 # Allow the user to insert raw html into a FAQ answer
259 # (Skip Montanaro, with changes by Guido)
260 tag
= string
.lower(string
.rstrip(line
))
270 if not string
.strip(line
):
277 if line
[0] not in string
.whitespace
:
285 if '/' in line
or '@' in line
:
286 line
= translate(line
, pre
)
287 elif '<' in line
or '&' in line
:
289 if not pre
and '*' in line
:
290 line
= emphasize(line
)
297 emit(ENTRY_FOOTER
, self
)
298 if self
.last_changed_date
:
299 emit(ENTRY_LOGINFO
, self
)
304 entryclass
= FaqEntry
306 __okprog
= re
.compile(OKFILENAME
)
308 def __init__(self
, dir=os
.curdir
):
313 if self
.__files
is not None:
315 self
.__files
= files
= []
316 okprog
= self
.__okprog
317 for file in os
.listdir(self
.__dir
):
318 if self
.__okprog
.match(file):
322 def good(self
, file):
323 return self
.__okprog
.match(file)
325 def parse(self
, file):
329 sec
, num
= m
.group(1, 2)
330 return string
.atoi(sec
), string
.atoi(num
)
333 # XXX Caller shouldn't modify result
337 def open(self
, file):
338 sec_num
= self
.parse(file)
340 raise InvalidFile(file)
344 raise NoSuchFile(file, msg
)
346 return self
.entryclass(fp
, file, sec_num
)
350 def show(self
, file, edit
=1):
351 self
.open(file).show(edit
=edit
)
353 def new(self
, section
):
354 if not SECTION_TITLES
.has_key(section
):
355 raise NoSuchSection(section
)
357 for file in self
.list():
358 sec
, num
= self
.parse(file)
360 maxnum
= max(maxnum
, num
)
361 sec_num
= (section
, maxnum
+1)
362 file = NEWFILENAME
% sec_num
363 return self
.entryclass(None, file, sec_num
)
368 self
.ui
= UserInput()
372 print 'Content-type: text/html'
373 req
= self
.ui
.req
or 'home'
374 mname
= 'do_%s' % req
376 meth
= getattr(self
, mname
)
377 except AttributeError:
378 self
.error("Bad request type %s." % `req`
)
382 except InvalidFile
, exc
:
383 self
.error("Invalid entry file name %s" % exc
.file)
384 except NoSuchFile
, exc
:
385 self
.error("No entry with file name %s" % exc
.file)
386 except NoSuchSection
, exc
:
387 self
.error("No section number %s" % exc
.section
)
390 def error(self
, message
, **kw
):
391 self
.prologue(T_ERROR
)
394 def prologue(self
, title
, entry
=None, **kw
):
395 emit(PROLOGUE
, entry
, kwdict
=kw
, title
=escape(title
))
401 self
.prologue(T_HOME
)
405 self
.prologue("FAQ Wizard Debugging")
406 form
= cgi
.FieldStorage()
408 cgi
.print_environ(os
.environ
)
409 cgi
.print_directory()
410 cgi
.print_arguments()
413 query
= self
.ui
.query
415 self
.error("Empty query string!")
417 if self
.ui
.querytype
== 'simple':
418 query
= re
.escape(query
)
420 elif self
.ui
.querytype
in ('anykeywords', 'allkeywords'):
421 words
= filter(None, re
.split('\W+', query
))
423 self
.error("No keywords specified!")
425 words
= map(lambda w
: r
'\b%s\b' % w
, words
)
426 if self
.ui
.querytype
[:3] == 'any':
427 queries
= [string
.join(words
, '|')]
429 # Each of the individual queries must match
432 # Default to regular expression
434 self
.prologue(T_SEARCH
)
436 for query
in queries
:
437 if self
.ui
.casefold
== 'no':
438 p
= re
.compile(query
)
440 p
= re
.compile(query
, re
.IGNORECASE
)
443 for file in self
.dir.list():
445 entry
= self
.dir.open(file)
449 if not p
.search(entry
.title
) and not p
.search(entry
.body
):
454 emit(NO_HITS
, self
.ui
, count
=0)
455 elif len(hits
) <= MAXHITS
:
457 emit(ONE_HIT
, count
=1)
459 emit(FEW_HITS
, count
=len(hits
))
460 self
.format_all(hits
, headers
=0)
462 emit(MANY_HITS
, count
=len(hits
))
463 self
.format_index(hits
)
467 files
= self
.dir.list()
468 self
.last_changed(files
)
469 self
.format_index(files
, localrefs
=1)
470 self
.format_all(files
)
473 files
= self
.dir.list()
475 self
.last_changed(files
)
476 self
.format_index(files
, localrefs
=1)
477 self
.format_all(files
, edit
=0)
478 sys
.exit(0) # XXX Hack to suppress epilogue
480 def last_changed(self
, files
):
483 entry
= self
.dir.open(file)
485 mtime
= mtime
= entry
.getmtime()
488 print time
.strftime(LAST_CHANGED
, time
.localtime(latest
))
491 def format_all(self
, files
, edit
=1, headers
=1):
495 entry
= self
.dir.open(file)
498 if headers
and entry
.sec
!= sec
:
501 title
= SECTION_TITLES
[sec
]
504 emit("\n<HR>\n<H1>%(sec)s. %(title)s</H1>\n",
505 sec
=sec
, title
=title
)
506 entry
.show(edit
=edit
)
509 self
.prologue(T_INDEX
)
510 files
= self
.dir.list()
511 self
.last_changed(files
)
512 self
.format_index(files
, add
=1)
514 def format_index(self
, files
, add
=0, localrefs
=0):
518 entry
= self
.dir.open(file)
524 emit(INDEX_ADDSECTION
, sec
=sec
)
525 emit(INDEX_ENDSECTION
, sec
=sec
)
528 title
= SECTION_TITLES
[sec
]
531 emit(INDEX_SECTION
, sec
=sec
, title
=title
)
533 emit(LOCAL_ENTRY
, entry
)
535 emit(INDEX_ENTRY
, entry
)
539 emit(INDEX_ADDSECTION
, sec
=sec
)
540 emit(INDEX_ENDSECTION
, sec
=sec
)
546 days
= string
.atof(self
.ui
.days
)
548 cutoff
= now
- days
* 24 * 3600
549 except OverflowError:
552 for file in self
.dir.list():
553 entry
= self
.dir.open(file)
556 mtime
= entry
.getmtime()
558 list.append((mtime
, file))
561 self
.prologue(T_RECENT
)
563 period
= "%.2g hours" % (days
*24)
565 period
= "%.6g days" % days
567 emit(NO_RECENT
, period
=period
)
569 emit(ONE_RECENT
, period
=period
)
571 emit(SOME_RECENT
, period
=period
, count
=len(list))
572 self
.format_all(map(lambda (mtime
, file): file, list), headers
=0)
575 def do_roulette(self
):
577 files
= self
.dir.list()
579 self
.error("No entries.")
581 file = random
.choice(files
)
582 self
.prologue(T_ROULETTE
)
587 self
.prologue(T_HELP
)
591 entry
= self
.dir.open(self
.ui
.file)
592 self
.prologue(T_SHOW
)
598 sections
= SECTION_TITLES
.items()
600 for section
, title
in sections
:
601 emit(ADD_SECTION
, section
=section
, title
=title
)
605 self
.prologue(T_DELETE
)
609 entry
= self
.dir.open(self
.ui
.file)
610 self
.prologue(T_LOG
, entry
)
612 self
.rlog(interpolate(SH_RLOG
, entry
), entry
)
614 def rlog(self
, command
, entry
=None):
615 output
= os
.popen(command
).read()
616 sys
.stdout
.write('<PRE>')
618 lines
= string
.split(output
, '\n')
619 while lines
and not lines
[-1]:
623 if line
[:1] == '=' and len(line
) >= 40 and \
624 line
== line
[0]*len(line
):
628 if entry
and athead
and line
[:9] == 'revision ':
629 rev
= string
.strip(line
[9:])
634 emit(REVISIONLINK
, entry
, rev
=rev
, line
=line
)
636 prev
= "%d.%d" % (mami
[0], mami
[1]-1)
637 emit(DIFFLINK
, entry
, prev
=prev
, rev
=rev
)
639 emit(DIFFLINK
, entry
, prev
=rev
, rev
=headrev
)
646 if line
[:1] == '-' and len(line
) >= 20 and \
647 line
== len(line
) * line
[0]:
649 sys
.stdout
.write('<HR>')
654 def do_revision(self
):
655 entry
= self
.dir.open(self
.ui
.file)
659 self
.error("Invalid revision number: %s." % `rev`
)
660 self
.prologue(T_REVISION
, entry
)
661 self
.shell(interpolate(SH_REVISION
, entry
, rev
=rev
))
664 entry
= self
.dir.open(self
.ui
.file)
669 self
.error("Invalid revision number: %s." % `rev`
)
671 if not revparse(prev
):
672 self
.error("Invalid previous revision number: %s." % `prev`
)
674 prev
= '%d.%d' % (mami
[0], mami
[1])
675 self
.prologue(T_DIFF
, entry
)
676 self
.shell(interpolate(SH_RDIFF
, entry
, rev
=rev
, prev
=prev
))
678 def shell(self
, command
):
679 output
= os
.popen(command
).read()
680 sys
.stdout
.write('<PRE>')
685 entry
= self
.dir.new(section
=string
.atoi(self
.ui
.section
))
686 entry
.version
= '*new*'
687 self
.prologue(T_EDIT
)
689 emit(EDITFORM1
, entry
, editversion
=entry
.version
)
690 emit(EDITFORM2
, entry
, load_my_cookie())
695 entry
= self
.dir.open(self
.ui
.file)
697 self
.prologue(T_EDIT
)
699 emit(EDITFORM1
, entry
, editversion
=entry
.version
)
700 emit(EDITFORM2
, entry
, load_my_cookie())
705 send_my_cookie(self
.ui
)
706 if self
.ui
.editversion
== '*new*':
707 sec
, num
= self
.dir.parse(self
.ui
.file)
708 entry
= self
.dir.new(section
=sec
)
709 entry
.version
= "*new*"
710 if entry
.file != self
.ui
.file:
711 self
.error("Commit version conflict!")
712 emit(NEWCONFLICT
, self
.ui
, sec
=sec
, num
=num
)
715 entry
= self
.dir.open(self
.ui
.file)
717 # Check that the FAQ entry number didn't change
718 if string
.split(self
.ui
.title
)[:1] != string
.split(entry
.title
)[:1]:
719 self
.error("Don't change the entry number please!")
721 # Check that the edited version is the current version
722 if entry
.version
!= self
.ui
.editversion
:
723 self
.error("Commit version conflict!")
724 emit(VERSIONCONFLICT
, entry
, self
.ui
)
726 commit_ok
= ((not PASSWORD
727 or self
.ui
.password
== PASSWORD
)
729 and '@' in self
.ui
.email
737 self
.prologue(T_REVIEW
)
739 entry
.body
= self
.ui
.body
740 entry
.title
= self
.ui
.title
742 emit(EDITFORM1
, self
.ui
, entry
)
749 emit(EDITFORM2
, self
.ui
, entry
, load_my_cookie())
752 def cantcommit(self
):
753 self
.prologue(T_CANTCOMMIT
)
754 print CANTCOMMIT_HEAD
756 print CANTCOMMIT_TAIL
758 def errordetail(self
):
759 if PASSWORD
and self
.ui
.password
!= PASSWORD
:
763 if not self
.ui
.author
:
765 if not self
.ui
.email
:
768 def commit(self
, entry
):
770 # Normalize line endings in body
771 if '\r' in self
.ui
.body
:
772 self
.ui
.body
= re
.sub('\r\n?', '\n', self
.ui
.body
)
773 # Normalize whitespace in title
774 self
.ui
.title
= string
.join(string
.split(self
.ui
.title
))
775 # Check that there were any changes
776 if self
.ui
.body
== entry
.body
and self
.ui
.title
== entry
.title
:
777 self
.error("You didn't make any changes!")
779 # XXX Should lock here
787 self
.error(CANTWRITE
, file=file, why
=why
)
789 date
= time
.ctime(now
)
790 emit(FILEHEADER
, self
.ui
, os
.environ
, date
=date
, _file
=f
, _quote
=0)
792 f
.write(self
.ui
.body
)
797 tfn
= tempfile
.mktemp()
799 emit(LOGHEADER
, self
.ui
, os
.environ
, date
=date
, _file
=f
)
802 command
= interpolate(
803 SH_LOCK
+ '\n' + SH_CHECKIN
,
806 p
= os
.popen(command
)
809 # XXX Should unlock here
811 self
.prologue(T_COMMITTED
)
814 self
.error(T_COMMITFAILED
)
815 emit(COMMITFAILED
, sts
=sts
)
816 print '<PRE>%s</PRE>' % escape(output
)
823 entry
= self
.dir.open(file)