author | Alberto Bertogli
<albertito@gmail.com> 2007-12-31 01:47:39 UTC |
committer | Alberto Bertogli
<albertito@gmail.com> 2007-12-31 03:52:41 UTC |
parent | c385634b23cff3c26172a62a74a223d912c07e12 |
config.py.sample | +4 | -0 |
wikiri.cgi | +360 | -13 |
diff --git a/config.py.sample b/config.py.sample index 8ec4dc8..b0d9e7d 100644 --- a/config.py.sample +++ b/config.py.sample @@ -23,3 +23,7 @@ css_url = wiki_url + "/style" # Wiki title. title = "I like wikis" +# History backend. Can be "none" for no history, or "git" to use git (needs +# git and "git init" to be called on the data path). +history = "none" + diff --git a/wikiri.cgi b/wikiri.cgi index 4bba9dc..47a9da5 100755 --- a/wikiri.cgi +++ b/wikiri.cgi @@ -28,6 +28,10 @@ css_url = wiki_url + "/style" # Wiki title. title = "I like wikis" +# History backend. Can be "none" for no history, or "git" to use git (needs +# git and "git init" to be called on the data path). +history = "none" + # # End of configuration # DO *NOT* EDIT ANYTHING PAST HERE @@ -150,6 +154,7 @@ default_article_footer = """ <div class="footer"> <a href="%(url)s/about">about</a> | <a href="%(url)s/help">help</a> | + <a href="%(url)s/%(artqname)s/log">view history</a> | <a href="%(url)s/%(artqname)s/edit">edit this page</a><br/> </div> </div> @@ -161,13 +166,19 @@ default_edit_page = """ <div class="editpage"> <form method="POST" action="save"> + <label for="newtitle">Title: </label> <input id="newtitle" type="text" name="newtitle" value="%(arttitle)s"/><p/> + <textarea name="newcontent" cols="76" rows="25"> %(content)s </textarea><p/> +<label for="comment">Change summary: </label> +<input id="comment" type="text" name="comment" + size="50" value=""/><p/> + <input type="submit" value="Save"/> </form> <p/> @@ -248,6 +259,31 @@ If you have any questions or comments, please send an email to </div> """ +default_log_header = """ +<h2>Change history for <a href="%(url)s/%(artqname)s">%(artname)s</a></h2> +<p/> + +<table class="log"> +""" + +default_log_entry = """ +<tr> + <td class="date"> + %(date_y)04d-%(date_m)02d-%(date_d)02d + %(date_H)02d:%(date_M)02d:%(date_S)02d + </td> + <td class="summary">%(summary)s</td> + <td class="links"> + <a href="%(url)s/logview/%(commitid)s/%(artqname)s">view</a> | + <a href="%(url)s/restore/%(commitid)s/%(artqname)s">restore</a> + </td> +</tr> +""" + +default_log_footer = """ +</table> +""" + # Default CSS default_css = """ body { @@ -318,6 +354,30 @@ div.footer a { text-decoration: none; } +table.log { + border-right: 1px solid #6666cc; + border-collapse: collapse; + margin-left: 1em; +} + +table.log td { + border-left: 1px solid #6666cc; + padding-right: 1em; + padding-left: 1em; +} + +table.log td.date { + text-style: italic; +} + +table.log td.summary { + +} + +table.log td.links { + font-size: small; +} + /* Articles are enclosed in <div class="section"> */ div.section h1 { font-size: medium; @@ -431,6 +491,47 @@ class Templates (object): return open(p).read() % self.vars return default_about_page % self.vars + def get_log_header(self, artname): + avars = self.vars.copy() + avars.update( { + 'artname': artname, + 'artqname': urllib.quote_plus(artname, safe = ''), + } ) + p = self.tpath + '/log_header.html' + if os.path.isfile(p): + return open(p).read() % avars + return default_log_header % avars + + def get_log_entry(self, artname, commit): + avars = self.vars.copy() + avars.update( { + 'artname': artname, + 'artqname': urllib.quote_plus(artname, safe = ''), + 'summary': commit['msg'], + 'commitid': commit['commit'], + 'date_y': commit['atime'].year, + 'date_m': commit['atime'].month, + 'date_d': commit['atime'].day, + 'date_H': commit['atime'].hour, + 'date_M': commit['atime'].minute, + 'date_S': commit['atime'].second, + } ) + p = self.tpath + '/log_entry.html' + if os.path.isfile(p): + return open(p).read() % avars + return default_log_entry % avars + + def get_log_footer(self, artname): + avars = self.vars.copy() + avars.update( { + 'artname': artname, + 'artqname': urllib.quote_plus(artname, safe = ''), + } ) + p = self.tpath + '/log_footer.html' + if os.path.isfile(p): + return open(p).read() % avars + return default_log_footer % avars + # @@ -438,13 +539,17 @@ class Templates (object): # class Article (object): - def __init__(self, name): + def __init__(self, name, content = None): self.name = name self.qname = urllib.quote_plus(name, safe = "") self.updated = None self.loaded = False + self.preloaded_content = None + if content: + self.preloaded_content = content + # loaded on demand self.attrs = {} self._raw_content = '' @@ -464,10 +569,15 @@ class Article (object): def load(self): try: - fd = open(data_path + '/' + self.qname) - raw = fd.readlines() - stat = os.fstat(fd.fileno()) - self.updated = datetime.datetime.fromtimestamp(stat.st_mtime) + if self.preloaded_content: + raw = self.preloaded_content + raw = [ s + '\n' for s in raw.split('\n') ] + self.updated = datetime.datetime.now() + else: + fd = open(data_path + '/' + self.qname) + raw = fd.readlines() + stat = os.fstat(fd.fileno()) + self.updated = datetime.datetime.fromtimestamp(stat.st_mtime) except: t = Templates() raw = t.get_default_article_content(self.name) @@ -488,12 +598,18 @@ class Article (object): self._raw_content = ''.join(raw[count + 1:]) self.loaded = True - def save(self, newtitle, newcontent): + def save(self, newtitle, newcontent, raw = False): fd = open(data_path + '/' + self.qname, 'w+') - fd.write('title: %s\n\n' % newtitle) - fd.write(newcontent.rstrip() + '\n') + if raw: + fd.write(newcontent) + else: + fd.write('title: %s\n\n' % newtitle) + fd.write(newcontent.rstrip() + '\n') fd.close() + # invalidate our information + self.loaded = False + def remove(self): try: os.unlink(data_path + '/' + self.qname) @@ -504,6 +620,194 @@ class Article (object): return content2html(self.raw_content) +# +# History backends +# +# At the moment we only support git (it's optional, though) + +class HistoryError (Exception): + pass + +class History: + def __init__(self): + if history == 'git': + self.be = GitBackend(data_path) + else: + self.be = NoneBackend() + + def commit(self, msg, author = 'Wikiri <somebody@wikiri>'): + self.be.commit(msg, author = author) + + def log(self, fname): + return self.be.log(file = fname) + + def add(self, *files): + return self.be.add(*files) + + def remove(self, *files): + return self.be.remove(*files) + + def get_content(self, fname, cid): + return self.be.get_content(fname, cid) + + def get_commit(self, cid): + return self.be.get_commit(cid) + +class NoneBackend: + def __init__(self, repopath): + pass + + def commit(self, *args): + pass + + def log(self, *args): + return () + + def add(self, *files): + pass + + def remove(self, *files): + pass + + def get_content(self, fname, cid): + return "Not supported." + + def get_commit(self, cid): + return { 'commit': cid } + +class GitBackend: + def __init__(self, repopath): + self.repo = repopath + self.prevdir = None + + def cdrepo(self): + self.prevdir = os.getcwd() + os.chdir(self.repo) + + def cdback(self): + os.chdir(self.prevdir) + + def git(self, *args): + # delay the import to avoid the hit on a regular page view + import subprocess + self.cdrepo() + cmd = subprocess.Popen( ['git'] + list(args), + stdin = subprocess.PIPE, + stdout = subprocess.PIPE, + stderr = sys.stderr ) + self.cdback() + return cmd + + def gitq(self, *args): + cmd = self.git(*args) + stdout, stderr = cmd.communicate() + return cmd.returncode + + def commit(self, msg, author = None): + if not author: + author = "Unknown <unknown@example.com>" + + # see if we have something to commit; if not, just return + self.gitq('update-index', '--refresh') + r = self.gitq('diff-index', '--exit-code', '--quiet', 'HEAD') + if r == 0: + return + + r = self.gitq('commit', + '-m', msg, + '--author', author) + if r != 0: + raise HistoryError, r + + def log(self, file = None, files = None): + if not files: + files = [] + if file: + files.append(file) + + cmd = self.git("rev-list", + "--all", "--pretty=raw", + "HEAD", "--", *files) + cmd.stdin.close() + + commit = { 'msg': '' } + in_hdr = True + l = cmd.stdout.readline() + while l: + if l != '\n' and not l.startswith(' '): + name, value = l[:-1].split(' ', 1) + if in_hdr: + commit[name] = value + else: + # the previous commit has ended + _add_times(commit) + yield commit + commit = { 'msg': '' } + in_hdr = True + + # continue reusing the line + continue + else: + if in_hdr: + in_hdr = False + if l.startswith(' '): + l = l[4:] + commit['msg'] += l + + l = cmd.stdout.readline() + + # the last commit, if there is one + if not in_hdr: + _add_times(commit) + yield commit + + cmd.wait() + if cmd.returncode != 0: + raise HistoryError, cmd.returncode + + def add(self, *files): + r = self.gitq('add', "--", *files) + if r != 0: + raise HistoryError, r + + def remove(self, *files): + r = self.gitq('rm', '-f', '--', *files) + if r != 0: + raise HistoryError, r + + def get_content(self, fname, commitid): + cmd = self.git("show", "%s:%s" % (commitid, fname)) + content = cmd.stdout.read() + cmd.wait() + return content + + def get_commit(self, cid): + cmd = self.git("rev-list", "-n1", "--pretty=raw", cid) + out = cmd.stdout.readlines() + cmd.wait() + commit = { 'msg': '' } + for l in out: + if l != '\n' and not l.startswith(' '): + name, value = l[:-1].split(' ', 1) + commit[name] = value + else: + commit['msg'] += l + _add_times(commit) + return commit + +def _add_times(commit): + if 'author' in commit: + author, epoch, tz = commit['author'].rsplit(' ', 2) + epoch = float(epoch) + commit['author'] = author + commit['atime'] = datetime.datetime.fromtimestamp(epoch) + + if 'committer' in commit: + committer, epoch, tz = commit['committer'].rsplit(' ', 2) + epoch = float(epoch) + commit['committer'] = committer + commit['ctime'] = datetime.datetime.fromtimestamp(epoch) + # # Main @@ -532,6 +836,16 @@ def render_removed(artname): print template.get_removed_page(artname) print template.get_main_footer() +def render_log(artname, log): + template = Templates() + print 'Content-type: text/html; charset=utf-8\n' + print template.get_main_header() + print template.get_log_header(artname) + for commit in log: + print template.get_log_entry(artname, commit) + print template.get_log_footer(artname) + print template.get_main_footer() + def render_help(): template = Templates() print 'Content-type: text/html; charset=utf-8\n' @@ -557,10 +871,14 @@ def handle_cgi(): edit = False save = False + log = False artname = 'index' newcontent = form.getfirst("newcontent", '') newtitle = form.getfirst("newtitle", '').strip() + comment = form.getfirst("comment", 'No comment').strip() + if not comment: + comment = "No comment" if os.environ.has_key('PATH_INFO'): path_info = os.environ['PATH_INFO'] @@ -568,8 +886,9 @@ def handle_cgi(): edit = path_info.endswith('/edit') save = path_info.endswith('/save') + log = path_info.endswith('/log') - if edit or save: + if edit or save or log: artname = path_info[1:].rsplit('/', 1)[0] else: artname = path_info[1:] @@ -589,12 +908,40 @@ def handle_cgi(): elif edit: render_edit(Article(artname)) elif save: + h = History() + a = Article(artname) if newcontent.strip(): - Article(artname).save(newtitle, newcontent) - render_article(Article(artname)) + a.save(newtitle, newcontent) + h.add(a.qname) + h.commit(msg = comment) + render_article(a) else: - Article(artname).remove() - render_removed(artname) + a.remove() + h.remove(a.qname) + h.commit(msg = comment) + render_removed(a.name) + elif log: + a = Article(artname) + render_log(a.name, History().log(a.qname)) + elif artname.startswith("logview/"): + unused, cid, artname = artname.split('/', 2) + artname = urllib.unquote_plus(artname) + oldcontent = History().get_content(Article(artname).qname, cid) + render_article(Article(artname, content = oldcontent)) + elif artname.startswith("restore/"): + unused, cid, artname = artname.split('/', 2) + artname = urllib.unquote_plus(artname) + + h = History() + a = Article(artname) + oldcontent = h.get_content(a.qname, cid) + + a.save(None, oldcontent, raw = True) + h.add(a.qname) + ctime = h.get_commit(cid)['atime'].strftime("%Y-%m-%d %H:%M:%S") + h.commit(msg = 'Restored ' + ctime) + + render_article(a) else: render_article(Article(artname))