2 from collections import namedtuple
9 from wsgiref.handlers import CGIHandler
11 pwd = os.path.dirname( os.path.realpath( __file__ ) )
15 entriesdir = os.path.join(pwd, "entries")
16 commentsdir = os.path.join(pwd, "comments")
17 staticsdir = os.path.join(pwd, "static")
19 monthnames = {"01": "January",
36 500: "Internal Server Error",
40 def __init__(self, status, headers, body):
42 self.headers = headers
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)
52 def __init__(self, path_re, fn, methods=["GET"], prefix_match=False):
53 self.path_re = re.compile(path_re)
55 self.methods = methods
56 self.prefix_match = prefix_match
58 def handle(self, match, env):
59 return self.fn(match, env)
62 def __init__(self, routes):
66 for route in self.routes:
67 if env["REQUEST_METHOD"] not in route.methods:
69 if route.prefix_match:
70 m = route.path_re.match(env["PATH_INFO"])
72 m = route.path_re.fullmatch(env["PATH_INFO"])
79 # read the paragraphs from a file
80 def readFile(filepath):
86 def loadComment(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()))
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)
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:]
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)]
107 # Collect lines into paragraph blocks. Returns a list of strings, each containing a well-formed HTML block.
108 def makeParas(lines):
113 thispara.append(line)
115 paragraphs.append("\n".join(thispara))
117 if len(thispara) > 0:
118 paragraphs.append("\n".join(thispara))
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]
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() ]
133 def render_no_entry(date):
134 return f"""<!DOCTYPE HTML>
137 <meta http-equiv="Content-Type" content="text/html;charset=utf-8" >
138 <title>Shortlog - a log of everyday things</title>
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>
148 def render_comments(comments):
149 if len(comments) == 0:
151 rendered_comments = []
152 for comment in comments:
157 timestamp = comment[4]
158 gravatar = comment[5]
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',
168 rendered_comments.append(rendered_comment)
171 "<hr><h3>Comments:</h3>",
172 "\n".join(rendered_comments),
175 def render_page(content):
176 return f"""<!DOCTYPE HTML>
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">
190 def render_multiday(entry_tuples, note):
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([
198 f'<h3><a href="/shortlog/{date}">{date}</a> | <a href="/shortlog/{date}#comments">{commentcount_label}</a></h3>',
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>
208 return render_page(content)
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>
215 <h3><a href="/shortlog/{date}">{date}</a></h3>
221 return render_page(content)
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>
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>
243 <name>Drew Fisher</name>
244 <email>zarvox@zarvox.org</email>
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:
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>"])
264 def serve_test_page(match, env):
265 return Response(200, [("Content-Type", "text/html")],
266 [b"<h1>It works!</h1>"])
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
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')])
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) ]
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')])
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) ])
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')])
307 body = render_no_entry(datestr)
308 return Response(404, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
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(), [])
319 group_timestamp = datetime.datetime.fromtimestamp(decorated[0][0]).isoformat() + "-06:00"
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')])
331 def serve_most_recent(match, env):
332 filelist = [ os.path.join(entriesdir, x) for x in most_recent_entries_filelist(20) ]
334 dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
336 entries = [markdoku.markdown(readFile(x)) for x in filelist]
337 commentcounts = map(len, map( getCommentFiles, dates))
339 body = render_multiday(zip(dates, entries, commentcounts), note)
340 return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
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),
353 #def redir(match, env):
354 # return Response(302, [("Location", "/")], [])
358 # '/week', 'pastweek' ,
359 # '/month', 'pastmonth' ,
360 # '/year', 'pastyear' ,
365 def application(env, start_response):
366 (route, match) = router.match(env)
371 response = route.handle(match, env)
373 print("EXCEPTION:", e)
376 print("No route matched", repr(env["PATH_INFO"]))
377 response = Response(404, [("Content-Type", "text/html")],
378 [b"<h1>404 Not Found :(</h1>"])
382 response = Response(500, [("Content-Type", "text/html")],
383 [b"<h1>Internal Server Error</h1>"])
385 print(env["REQUEST_METHOD"], env["PATH_INFO"], response.status)
386 return response.send(start_response)
389 if __name__ == "__main__":
391 CGIHandler().run(application)