Initial commit - stahg-gopher - Static Mercurial page generator for gopher
 (HTM) hg clone https://bitbucket.org/iamleot/stahg-gopher
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) README
 (DIR) LICENSE
       ---
 (DIR) changeset 952e00b3a83e846a78c6f8669747d8fa6c957f82
 (HTM) 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)