git » wikiri » commit 3dfb879

Implement optional history tracking with a git backend.

author Alberto Bertogli
2007-12-31 01:47:39 UTC
committer Alberto Bertogli
2007-12-31 03:52:41 UTC
parent c385634b23cff3c26172a62a74a223d912c07e12

Implement optional history tracking with a git backend.

Signed-off-by: Alberto Bertogli <albertito@gmail.com>

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))