summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rwxr-xr-xganarchy.py346
1 files changed, 260 insertions, 86 deletions
diff --git a/ganarchy.py b/ganarchy.py
index 9b6c1bc..3ffd31c 100755
--- a/ganarchy.py
+++ b/ganarchy.py
@@ -23,9 +23,106 @@ import subprocess
 import hashlib
 import hmac
 import jinja2
+import re
 
-# default HTML, can be overridden in $XDG_DATA_HOME/ganarchy/template.html or the $XDG_DATA_DIRS (TODO)
-TEMPLATE = """<!DOCTYPE html>
+MIGRATIONS = {
+        "gen-index": (
+                (
+                    """ALTER TABLE "config" ADD COLUMN "title" TEXT"""),
+                (),
+                "supports generating an index page"
+            ),
+        "better-project-management": (
+                (
+                    """ALTER TABLE "repos" ADD COLUMN "branch" TEXT""",
+                    """ALTER TABLE "repos" ADD COLUMN "project" TEXT""",
+                    """CREATE UNIQUE INDEX "repos_url_branch_project" ON "repos" ("url", "branch", "project")""",
+                    """CREATE INDEX "repos_project" ON "repos" ("project")""",
+                    """ALTER TABLE "repo_history" ADD COLUMN "branch" TEXT""",
+                    """ALTER TABLE "repo_history" ADD COLUMN "project" TEXT""",
+                    """CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")"""),
+                (
+                    """DELETE FROM "repos" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL""",
+                    """DELETE FROM "repo_history" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL"""),
+                "supports multiple projects, and allows choosing non-default branches"
+            ),
+        "test": (
+                ("""-- apply""",),
+                ("""-- revert""",),
+                "does nothing"
+            )
+        }
+
+data_home = os.environ.get('XDG_DATA_HOME', '')
+if not data_home:
+    data_home = os.environ['HOME'] + '/.local/share'
+data_home = data_home + "/ganarchy"
+
+cache_home = os.environ.get('XDG_CACHE_HOME', '')
+if not cache_home:
+    cache_home = os.environ['HOME'] + '/.cache'
+cache_home = cache_home + "/ganarchy"
+
+config_home = os.environ.get('XDG_CONFIG_HOME', '')
+if not config_home:
+    config_home = os.environ['HOME'] + '/.config'
+config_home = config_home + "/ganarchy"
+
+config_dirs = os.environ.get('XDG_CONFIG_DIRS', '')
+if not config_dirs:
+    config_dirs = '/etc/xdg'
+# TODO check if this is correct
+config_dirs = [config_dir + "/ganarchy" for config_dir in config_dirs.split(':')]
+
+def get_template_loader():
+    from jinja2 import DictLoader, FileSystemLoader, ChoiceLoader
+    return ChoiceLoader([
+        FileSystemLoader([config_home + "/templates"] + [config_dir + "/templates" for config_dir in config_dirs]),
+        DictLoader({
+            ## index.html
+            'index.html': """<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <!--
+        GAnarchy - project homepage generator
+        Copyright (C) 2019  Soni L.
+
+        This program is free software: you can redistribute it and/or modify
+        it under the terms of the GNU Affero General Public License as published by
+        the Free Software Foundation, either version 3 of the License, or
+        (at your option) any later version.
+
+        This program is distributed in the hope that it will be useful,
+        but WITHOUT ANY WARRANTY; without even the implied warranty of
+        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+        GNU Affero General Public License for more details.
+
+        You should have received a copy of the GNU Affero General Public License
+        along with this program.  If not, see <https://www.gnu.org/licenses/>.
+        -->
+        <title>{{ ganarchy.title|e }}</title>
+        <meta name="description" content="{{ ganarchy.title|e }}" />
+        <!--if your browser doesn't like the following, use a different browser.-->
+        <script type="application/javascript" src="/index.js"></script>
+    </head>
+    <body>
+        <h1>{{ ganarchy.title|e }}</h1>
+        <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p>
+        <ul>
+        {% for project in ganarchy.projects -%}
+            <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li>
+        {% endfor -%}
+        </ul>
+        <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
+        <p>
+            <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
+        </p>
+    </body>
+</html>
+""",
+            ## project.html FIXME
+            'project.html': """<!DOCTYPE html>
 <html lang="en">
     <head>
         <meta charset="utf-8" />
@@ -57,7 +154,7 @@ TEMPLATE = """<!DOCTYPE html>
         <ul>
         {% for url, msg, img, branch in repos -%}
             <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li>
-        {%- endfor %}
+        {% endfor -%}
         </ul>
         <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
         <p>
@@ -66,45 +163,12 @@ TEMPLATE = """<!DOCTYPE html>
         </p>
     </body>
 </html>
-"""
+""",
+            ## history.svg FIXME
+            'history.svg': """""",
+        })
+    ])
 
-MIGRATIONS = {
-        "better-project-management": (
-                (
-                    """ALTER TABLE "repos" ADD COLUMN "branch" TEXT""",
-                    """ALTER TABLE "repos" ADD COLUMN "project" TEXT""",
-                    """CREATE UNIQUE INDEX "repos_url_branch_project" ON "repos" ("url", "branch", "project")""",
-                    """CREATE INDEX "repos_project" ON "repos" ("project")""",
-                    """ALTER TABLE "repo_history" ADD COLUMN "branch" TEXT""",
-                    """ALTER TABLE "repo_history" ADD COLUMN "project" TEXT""",
-                    """CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")"""),
-                (
-                    """DELETE FROM "repos" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL""",
-                    """DELETE FROM "repo_history" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL"""),
-                "supports multiple projects, and allows choosing non-default branches"
-            ),
-        "test": (
-                ("""-- apply""",),
-                ("""-- revert""",),
-                "does nothing"
-            )
-        }
-
-try:
-    data_home = os.environ['XDG_DATA_HOME']
-except KeyError:
-    data_home = ''
-if not data_home:
-    data_home = os.environ['HOME'] + '/.local/share'
-data_home = data_home + "/ganarchy"
-
-try:
-    cache_home = os.environ['XDG_CACHE_HOME']
-except KeyError:
-    cache_home = ''
-if not cache_home:
-    cache_home = os.environ['HOME'] + '/.cache'
-cache_home = cache_home + "/ganarchy"
 
 @click.group()
 def ganarchy():
@@ -122,8 +186,8 @@ def initdb():
     c.execute('''CREATE INDEX "repos_active" ON "repos" ("active")''')
     c.execute('''CREATE TABLE "repo_history" ("entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, "url" TEXT, "count" INTEGER, "head_commit" TEXT, "branch" TEXT, "project" TEXT)''')
     c.execute('''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''')
-    c.execute('''CREATE TABLE "config" ("git_commit" TEXT, "base_url" TEXT)''')
-    c.execute('''INSERT INTO "config" VALUES ('', '')''')
+    c.execute('''CREATE TABLE "config" ("git_commit" TEXT, "base_url" TEXT, "title" TEXT)''')
+    c.execute('''INSERT INTO "config" VALUES ('', '', '')''')
     conn.commit()
     conn.close()
 
@@ -131,7 +195,6 @@ def initdb():
 @click.argument('commit')
 def set_commit(commit):
     """Sets the commit that represents the project."""
-    import re
     if not re.fullmatch("[a-fA-F0-9]{40}", commit):
         raise click.BadArgumentUsage("COMMIT must be a git commit hash")
     conn = sqlite3.connect(data_home + "/ganarchy.db")
@@ -143,13 +206,23 @@ def set_commit(commit):
 @ganarchy.command()
 @click.argument('base-url')
 def set_base_url(base_url):
-    """Sets the GAnarchy instance's base URL."""
+    """Sets the GAnarchy instance's base URL. Used for the URI handler."""
     conn = sqlite3.connect(data_home + "/ganarchy.db")
     c = conn.cursor()
     c.execute('''UPDATE "config" SET "base_url"=?''', (base_url,))
     conn.commit()
     conn.close()
 
+@ganarchy.command()
+@click.argument('title')
+def set_title(title):
+    """Sets the GAnarchy instance's title. This title is displayed on the index."""
+    conn = sqlite3.connect(data_home + "/ganarchy.db")
+    c = conn.cursor()
+    c.execute('''UPDATE "config" SET "title"=?''', (title,))
+    conn.commit()
+    conn.close()
+
 # TODO move --branch into here?
 @ganarchy.group()
 def repo():
@@ -265,28 +338,51 @@ def migrations():
 
 migrations()
 
-@ganarchy.command()
-@click.argument('project', required=False)
-def cron_target(project):
-    """Runs ganarchy as a cron target."""
-    def handle_target(url, branch, project_commit):
+class GitError(LookupError):
+    """Raised when a git operation fails, generally due to a missing commit or branch, or network connection issues."""
+    pass
+
+class Repo:
+    def __init__(self, dbconn, project, url, branch, list_metadata=False):
+        self.url = url
+        self.branch = branch
+        self.project_commit = project.commit
+
         if not branch:
-            branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest()
-            branch = "HEAD"
+            self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest()
+            self.head = "HEAD"
         else:
-            branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest()
-            branch = "refs/heads/" + branch
+            self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest()
+            self.head = "refs/heads/" + branch
+
         try:
-            pre_hash = subprocess.check_output(["git", "-C", cache_home, "show", branchname, "-s", "--format=%H", "--"], stderr=subprocess.DEVNULL).decode("utf-8").strip()
+            self.hash = subprocess.check_output(["git", "-C", cache_home, "show", self.branchname, "-s", "--format=%H", "--"], stderr=subprocess.DEVNULL).decode("utf-8").strip()
         except subprocess.CalledProcessError:
-            pre_hash = None
+            self.hash = None
+
+        self.message = None
+        if list_metadata:
+            try:
+                self.fetch_metadata()
+            except GitError:
+                pass
+
+    def fetch_metadata(self):
+        try:
+            self.message = subprocess.check_output(["git", "-C", cache_home, "show", self.branchname, "-s", "--format=%B", "--"], stderr=subprocess.DEVNULL).decode("utf-8", "replace")
+        except subprocess.CalledProcessError as e:
+            raise GitError from e
+
+    def update(self): # FIXME?
         try:
-            subprocess.check_output(["git", "-C", cache_home, "fetch", "-q", url, "+" + branch + ":" + branchname], stderr=subprocess.STDOUT)
+            subprocess.check_output(["git", "-C", cache_home, "fetch", "-q", self.url, "+" + self.head + ":" + self.branchname], stderr=subprocess.STDOUT)
         except subprocess.CalledProcessError as e:
             # This may error for various reasons, but some are important: dead links, etc
             click.echo(e.output, err=True)
             return None
-        post_hash = subprocess.check_output(["git", "-C", cache_home, "show", branchname, "-s", "--format=%H", "--"], stderr=subprocess.DEVNULL).decode("utf-8").strip()
+        pre_hash = self.hash
+        post_hash = subprocess.check_output(["git", "-C", cache_home, "show", self.branchname, "-s", "--format=%H", "--"], stderr=subprocess.DEVNULL).decode("utf-8").strip()
+        self.hash = post_hash
         if not pre_hash:
             pre_hash = post_hash
         try:
@@ -294,16 +390,99 @@ def cron_target(project):
         except subprocess.CalledProcessError:
             count = 0  # force-pushed
         try:
-            subprocess.check_call(["git", "-C", cache_home, "merge-base", "--is-ancestor", project_commit, branchname], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-            return count, post_hash, subprocess.check_output(["git", "-C", cache_home, "show", branchname, "-s", "--format=%B", "--"], stderr=subprocess.DEVNULL).decode("utf-8", "replace")
-        except subprocess.CalledProcessError:
+            subprocess.check_call(["git", "-C", cache_home, "merge-base", "--is-ancestor", self.project_commit, self.branchname], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+            self.fetch_metadata()
+            return count, post_hash, self.message
+        except (subprocess.CalledProcessError, GitError) as e:
+            click.echo(e, err=True)
             return None
+
+class Project:
+    def __init__(self, dbconn, ganarchy, project_commit, list_repos=False):
+        self.commit = project_commit
+        if ganarchy.project_commit == project_commit:
+            project_commit = None
+        self.refresh_metadata()
+        if list_repos:
+            repos = []
+            with dbconn:
+                for (e, url, branch) in dbconn.execute('''SELECT "max"("e"), "url", "branch" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch" FROM "repo_history" "T1"
+                                                                    WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1)
+                                                                    GROUP BY "T1"."url", "T1"."branch"
+                                                                    UNION
+                                                                    SELECT null, "T3"."url", "T3"."branch" FROM "repos" "T3" WHERE "active" AND "project" IS ?1)
+                                           GROUP BY "url" ORDER BY "e"''', (project_commit,)):
+                    repos.append(Repo(dbconn, self, url, branch))
+            self.repos = repos
+        else:
+            self.repos = None
+
+    def refresh_metadata(self):
+        try:
+            project = subprocess.check_output(["git", "-C", cache_home, "show", self.commit, "-s", "--format=%B", "--"], stderr=subprocess.DEVNULL).decode("utf-8", "replace")
+            project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE))
+            if not project_title.strip():
+                project_title, project_desc = ("Error parsing project commit",)*2
+            if project_desc:
+                project_desc = project_desc.strip()
+            self.commit_body = project
+            self.title = project_title
+            self.description = project_desc
+        except subprocess.CalledProcessError:
+            self.commit_body = None
+            self.title = None
+            self.description = None
+
+    def update(self):
+        # TODO
+        self.refresh_metadata()
+
+class GAnarchy:
+    def __init__(self, dbconn, list_projects=False):
+        with dbconn:
+            # TODO
+            #(project_commit, base_url, title) = dbconn.execute('''SELECT "git_commit", "base_url", "title" FROM "config"''').fetchone()
+            (project_commit, base_url) = dbconn.execute('''SELECT "git_commit", "base_url" FROM "config"''').fetchone()
+            title = None
+            self.project_commit = project_commit
+            self.base_url = base_url
+            if not base_url:
+                pass ## TODO
+            if not title:
+                from urllib.parse import urlparse
+                title = "GAnarchy on " + urlparse(base_url).hostname
+            self.title = title
+        if list_projects:
+            projects = []
+            with dbconn:
+                for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''): # FIXME? *maybe* sort by activity in the future
+                    if project == None:
+                        project = self.project_commit
+                    projects.append(Project(dbconn, ganarchy, project))
+            projects.sort(key=lambda project: project.title)
+            self.projects = projects
+        else:
+            self.projects = None
+
+
+@ganarchy.command()
+@click.argument('project', required=False)
+def cron_target(project):
+    """Runs ganarchy as a cron target."""
+    # make sure the cache dir exists
     os.makedirs(cache_home, exist_ok=True)
+    # make sure it is a git repo
     subprocess.call(["git", "-C", cache_home, "init", "-q"])
     conn = sqlite3.connect(data_home + "/ganarchy.db")
-    c = conn.cursor()
-    c.execute('''SELECT "git_commit", "base_url" FROM "config"''')
-    (project_commit, base_url) = c.fetchone()
+    instance = GAnarchy(conn, list_projects=project=="index")
+    env = jinja2.Environment(loader=get_template_loader(), autoescape=False)
+    if project == "index":
+        # render the index
+        template = env.get_template('index.html')
+        click.echo(template.render(ganarchy = instance))
+        return
+    project_commit = instance.project_commit
+    base_url = instance.base_url
     if not base_url or not (project or project_commit):
         click.echo("No base URL or project commit specified", err=True)
         return
@@ -313,17 +492,16 @@ def cron_target(project):
         project_commit = project
     entries = []
     generate_html = []
-    for (e, url, branch) in c.execute('''SELECT "max"("e"), "url", "branch" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch" FROM "repo_history" "T1"
-                                                        WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1)
-                                                        GROUP BY "T1"."url", "T1"."branch"
-                                                        UNION
-                                                        SELECT null, "T3"."url", "T3"."branch" FROM "repos" "T3" WHERE "active" AND "project" IS ?1)
-                               GROUP BY "url" ORDER BY "e"''', (project,)):
-        result = handle_target(url, branch, project_commit)
+    c = conn.cursor()
+    p = Project(conn, instance, project_commit, list_repos=True)
+    # FIXME: this should be moved into Project.update()
+    for repo in p.repos:
+        result = repo.update()
         if result is not None:
             count, post_hash, msg = result
-            entries.append((url, count, post_hash, branch, project))
-            generate_html.append((url, msg, count, branch))
+            entries.append((repo.url, count, post_hash, repo.branch, project))
+            generate_html.append((repo.url, msg, count, repo.branch))
+    p.refresh_metadata()
     # sort stuff twice because reasons
     entries.sort(key=lambda x: x[1], reverse=True)
     generate_html.sort(key=lambda x: x[2], reverse=True)
@@ -334,20 +512,16 @@ def cron_target(project):
         history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall()
         # TODO process history into SVG
         html_entries.append((url, msg, "", branch))
-    template = jinja2.Template(TEMPLATE)
-    import re
-    project = subprocess.check_output(["git", "-C", cache_home, "show", project_commit, "-s", "--format=%B", "--"], stderr=subprocess.DEVNULL).decode("utf-8", "replace")
-    project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE))
-    if not project_title.strip():
-        project_title, project_desc = ("Error parsing project commit",)*2
-    if project_desc:
-        project_desc = project_desc.strip()
-    click.echo(template.render(project_title  = project_title,
-                               project_desc   = project_desc,
-                               project_body   = project,
-                               project_commit = project_commit,
+    template = env.get_template('project.html')
+    click.echo(template.render(project_title  = p.title,
+                               project_desc   = p.description,
+                               project_body   = p.commit_body,
+                               project_commit = p.commit,
                                repos          = html_entries,
-                               base_url       = base_url))
+                               base_url       = base_url,
+                               # I don't think this thing supports deprecating the above?
+                               project        = p,
+                               ganarchy       = instance))
 
 if __name__ == "__main__":
     ganarchy()