]> git.zarvox.org Git - shortlog.git/blob - shortlog.py
Don't serve .gitignore (or other non-entry files)
[shortlog.git] / shortlog.py
1 #!/usr/bin/python3
2 from collections import namedtuple
3 import datetime
4 import hashlib
5 import mimetypes
6 import os
7 import re
8 import sys
9 from wsgiref.handlers import CGIHandler
10
11 pwd = os.path.dirname( os.path.realpath( __file__ ) )
12 sys.path.append(pwd)
13 import markdoku
14
15 entriesdir = os.path.join(pwd, "entries")
16 commentsdir = os.path.join(pwd, "comments")
17 staticsdir = os.path.join(pwd, "static")
18
19 monthnames = {"01": "January",
20                 "02": "February",
21                 "03": "March",
22                 "04": "April",
23                 "05": "May",
24                 "06": "June",
25                 "07": "July",
26                 "08": "August",
27                 "09": "September",
28                 "10": "October",
29                 "11": "November",
30                 "12": "December"}
31
32 STATUS_CODES = {
33         200: "OK",
34         302: "Found",
35         404: "Not Found",
36         500: "Internal Server Error",
37 }
38
39 class Response:
40         def __init__(self, status, headers, body):
41                 self.status = status
42                 self.headers = headers
43                 self.body = body
44
45         def send(self, start_response):
46                 status_str = STATUS_CODES[self.status]
47                 resp_line = f"{self.status} {status_str}"
48                 start_response(resp_line, self.headers)
49                 return self.body
50
51 class Route:
52         def __init__(self, path_re, fn, methods=["GET"], prefix_match=False):
53                 self.path_re = re.compile(path_re)
54                 self.fn = fn
55                 self.methods = methods
56                 self.prefix_match = prefix_match
57
58         def handle(self, match, env):
59                 return self.fn(match, env)
60
61 class Router:
62         def __init__(self, routes):
63                 self.routes = routes
64
65         def match(self, env):
66                 for route in self.routes:
67                         if env["REQUEST_METHOD"] not in route.methods:
68                                 continue
69                         if route.prefix_match:
70                                 m = route.path_re.match(env["PATH_INFO"])
71                         else:
72                                 m = route.path_re.fullmatch(env["PATH_INFO"])
73                         if m:
74                                 return (route, m)
75                 return (None, None)
76
77 # Helper functions
78
79 # read the paragraphs from a file
80 def readFile(filepath):
81         f = open(filepath)
82         data = f.read()
83         f.close()
84         return data
85
86 def loadComment(filepath):
87         f = open(filepath)
88         nameline = f.readline().strip()
89         emailline = f.readline().strip()
90         websiteline = f.readline().strip()
91         timestamp = f.readline().strip()
92         commentbody = makeParas(map(str.strip, f.readlines()))
93         m = hashlib.md5()
94         m.update(emailline.lower().encode('utf-8'))
95         gravatar = "http://www.gravatar.com/avatar/%s?s=48&d=identicon" % str(m.hexdigest())
96         #timestamp = datetime.datetime.fromtimestamp(os.stat(filepath).st_mtime).isoformat()
97         return (nameline, emailline, websiteline, commentbody, timestamp, gravatar)
98
99 def most_recent_entries_filelist(count):
100         files = sorted([x for x in os.listdir(entriesdir) if re.match("\d\d\d\d-\d\d-\d\d.txt", x)])
101         return files[-count:]
102
103 def getCommentFiles(date): # Returns a list of paths to comment files associated with date
104         filelist = [x for x in os.listdir(commentsdir) if re.match(date, x)]
105         return filelist
106
107 # Collect lines into paragraph blocks.  Returns a list of strings, each containing a well-formed HTML block.
108 def makeParas(lines):
109         paragraphs = []
110         thispara = []
111         for line in lines:
112                 if len(line) != 0:
113                         thispara.append(line)
114                 else:
115                         paragraphs.append("\n".join(thispara))
116                         thispara = []
117         if len(thispara) > 0:
118                 paragraphs.append("\n".join(thispara))
119
120         # wrap paragraphs in <p></p> if they aren't other HTML tags that don't want to be wrapped in <p> tags
121         #return map( lambda x: x if "<ol>" in x or "<ul>" in x or "<pre>" in x or "<table>" in x or "<video>" in x else "<p>" + x + "</p>", paragraphs)
122         return [x if "<ol>" in x or "<ul>" in x or "<pre>" in x or "<table>" in x or "<video>" in x else "<p>" + x + "</p>" for x in paragraphs]
123
124 def getFileList(numdays=7):
125         files = sorted(os.listdir(entriesdir))
126         today = datetime.date.today()
127         then = today - datetime.timedelta(days=numdays)
128         retval = [ x for x in sorted(files) if x > then.isoformat() ]
129         return retval
130
131 # Templates
132
133 def render_no_entry(date):
134         return f"""<!DOCTYPE HTML>
135 <html>
136 <head>
137 <meta http-equiv="Content-Type" content="text/html;charset=utf-8" >
138 <title>Shortlog - a log of everyday things</title>
139 </head>
140 <body>
141 <h1>Shortlog - a log of everyday things</h1>
142 <h3><a href="/shortlog/">Home</a></h3>
143 <p>There's no entry for {date}.  Perhaps you reached this page by mistake?</p>
144 </body>
145 </html>
146 """
147
148 def render_comments(comments):
149         if len(comments) == 0:
150                 return ""
151         rendered_comments = []
152         for comment in comments:
153                 name = comment[0]
154                 email = comment[1]
155                 website = comment[2]
156                 body = comment[3]
157                 timestamp = comment[4]
158                 gravatar = comment[5]
159                 writer_line = None
160                 name_block = f'<a href="{website}">{name}</a>' if website else name
161                 rendered_paras = "\n".join(body)
162                 rendered_comment = "\n".join([
163                         '<div class="comment">',
164                         f'<p><span style="float: right;"><img src="{gravatar}" alt="avatar from Gravatar"></span>',
165                         f'<p>{name_block} | {timestamp}</p',
166                         rendered_paras
167                 ])
168                 rendered_comments.append(rendered_comment)
169
170         return "\n".join([
171                 "<hr><h3>Comments:</h3>",
172                 "\n".join(rendered_comments),
173         ])
174
175 def render_page(content):
176         return f"""<!DOCTYPE HTML>
177 <html>
178 <head>
179 <meta http-equiv="Content-Type" content="text/html;charset=utf-8" >
180 <title>Shortlog - a log of everyday things</title>
181 <link rel="stylesheet" type="text/css" href="/shortlog/static/style.css">
182 <link rel="alternate" type="application/atom+xml" href="/shortlog/feed">
183 </head>
184 <body>
185 {content}
186 </body>
187 </html>
188 """
189
190 def render_multiday(entry_tuples, note):
191         entries = []
192         for entry in entry_tuples:
193                 (date, entrytext, commentcount) = entry
194                 commentcount_suffix = "s" if commentcount != 1 else ""
195                 commentcount_label = f"{commentcount} comment{commentcount_suffix}"
196                 entries.append("\n".join([
197                         "<div>",
198                         f'<h3><a href="/shortlog/{date}">{date}</a> | <a href="/shortlog/{date}#comments">{commentcount_label}</a></h3>',
199                         entrytext,
200                 ]))
201         all_entry_text = "\n".join(entries)
202         content = f"""<h1>Shortlog - a log of everyday things</h1>
203 <h3>View the past: <a href="/shortlog/2013">2013</a> <a href="/shortlog/2012">2012</a> <a href="/shortlog/2011">2011</a> <a href="/shortlog/2010">2010</a> </h3>
204 <h4><a href="/shortlog/feed">Atom Feed</a></h4>
205 {note}
206 {all_entry_text}
207 """
208         return render_page(content)
209
210 def render_day(date, entry, comments):
211         comment_markup = render_comments(comments)
212         content = f"""<h1>Shortlog - a log of everyday things</h1>
213 <h3><a href="/shortlog/">Home</a></h3>
214 <div>
215   <h3><a href="/shortlog/{date}">{date}</a></h3>
216   {entry}
217 </div>
218 <div id="comments">
219 {comment_markup}
220 </div>"""
221         return render_page(content)
222
223 def render_feed(timestamp, entries):
224         rendered_entries = []
225         for entry in entries:
226                 rendered_entries.append(f"""    <entry>
227                 <title>{entry["date"]}</title>
228                 <link href="https://zarvox.org/shortlog/{entry["date"]}" rel="alternate"></link>
229                 <id>https://zarvox.org/shortlog/{entry["date"]}</id>
230                 <updated>{entry["timestamp"]}</updated>
231                 <summary type="html">{entry["content"]}</summary>
232         </entry>""")
233         entries_text = "\n".join(rendered_entries)
234         content = f"""<?xml version="1.0" encoding="utf-8"?>
235 <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
236         <title>Shortlog</title>
237         <subtitle>A log of everyday things</subtitle>
238         <link href="https://zarvox.org/shortlog/feed rel="self"></link>
239         <link href="https://zarvox.org/shortlog/" rel="alternate"></link>
240         <id>https://zarvox.org/shortlog</id>
241         <updated>{timestamp}</updated>
242         <author>
243                 <name>Drew Fisher</name>
244                 <email>zarvox@zarvox.org</email>
245         </author>
246 {entries_text}
247 </feed>
248 """
249         return content
250
251 # Route definitions
252
253 def serve_static(match, env):
254         requested_filename = env["PATH_INFO"][len("/static/"):]
255         path = os.path.normpath(os.path.join(staticsdir, requested_filename))
256         if os.path.commonprefix([path, staticsdir]) == staticsdir:
257                 content_type = mimetypes.guess_type(path)
258                 with open(path, "rb") as f:
259                         content = f.read()
260                 return Response(200, [("Content-Type", content_type[0])], [content])
261         return Response(404, [("Content-Type", "text/html")],
262                         [b"<h1>404 Asset " + requested_filename.encode('utf-8') + b" Not Found :(</h1>"])
263
264 def serve_test_page(match, env):
265         return Response(200, [("Content-Type", "text/html")],
266                         [b"<h1>It works!</h1>"])
267
268 def serve_year(match, env):
269         (year,) = match.groups()
270         files = sorted(os.listdir(entriesdir))
271         filelist = [ os.path.join(entriesdir, x) for x in files if re.match(year,x) ]
272         # It makes more sense to sort fixed chunks of time in chronological order, rather than feed-style
273         #filelist.reverse()
274         dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
275         entries = [markdoku.markdown(readFile(x)) for x in filelist]
276         commentcounts = map(len, map( getCommentFiles, dates))
277         note = f"Entries from {year}"
278         body = render_multiday(zip(dates, entries, commentcounts), note)
279         return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
280
281 def serve_month(match, env):
282         (year, month) = match.groups()
283         files = sorted(os.listdir(entriesdir))
284         filelist = [ os.path.join(entriesdir, x) for x in files if re.match(f"{year}-{month}", x) ]
285         #filelist.reverse()
286         dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
287         entries = [markdoku.markdown(readFile(x)) for x in filelist]
288         commentcounts = map(len, map( getCommentFiles, dates))
289         note = f"Entries from {monthnames[month]}, {year}"
290         body = render_multiday(zip(dates, entries, commentcounts), note)
291         return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
292
293 def serve_day(match, env):
294         (year, month, day) = match.groups()
295         datestr = f"{year}-{month}-{day}"
296         filename = os.path.join(entriesdir, f"{datestr}.txt")
297         if os.path.isfile(filename):
298                 entry = markdoku.markdown(readFile(filename))
299                 commentfilelist = os.listdir(commentsdir)
300                 files = sorted([os.path.join(commentsdir, filename) for filename in commentfilelist if re.match("(.*)%s-%s-%s_(\d\d\d\d\d\d).txt$" % (year, month, day), filename) ])
301                 comments = []
302                 for f in files:
303                         comments.append(loadComment(f))
304                 body = render_day(datestr, entry, comments)
305                 return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
306         else:
307                 body = render_no_entry(datestr)
308                 return Response(404, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
309
310 def serve_feed(match, env):
311         filelist = os.listdir(entriesdir)
312         files = [os.path.join(entriesdir, f) for f in filelist if re.match("(.*)(\d\d\d\d-\d\d-\d\d).txt$", f)]
313         stats = [os.stat(f) for f in files]
314         decorated = [(stat.st_mtime, filename) for (filename, stat) in zip(files, stats)]
315         decorated.sort(reverse=True)
316         if len(decorated) == 0:
317                 body = render_feed(datetime.datetime.today(), [])
318         else:
319                 group_timestamp = datetime.datetime.fromtimestamp(decorated[0][0]).isoformat() + "-06:00"
320                 entries = []
321                 for d in decorated[:10]:
322                         mtime = datetime.datetime.fromtimestamp(d[0])
323                         second = {"date" : re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", d[1]),
324                                         "content": markdoku.markdown(readFile(d[1])),
325                                         "timestamp": (mtime.isoformat() + "-06:00") }
326                         entries.append(second)
327                 body = render_feed(group_timestamp, entries)
328         return Response(200, [('Content-Type', 'application/atom+xml')],
329                         [body.encode('utf-8')])
330
331 def serve_most_recent(match, env):
332         filelist = [ os.path.join(entriesdir, x) for x in most_recent_entries_filelist(20) ]
333         filelist.reverse()
334         dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
335         print(dates)
336         entries = [markdoku.markdown(readFile(x)) for x in filelist]
337         commentcounts = map(len, map( getCommentFiles, dates))
338         note = ""
339         body = render_multiday(zip(dates, entries, commentcounts), note)
340         return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
341
342 router = Router([
343         Route("/static/", serve_static, prefix_match=True),
344         Route("/(\d\d\d\d)-(\d\d)-(\d\d)", serve_day),
345         Route("/(\d\d\d\d)/(\d\d)/(\d\d)", serve_day),
346         Route("/(\d\d\d\d)-(\d\d)", serve_month),
347         Route("/(\d\d\d\d)/(\d\d)", serve_month),
348         Route("/(\d\d\d\d)", serve_year),
349         Route("/feed", serve_feed),
350         Route("/", serve_most_recent),
351 ])
352
353 #def redir(match, env):
354 #       return Response(302, [("Location", "/")], [])
355
356 #urls = (
357 #       '', 'redir',
358 #       '/week', 'pastweek' ,
359 #       '/month', 'pastmonth' ,
360 #       '/year', 'pastyear' ,
361 #       '/feed', 'feed' ,
362 #       '/', 'pastweek',
363 #       )
364
365 def application(env, start_response):
366     (route, match) = router.match(env)
367
368     response = None
369     if route:
370         try:
371             response = route.handle(match, env)
372         except e:
373             print("EXCEPTION:", e)
374             pass
375     else:
376         print("No route matched", repr(env["PATH_INFO"]))
377         response = Response(404, [("Content-Type", "text/html")],
378                             [b"<h1>404 Not Found :(</h1>"])
379
380
381     if response is None:
382         response = Response(500, [("Content-Type", "text/html")],
383                             [b"<h1>Internal Server Error</h1>"])
384
385     print(env["REQUEST_METHOD"], env["PATH_INFO"], response.status)
386     return response.send(start_response)
387
388
389 if __name__ == "__main__":
390     # CGI interface
391     CGIHandler().run(application)