gemini.git

going-flying.com gemini git repository

summary

tree

log

refs

6579d7412da733536ff6cedd08604c9022b6bf53 - Matthew Ernisse - 1595803382

I have not had this much fun in a while...

view tree

view raw

diff --git a/files/deploy b/files/deploy
new file mode 100755
index 0000000..454270f
--- /dev/null
+++ b/files/deploy
@@ -0,0 +1,74 @@
+#!/bin/sh
+# going-flying.com post-receive hook for gemini://
+# (c) 2016-2020 Matthew J. Ernisse <matt@going-flying.com>
+# All Rights Reserved.
+#
+# Update the on-disk representation of my capsule when I push a new
+# revision up to the git repository.
+
+set -e
+
+BRANCH=""
+BUILD_DIR="/var/gemini"
+GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
+REV=0
+
+while read oldrev newrev refname; do
+	BRANCH="$refname"
+	REV="$newrev"
+done
+
+if [ "$BRANCH" != "refs/heads/master" ]; then
+	echo "detected $BRANCH, aborting"
+	exit 0
+fi
+
+if [ -z "$GIT_DIR" ]; then
+	echo >&2 "fatal: post-receive GIT_DIR not set"
+	exit 1
+fi
+
+cat << EOF
+                                »^  \
+                               ƒ     L
+                      ▄▄▄▌     ⌐    ╒
+                     ██████b   ¼   /   ,╓▄▄▓█▌
+                    ▐██████▌     )Θ    ╙└ ╟███▌
+                    ╫███████    ]   ╕   Φ█▀██▓█
+                    ████████    ╞    b "▓ ▐█╙¬
+                    ████████         ╘ ]  ╚▀
+                    ████████⌐    └
+                   ██████████ ╓┐  Γ
+                  ▓█████████████ ,¬
+                 ▐███████████████
+                ]█████████████████
+                ████████████████▀╙
+              ]██████████████████
+              ╟██████████████████
+              ╫██████████████████▌
+              ████████████████████
+             ▐████████████████████⌐
+             █████████████████████▌
+            ,██████████████████████
+            ▐██████████████████████µ
+               ─└└└└└└└└└└└└└└└└└─
+EOF
+
+umask 0022
+
+if [ ! -d "$BUILD_DIR" ]; then
+	echo "Creating $BUILD_DIR"
+
+	mkdir -- "$BUILD_DIR"
+	chgrp www-data "$BUILD_DIR"
+	chmod 664 "$BUILD_DIR"
+fi
+
+echo "updating $BUILD_DIR"
+GIT_WORK_TREE=$BUILD_DIR git checkout -f
+
+echo "setting rev to $REV"
+sed -e "s/GIT_REV/${REV}/" "$BUILD_DIR/index.gmi" > "$BUILD_DIR/index.gmi.new"
+mv $BUILD_DIR/index.gmi.new $BUILD_DIR/index.gmi
+
+echo "site deployed."
diff --git a/files/thoughts-to-gemini.py b/files/thoughts-to-gemini.py
new file mode 100755
index 0000000..6c25365
--- /dev/null
+++ b/files/thoughts-to-gemini.py
@@ -0,0 +1,304 @@
+#!/usr/bin/env python3
+# -*- coding: UTF-8 -*-
+'''thoughts-to-gemini.py (c) 2020 Matthew J Ernisse <matt@going-flying.com>
+All Rights Reserved.
+
+Redistribution and use in source and binary forms,
+with or without modification, are permitted provided
+that the following conditions are met:
+
+    * Redistributions of source code must retain the
+      above copyright notice, this list of conditions
+      and the following disclaimer.
+    * 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 OWNER 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 jinja2
+import json
+import os
+import pytz
+import requests
+import sys
+import time
+
+from bs4 import BeautifulSoup
+
+entry_template = '''
+===>> [ {{ entry.date }} ]
+> {{ entry.message }}
+
+{% if 'attachment' in entry.keys() %}
+    Attachments:
+{% for attachment in entry.attachment %}
+=> {{ attachment.name|urlencode }}	{{ attachment.type }}
+{% endfor %}
+
+{% endif %}
+'''
+
+index_template = '''
+```
+ _______  __                          __     __          
+|_     _||  |--..-----..--.--..-----.|  |--.|  |_ .-----.
+  |   |  |     ||  _  ||  |  ||  _  ||     ||   _||__ --|
+  |___|  |__|__||_____||_____||___  ||__|__||____||_____|
+                              |_____|                    
+```
+
+# 💭 Random Thoughts
+
+{% for year in thoughts.years %}
+## {{ year }}
+
+	{% for month in thoughts.byYear(year) %}
+### {{ month }}
+		{% for entry in thoughts.forMonth(year, month) %}
+{{ entry }}
+		{% endfor %}
+	{% endfor %}
+{% endfor %}
+
+# 🕰️ Generated at {{ build_time }}
+
+'''
+
+class DeHTMLizer(object):
+	'''Converter for the lightweight Thoughts HTML into gemini's
+	markup language.
+	'''
+	def __init__(self, s):
+		''' Given a HTML string, convert it into text/gemini '''
+		soup = BeautifulSoup(s, 'lxml')
+		self.gemini = ''
+		self.links = []
+
+		for el in soup.find('body').contents:
+			if el.name is not None:
+				self.gemini += self.parseTag(el)
+
+			elif el.string is not None:
+				self.gemini += el.string
+				
+	def __str__(self):
+		if len(self.gemini) == 0:
+			return '~ NO MESSAGE ~'
+
+		if len(self.links) == 0:
+			return self.gemini
+
+		trailer = '\n\n'
+		for n, link in enumerate(self.links):
+			trailer += f'=> {link} [{n + 1}]\n'
+
+		return self.gemini + trailer
+
+	def parseTag(self, tag):
+		noprint = ['style', 'script']
+
+		if tag.name == 'a':
+			self.links.append(tag['href'])
+			num = len(self.links)
+
+			return f'«{tag.string}[{num}]»'
+
+		elif tag.name == 'blockquote':
+			return f'“{tag.string}”'
+
+		elif tag.name in ['em', 'strong']:
+			return f'*{tag.string}*'
+
+		elif tag.name in noprint:
+			return ''
+
+		elif tag.string == None:
+			return ''
+		else:
+			return tag.string
+
+
+class Thoughts(object):
+	''' Render Thoughts from the API and save state to disk.'''
+	attachurl = 'https://thoughtsassets.blob.core.windows.net/thumbnails'
+	def __init__(self, thoughtdir):
+		self.api = ThoughtApi()
+		self.entries = []
+		self.thoughtdir = thoughtdir
+		self.thoughts = []
+		self._years = {}
+
+		if not os.path.exists(thoughtdir):
+			raise ValueError('Dir does not exist')
+
+		t_json = os.path.join(thoughtdir, 'thoughts.json')
+		if os.path.exists(t_json):
+			with open(t_json, 'r', encoding='utf-8') as fd:
+				self.thoughts = json.load(fd)
+
+		self.thoughts.sort(key=lambda k: k['id'], reverse=True)
+		if len(self.thoughts) != 0:
+			local_newest = self.thoughts[0]['id']
+			if self.api.newest > local_newest:
+				_t = ThoughtApi(local_newest)
+				self.thoughts.extend(_t.thoughts)
+		else:
+			_t = ThoughtApi()
+			self.thoughts = list(_t.thoughts)
+
+		with open(t_json, 'w', encoding='utf-8') as fd:
+			json.dump(
+				self.thoughts,
+				fd,
+				ensure_ascii=False
+			)
+
+		self.tmpl = jinja2.Template(
+			entry_template,
+			trim_blocks=True,
+			lstrip_blocks=True
+		)
+
+		for thought in self.thoughts:
+			self._processThought(thought)
+			self._downloadAttachments(thoughtdir, thought)
+
+		now = datetime.datetime.now(pytz.timezone('US/Eastern'))
+		tmpl = jinja2.Template(
+			index_template,
+			trim_blocks=True,
+			lstrip_blocks=True
+		)
+
+		outFile = os.path.join(thoughtdir, 'index.gmi')
+		with open(outFile, 'w', encoding='utf-8') as fd:
+			fd.write(tmpl.render({
+				'build_time': now.strftime('%c %z'),
+				'thoughts': self
+			}))
+
+	def _downloadAttachments(self, localdir, thought):
+		if 'attachment' not in thought:
+			return
+
+		for a in thought['attachment']:
+			outFile = os.path.join(localdir, a['name'])
+
+			if os.path.exists(outFile):
+				continue
+
+			resp = requests.get(self.attachurl + '/' + a['name'])
+			resp.raise_for_status()
+
+			with open(outFile, 'wb') as fd:
+				fd.write(resp.content)
+
+	def _processThought(self, thought):
+		dt = datetime.datetime.utcfromtimestamp(thought['id'])
+		if dt.year not in self._years:
+			self._years[dt.year] = {}
+
+		month = dt.strftime('%B')
+		if month not in self._years[dt.year]:
+			self._years[dt.year][month] = []
+
+		thought['message'] = DeHTMLizer(thought['message'])
+		self._years[dt.year][month].append(
+			self.tmpl.render(entry=thought)
+		)
+
+	@property
+	def years(self):
+		for year in self._years.keys():
+			yield year
+
+	def byYear(self, year):
+		return self._years[year].keys()
+
+	def forMonth(self, year, month):
+		return self._years[year][month]
+
+
+class ThoughtApi(object):
+	''' Provide an interface to my Thoughts. '''
+	imgUrl = 'https://thoughtsassets.blob.core.windows.net/thumbnails'
+	def __init__(self, since=0):
+		self.since = since
+
+	@property
+	def newest(self):
+		''' Return the ID of the newest thought. '''
+		_t = self._get(1, before=int(time.time()))[0]
+		return _t['id']
+
+	@property
+	def oldest(self):
+		_t = self._get(1, since=0)[0]
+		return _t['id']
+
+	@property
+	def thoughts(self):
+		''' Fetch the thoughts from the API and emit them. '''
+		more = True
+		while more:
+			_t = self._getRange()
+			if len(_t) < 25:
+				more = False
+
+			for thought in _t:
+				self.since = thought['id']
+				yield thought
+
+	def _get(self, count=25, before=None, since=None):
+		headers = {'User-Agent': 'thought-to-gemini/1.0'}
+		params = {'count': count}
+
+		if before is not None:
+			params['before'] = before
+
+		if since is not None:
+			params['since'] = since
+
+		resp = requests.get(
+			'https://vociferate.azurewebsites.net/api/get',
+			headers=headers,
+			params=params
+		)
+
+		resp.raise_for_status()
+		thoughts = resp.json()
+		thoughts.sort(key=lambda k: k['id'])
+		return thoughts
+
+	def _getRange(self):
+		''' Return a range of 25 thoughts from self.since. '''
+		return self._get(since=self.since)
+
+
+if __name__ == '__main__':
+	if len(sys.argv) != 2:
+		print(f'Usage: {os.path.basename(sys.argv[0])} path')
+		print()
+		print('This will write all Thoughts to index.gmi at the given')
+		print('path and download all attachments there as well.')
+		sys.exit(1)
+
+	localdir = sys.argv[1]
+	if not os.path.exists(localdir):
+		print(f'{localdir} does not exist or is not readable.')
+		sys.exit(1)
+
+	Thoughts(localdir)
diff --git a/how-built.gmi b/how-built.gmi
new file mode 100644
index 0000000..18e1854
--- /dev/null
+++ b/how-built.gmi
@@ -0,0 +1,16 @@
+# How I am building this capsule
+## Author: mernisse
+## Date 26/July/2020
+
+I hope it may be of some interest to someone, someday how the content in here is maintained.  The funny thing is that in a lot of ways it is built extremely similarly to the HTTPS version of going-flying.com, which is to say it uses a git post-receive hook.
+
+=> https://www.going-flying.com/blog/first-post-part-two.html 
+
+There are actually two parts here, part one is the git repository where all of the stuff I write by hand lives.  This is deployed as described, by a post-receive hook which simply checks out the repository into /var/gemini which is mounted into the Molly Brown container that is running.  In this way all the content just appears.  The other piece is a Python script running out of cron that renders the contents of the Thoughts microblogging (ish) system I built on Azure Functions out to a text/gemini file.
+
+=> https://www.going-flying.com/blog/thoughts-on-azure-functions.html
+=> /files/thoughts-to-gemini.py
+
+In the future I plan on building something resembling a blog here as well.  I think the community is drifting towards ``glog'' but I suppose we'll see when it comes down to that.  When I get to that point I imagine I'll end up building it similarly to my web blog, which is to say just create a directory full of text files with a metadata header and then generating the index page and the article pages from the git deploy script.
+
+=> /files/deploy
diff --git a/index.gmi b/index.gmi
index c1b5599..918193f 100644
--- a/index.gmi
+++ b/index.gmi
@@ -10,11 +10,13 @@
 ```
 # Welcome to going-flying.com 🛩️
 
-I have some plans to make more content available here, but for the moment there is really only this and a version of what you might call my microblog rendered into text/gemini.  For the interested this capsule is running on geminid in a Docker container.
+I have some plans to make more content available here, but for the moment there is really only this and a version of what you might call my microblog rendered into text/gemini.  For the interested this capsule is running on Molly Brown in a Docker container, but started life as geminid in a Docker container.
 
 => /thoughts/	My microblog?  Something like that.
+=> /how-built.gmi	How this capsule is built, vaguely.
 => https://www.going-flying.com	HTTPS version of going-flying.com
-=> https://ssl.ub3rgeek.net/git/?p=containers.git;a=tree;f=geminid;hb=HEAD	Git repository for this container
+=> https://ssl.ub3rgeek.net/git/?p=containers.git;a=tree;f=geminid;hb=HEAD	Git repository for this geminid in a container
+=> https://ssl.ub3rgeek.net/git/?p=containers.git;a=tree;f=mollybrown;hb=HEAD	Git repository for this Molly Brown in a container
 => gemini://gemini.uxq.ch	geminid Information
 => gemini://gemini.circumlunar.space	Gemini Information