Initial commit - stahg-gopher - Static Mercurial page generator for gopher HTML hg clone https://bitbucket.org/iamleot/stahg-gopher DIR Log DIR Files DIR Refs DIR README DIR LICENSE --- DIR changeset 952e00b3a83e846a78c6f8669747d8fa6c957f82 HTML Author: Leonardo Taccari <iamleot@gmail.com> Date: Sun, 12 May 2019 21:49:58 Initial commit Diffstat: README | 7 + stahg.py | 337 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 0 deletions(-) --- diff -r 000000000000 -r 952e00b3a83e README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README Sun May 12 21:49:58 2019 +0200 @@ -0,0 +1,7 @@ +stahg-gopher +============ + +Static Mercurial page generator for gopher. + +stahg-gopher is a stagit-gopher clone for Mercurial. It generates +pages in the geomyidae .gph file format. diff -r 000000000000 -r 952e00b3a83e stahg.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/stahg.py Sun May 12 21:49:58 2019 +0200 @@ -0,0 +1,337 @@ +#!/usr/bin/env python3.7 + +# +# Copyright (c) 2019 Leonardo Taccari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS +# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + + +import datetime +import os +import shutil +import stat + +import hglib + + +LICENSE_FILES = [ 'LICENSE', 'LICENSE.md', 'COPYING' ] +README_FILES = [ 'README', 'README.md' ] + + +def gph_escape_entry(text): + """Render text entry `[...]' by escaping/translating characters""" + escaped_text = text.expandtabs().replace('|', '\|') + + return escaped_text + + +def gph_escape_text(text): + """Render text to .gph by escaping/translating characters""" + escaped_text = [] + + for line in text.expandtabs().splitlines(): + # add leading 't' if needed + if len(line) > 0 and line[0] == 't': + line = 't' + line + + escaped_text.append(line) + + return '\n'.join(escaped_text) + + +def shorten(text, n=80): + """Shorten text to the first `n' character of first line""" + s, _, _ = text.partition('\n') + + if len(s) > n: + s = s[:n - 1] + '…' + + return s + + +def rshorten(text, n=80): + """Shorten text to the last `n' character of first line""" + s, _, _ = text.partition('\n') + + if len(s) > n: + s = '…' + s[- (n - 1):] + + return s + + +def author_name(author): + """Given an author `Name <email>' extract their name""" + name, _, _ = author.rpartition(' <') + + return name + + +def author_email(author): + """Given an author `Name <email>' extract their email""" + _, _, email = author.rpartition(' <') + email = email.rstrip('>') + + return email + + +class Stahg: + def __init__(self, base_prefix='', limit=None): + self.base_prefix = base_prefix + self.client = None + self.description = '' + self.license = None + self.limit = limit + self.readme = None + self.repodir = '' + self.repository = '' + self.url = '' + + + def open(self, repodir): + """Open repository in repodir""" + self.repodir = os.path.normpath(repodir) + self.client = hglib.open(self.repodir) + self.base_prefix = base_prefix + self.repository = os.path.basename(self.repodir) + + try: + for _, k, value in self.client.config([b'web']): + if k == 'description': + self.description = value + break + except: + self.description = \ + "Unnamed repository, adjust .hg/hgrc `[web]' section, `description' key" + + # XXX: For repository with a lot of files this is suboptimal... + # XXX: Is there a simpler way to check for that? + for e in self.client.manifest(rev=b'tip'): + fpath = e[4].decode() + + # file paths are sorted, break as soon as possible + if fpath > max(LICENSE_FILES) and fpath > max(README_FILES): + break + + if fpath in LICENSE_FILES: + self.license = fpath + if fpath in README_FILES: + self.readme = fpath + + + def close(self): + """Close repository""" + self.client.close() + + + def menu(self): + """Generate menu for .gph files""" + bp = gph_escape_entry(self.base_prefix) + + m = '[1|Log|' + bp + '/log.gph|server|port]\n' + \ + '[1|Files|' + bp + '/files.gph|server|port]' + + if self.readme: + m += '\n[1|README|' + bp + '/file/{file}.gph|server|port]'.format( + file=self.readme) + + if self.license: + m += '\n[1|LICENSE|' + bp + '/file/{file}.gph|server|port]'.format( + file=self.license) + + return m + + + def title(self, text): + """Generate title for .gph files""" + return gph_escape_text( + ' - '.join([text, self.repository, self.description])) + + + def log(self): + """Generate log.gph with latest commits""" + bp = gph_escape_entry(self.base_prefix) + fname = 'log.gph' + + with open(fname, 'w') as f: + print(self.title('Log'), file=f) + print(self.menu(), file=f) + print('---', file=f) + + print('{:16} {:40} {}'.format('Date', 'Commit message', 'Author'), + file=f) + for i, e in enumerate(self.client.log()): + if self.limit and i > self.limit: + print(' More commits remaining [...]', + file=f) + break + print('[1|{desc}|{path}|server|port]'.format( + desc='{date:16} {commit_message:40} {author}'.format( + date=e.date.strftime('%Y-%m-%d %H:%M'), + commit_message=gph_escape_entry(shorten(e.desc.decode(), 40)), + author=author_name(e.author.decode())), + path='{base_path}/commit/{changeset}.gph'.format( + base_path=bp, + changeset=e.node.decode())), file=f) + + + def files(self): + """Generate files.gph with links to all files in `tip'""" + bp = gph_escape_entry(self.base_prefix) + fname = 'files.gph' + + with open(fname, 'w') as f: + print(self.title('Files'), file=f) + print(self.menu(), file=f) + print('---', file=f) + + print('{:10} {:68}'.format('Mode', 'Name'), file=f) + + for e in self.client.manifest(rev=b'tip'): + print('[1|{desc}|{path}|server|port]'.format( + desc='{mode:10} {name:68}'.format( + mode=stat.filemode(int(e[1].decode(), base=8)), + name=gph_escape_entry(e[4].decode())), + path='{base_path}/file/{file}.gph'.format( + base_path=bp, + file=gph_escape_entry(e[4].decode()))), file=f) + + + def refs(self): + """Generate refs.gph listing all branches and tags""" + pass # TODO + + + def commit(self, changeset): + """Generate commit/<changeset>.gph with commit message and diff""" + bp = gph_escape_entry(self.base_prefix) + c = self.client[changeset] + fname = 'commit/{changeset}.gph'.format(changeset=c.node().decode()) + + with open(fname, 'w') as f: + print(self.title(shorten(c.description().decode(), 80)), file=f) + print(self.menu(), file=f) + print('---', file=f) + + print('[1|{desc}|{path}|server|port]'.format( + desc='changeset {changeset}'.format(changeset=c.node().decode()), + path='{base_path}/commit/{changeset}.gph'.format( + base_path=bp, + changeset=c.node().decode())), file=f) + + for p in c.parents(): + if p.node() == b'0000000000000000000000000000000000000000': + continue + print('[1|{desc}|{path}|server|port]'.format( + desc='parent {changeset}'.format(changeset=p.node().decode()), + path='{base_path}/commit/{changeset}.gph'.format( + base_path=bp, + changeset=p.node().decode())), file=f) + + print('[h|Author: {author}|URL:mailto:{email}|server|port]'.format( + author=gph_escape_entry(c.author().decode()), + email=gph_escape_entry(author_email(c.author().decode()))), file=f) + + print('Date: {date}'.format( + date=c.date().strftime('%a, %e %b %Y %H:%M:%S %z')), file=f) + + print(file=f) + print(gph_escape_text(c.description().decode()), file=f) + print(file=f) + + print('Diffstat:', file=f) + print(gph_escape_text(self.client.diff(change=c.node(), stat=True).decode().rstrip()), + file=f) + print('---', file=f) + + print(gph_escape_text(self.client.diff(change=c.node()).decode()), + file=f) + + + def file(self, file): + """Generate file/<file>.gph listing <file> at `tip'""" + bp = gph_escape_entry(self.base_prefix) + fname = 'file/{file}.gph'.format(file=file.decode()) + os.makedirs(os.path.dirname(fname), exist_ok=True) + + with open(fname, 'w') as f: + print(self.title(os.path.basename(file.decode())), file=f) + print(self.menu(), file=f) + print('---', file=f) + + print('{filename}'.format( + filename=os.path.basename(file.decode())), file=f) + print('---', file=f) + + files = [self.client.root() + os.sep.encode() + file] + for num, line in enumerate(self.client.cat(files).decode().splitlines(), start=1): + print('{num:6d} {line}'.format( + num=num, + line=gph_escape_text(line)), file=f) + + +if __name__ == '__main__': + import getopt + import sys + + def usage(): + print('usage: {} [-b baseprefix] [-l commits] repodir'.format( + sys.argv[0])) + exit(1) + + try: + opts, args = getopt.getopt(sys.argv[1:], 'b:l:') + except: + usage() + + if len(args) != 1: + usage() + + base_prefix = '' + limit = None + for o, a in opts: + if o == '-b': + base_prefix = a + elif o == '-l': + limit = int(a) + + repodir = args[0] + + sh = Stahg(base_prefix=base_prefix, limit=limit) + sh.open(repodir) + + sh.log() + sh.files() + sh.refs() + + shutil.rmtree('file', ignore_errors=True) + os.makedirs('file', exist_ok=True) + for e in sh.client.manifest(rev=b'tip'): + sh.file(e[4]) + + os.makedirs('commit', exist_ok=True) + for e in sh.client.log(): + if os.path.exists('commit/{changeset}.gph'.format(changeset=e.node.decode())): + break + sh.commit(e.node)