]> git.cworth.org Git - turbot-web/blob - html_generator.py
Add code to auto-refresh each HTML page
[turbot-web] / html_generator.py
1 # -*- coding: utf-8 -*-
2 """
3 Created on Thu Jan  6 23:35:23 2022
4
5 @author: Avram Gottschlich
6 """
7 """
8 I copied several functions from your code;
9 if it's easier to refer to them rather than copying them that's absolutely fine
10
11 This rewrites the html each time it is called
12 If it's easy to call the puzzle/round functions each time they're updated,
13 that would be great
14
15 Requires sorttable.js, which should be included
16 """
17 import boto3
18 from boto3.dynamodb.conditions import Key
19 import os
20 import re
21 import sys
22
23 WEBROOT = "/srv/halibut.cworth.org/www"
24
25
26 def hunt_file(hunt, name):
27     """Return a path file 'name' within the given hunt.
28
29     This will be withing WEBROOT and in a hunt-specific path."""
30
31     return "{}/{}/{}".format(WEBROOT, hunt['channel_id'], name)
32
33 def internal_link(hunt, name):
34     """Returns a path for a link on this site."""
35
36     # Just generate a relative link, (which is just the name itself)
37     return "{}".format(name)
38
39 def filename_from_name(name):
40     """Returns a string derived from name, but with all spaces and slashes
41        replaced with underscores, (for making a clean filename)"""
42     return re.sub(r'[ /]', '_', name)
43
44 def channel_url(channel_id):
45     """Given a channel ID, return the URL for that channel."""
46
47     return "https://halibutthatbass.slack.com/archives/{}".format(channel_id)
48
49 def find_hunt_for_hunt_id(table, hunt_id):
50     """Given a hunt ID find the database item for that hunt
51
52     Returns None if hunt ID is not found, otherwise a
53     dictionary with all fields from the hunt's row in the table,
54     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
55     """
56     response = table.get_item(
57         Key={
58             'hunt_id': hunt_id,
59             'SK': 'hunt-{}'.format(hunt_id)
60             })
61
62     if 'Item' in response:
63         return response['Item']
64     else:
65         return None
66
67 def hunt_puzzles_for_hunt_id(table, hunt_id):
68     """Return all puzzles that belong to the given hunt_id"""
69
70     response = table.query(
71         KeyConditionExpression=(
72             Key('hunt_id').eq(hunt_id) &
73             Key('SK').begins_with('puzzle-')
74             )
75         )
76
77     return response['Items']
78
79 def elink(lin, text):
80     #shortcutting function for creating html links
81     #opens in a new tab for external links
82     return '<a href="{}" target="_blank" rel="noreferrer noopener">{}</a>'.format(lin, text)
83
84 def link(lin, text):
85     #internal links, doesn't open new tab
86     return '<a href="{}">{}</a>'.format(lin, text)
87
88 def hunt_info(table, hunt_id):
89     """
90     Retrieves list of rounds, puzzles for the given hunt
91     """
92
93     hunt = find_hunt_for_hunt_id(table, hunt_id)
94
95     name = hunt["name"]
96     channel_id = hunt["channel_id"]
97
98     puzzles = hunt_puzzles_for_hunt_id(table, hunt_id)
99
100     rounds = set()
101     for puzzle in puzzles:
102         if "rounds" not in puzzle:
103             continue
104         for rnd in puzzle["rounds"]:
105             rounds.add(rnd)
106     rounds = list(rounds)
107     rounds.sort()
108
109     return hunt, puzzles, rounds
110
111 def round_stat(rnd, puzzles):
112     #Counts puzzles, solved, list of puzzles for a given round
113     puzzle_count = 0
114     solved_count = 0
115     solved_puzzles = []
116     unsolved_puzzles = []
117     metas = []
118     meta_solved = 0
119     for puzzle in puzzles:
120         if "rounds" not in puzzle:
121             continue
122         if rnd in puzzle["rounds"]:
123             if puzzle['type'] == 'meta':
124                 metas.append(puzzle)
125                 if puzzle['status'] == 'solved':
126                     meta_solved += 1
127             else:
128                 puzzle_count += 1
129                 if puzzle['status'] == 'solved':
130                     solved_count += 1
131                     solved_puzzles.append(puzzle)
132                 else:
133                     unsolved_puzzles.append(puzzle)
134     solved_puzzles = sorted(solved_puzzles, key = lambda i: i['name'])
135     unsolved_puzzles = sorted(unsolved_puzzles, key = lambda i: i['name'])
136     rnd_puzzles = metas + unsolved_puzzles + solved_puzzles
137     return puzzle_count, solved_count, rnd_puzzles, meta_solved, len(metas)
138
139
140
141 def overview(hunt, puzzles, rounds):
142     #big board, main page. saves as index.html
143     start = ['<!DOCTYPE html>\n',
144      '<html>\n',
145      '<head>\n',
146      '  <meta charset="utf-8">\n',
147      '  <meta name="viewport" content="width=device-width, initial-scale=1">\n',
148      '  <meta http-equiv="refresh" content = "15">\n',
149      '\n',
150      '  <link rel="stylesheet" href="/overview.css">\n',
151      '  <script type="text/javascript">\n',
152      '    // Hide all elements with class="containerTab", except for the one that matches the clickable grid column\n',
153      '    function openTab(tabName) {\n',
154      '      var i, x;\n',
155      '      x = document.getElementsByClassName("containerTab");\n',
156      '      for (i = 0; i < x.length; i++) {\n',
157      '        x[i].style.display = "none";\n',
158      '      }\n',
159      '      document.getElementById(tabName).style.display = "block";\n',
160      '    }\n',
161      '  </script>\n',
162      '\n',
163      '  <title>Hunt Overview</title>\n',
164      '  <script src="/sorttable.js"></script>\n'
165      '</head>\n',
166      '    <div class="sidenav">\n',
167      '      <a href="index.html">Hunt Overview</a>',
168      '      <a href="all.html">All Puzzles</a>\n',
169      '      <a href="unsolved.html">Unsolved</a>\n',
170      '      <a href="solved.html">Solved</a>\n',
171      '      <a href="https://docs.google.com/document/d/14Ww6vWFO4hx1GYz8zDRxP_rI_v4hmRgdgYmN91F-Lqk/edit" target="_blank" rel="noreferrer noopener">Turbot Docs</a>\n'
172      '    </div>\n',
173      '<body>\n',]
174     columns = ['  <div class="row">\n']
175     expanding = []
176     i = 1
177     for rnd in rounds:
178         puzzle_count, solved_count, rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)
179         if metas == meta_solved and metas > 0:
180             status = 'solved'
181         else:
182             status = 'unsolved'
183         columns += ['    <div class="column {}" onclick="openTab(\'b{}\');">\n'.format(status, i),
184         '      <p>{}</p>\n'.format(rnd),
185         '      <p>Puzzles: {}/{}</p>\n'.format(solved_count, puzzle_count),
186         '      <p>Metas: {}/{}</p>\n'.format(meta_solved, metas),
187         '    </div>\n']
188
189         expanding += [
190         '  <div id="b{}" class="containerTab {}" style="display:none;">\n'.format(i, status),
191         '    <span onclick="this.parentElement.style.display=\'none\'" class="closebtn">x</span>\n',
192         '    <h2>{}</h2>\n'.format(link(internal_link(hunt, filename_from_name(rnd)) + "_round.html", rnd)),
193         '    <table class="sortable">\n',
194         '      <tr>\n',
195         '        <th><u>Puzzle</u></th>\n',
196         '        <th><u>Answer</u></th>\n',
197         '      </tr>\n',]
198
199         for puzzle in rnd_puzzles:
200             if puzzle['type'] == 'meta':
201                 meta = ' [META]'
202             else:
203                 meta = ''
204             if puzzle['status'] == 'solved':
205                 expanding += ['      <tr class=\'solved\';>\n',
206                 '        <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", puzzle['name']+meta)),
207                 '        <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
208                 '      </tr>\n']
209             else:
210                 expanding += ['      <tr class=\'unsolved\';>\n',
211                 '        <td><b>{}</b></td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", puzzle['name']+meta)),
212                 '        <td></td>\n',
213                 '      </tr>\n']
214         expanding.append('    </table>\n')
215         expanding.append('  </div>\n')
216         i += 1
217     columns.append('  </div>\n')
218     end = ['</body>\n', '</html>\n']
219     html = start + expanding + columns + end
220     file = hunt_file(hunt, "index.html")
221     f = open(file + ".tmp", "w")
222     for line in html:
223         f.write(line)
224     f.close()
225     os.rename(file + ".tmp", file)
226     return None
227
228 def round_overview(hunt, rnd, puzzles):
229     #inputs: round name, puzzles
230     #round overview page
231     #saves as (round name)_round.html, in case meta/round share names.
232     #underscores replace spaces for links.
233     rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)[2:]
234     if meta_solved == metas and metas > 0:
235         status = 'solved'
236     else:
237         status = 'unsolved'
238     start = ['<html>\n',
239      '    <head>\n',
240      '        <link rel="stylesheet" href="/individual.css">\n',
241      '        <title>Mystery Hunt 2022</title>\n',
242      '        <script src="/sorttable.js"></script>\n',
243      '        <meta http-equiv="refresh" content = "15">\n',
244      '    </head>\n',
245      '    <body class="{}">\n'.format(status),
246      '        <h1><b>{}</b></h1>\n'.format(rnd),
247      '        <p>{}</p>\n'.format(link(internal_link(hunt, "index") + ".html", 'Hunt Overview')),
248      '        <div>\n',
249      '            <table class="center sortable">\n',
250      '                <thead>\n',
251      '                    <tr>\n',
252      '                        <th>Puzzle Title/Slack</th>\n',
253      '                        <th>Puzzle Link</th>\n',
254      '                        <th>Sheet</th>\n',
255      '                        <th>Overview</th>\n',
256      '                        <th>Answer</th>\n',
257      #'                        <th>Extra Links</th>\n',
258      '                        <th>Tags</th>\n',
259      '                    </tr>\n',
260      '                </thead>\n',
261      '                <tbody>\n']
262     puzzle_list = []
263     for puzzle in rnd_puzzles:
264         if puzzle['type'] == 'meta':
265             meta = ' [META]'
266         else:
267             meta = ''
268         slack_url = channel_url(puzzle['channel_id'])
269
270         if puzzle['status'] == 'solved':
271             puzzle_list += [ '                    <tr>\n',
272              '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
273              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
274              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
275              '                        <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
276              '                        <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
277             # '                        <td></td>\n',
278              '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
279              '                    </tr>\n']
280         else:
281             puzzle_list += [ '                    <tr>\n',
282              '                        <td><b>{}</b></td>\n'.format(elink(slack_url, puzzle['name']+meta)),
283              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
284              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
285              '                        <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
286              '                        <td></td>\n',
287             # '                        <td></td>\n',
288              '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
289              '                    </tr>\n']
290     end = ['                </tbody>\n',
291     '            </table>\n',
292     '        </div>\n',
293     '    </body>\n',
294     '</html>\n']
295     html = start + puzzle_list + end
296     file = hunt_file(hunt, "{}_round.html".format(filename_from_name(rnd)))
297     f = open(file + ".tmp", "w")
298     for line in html:
299         f.write(line)
300     f.close()
301     os.rename(file + ".tmp", file)
302     return None
303
304 def puzzle_overview(hunt, puzzle):
305     #overview page for individual puzzles. saves as (name of puzzle).html,
306     #with underscores rather than spaces
307     name = puzzle['name']
308     if puzzle['type'] == 'meta':
309         meta = ' [META]'
310     else:
311         meta = ''
312     slack_url = channel_url(puzzle['channel_id'])
313     if 'rounds' in puzzle:
314         round_url = [link(internal_link(hunt, filename_from_name(rnd)) + "_round.html", rnd) for rnd in puzzle['rounds']]
315     else:
316         round_url = ''
317     if puzzle['status'] == 'solved':
318         solution = ", ".join(puzzle['solution']).upper()
319         status = 'solved'
320     else:
321         solution = ""
322         status = 'unsolved'
323     html = ['<!DOCTYPE html>\n',
324      '<html>\n',
325      '<head>\n',
326      '    <meta charset="utf-8">\n',
327      '    <meta name="viewport" content="width=device-width, initial-scale=1">\n',
328      '    <meta http-equiv="refresh" content = "15">\n',
329      '    <link rel="stylesheet" href="/individual.css">\n',
330      '    <title>{}</title>\n'.format(name+meta),
331      '    <p>{}</p>'.format(link(internal_link(hunt, 'index') + ".html", 'Hunt Overview')),
332      '\n',
333      '</head>\n',
334      '<body class="{}">\n'.format(status),
335      '    <h1>{}</h1>\n'.format(name+meta),
336      '    <div>\n',
337      '        <table class="center">\n',
338      '            <tr>\n',
339      '                <td>{}</td>\n'.format(elink(slack_url, 'Channel')), #slack channel
340      '                <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
341      '                <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
342      '                <td>Additional Resources</td>\n',
343      '            </tr>\n',
344      '        </table>\n',
345      '        <table class="center">\n',
346      '            <tr>\n',
347      '                <td>Round(s): {}</td>\n'.format(" ".join(round_url)), #round page on our site
348      '                <td>Tags: {}</td>\n'.format(", ".join(puzzle.get('tags',[]))), #add tags
349      '            </tr>\n',
350      '            <tr>\n',
351      '                <td>Answer: {}</td>\n'.format(solution),
352      '                <td>State: {}</td>\n'.format(puzzle['status']),
353      '            </tr>\n',
354      '        </table>\n',
355      '    </div>\n',
356      '\n',
357      '</body>\n',
358      '</html>\n']
359     file = hunt_file(hunt, "{}.html".format(filename_from_name(name)))
360     f = open(file + ".tmp", "w")
361     for line in html:
362         f.write(line)
363     f.close()
364     os.rename(file + ".tmp", file)
365     return None
366
367 def puzzle_lists(hunt, puzzles, filt):
368     #filt is one of "All", "Solved", "Unsolved" and spits out the appropriate list of puzzles
369     #generates pages for all puzzles, solved puzzles, unsolved puzzles
370     #saves as all/solved/unsolved.html, has a sidebar to link to each other and to the big board
371     solved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] == 'solved']
372     unsolved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] != 'solved']
373     start = ['<html>\n',
374      '    <head>\n',
375      '        <link rel="stylesheet" href="/overview.css">\n',
376      '        <title>Mystery Hunt 2022</title>\n',
377      '        <script src="/sorttable.js"></script>\n',
378      '        <meta http-equiv="refresh" content = "15">\n',
379      '    </head>\n',
380      '    <div class="sidenav">\n',
381      '      <a href="index.html">Hunt Overview</a>',
382      '      <a href="all.html">All Puzzles</a>\n',
383      '      <a href="unsolved.html">Unsolved</a>\n',
384      '      <a href="solved.html">Solved</a>\n',
385      '      <a href="https://docs.google.com/document/d/14Ww6vWFO4hx1GYz8zDRxP_rI_v4hmRgdgYmN91F-Lqk/edit" target="_blank" rel="noreferrer noopener">Turbot Docs</a>\n'
386      '    </div>\n',
387      '    <body>\n',
388      '        <h1><b>{}</b></h1>\n'.format('{} Puzzles').format(filt),
389      '        <p>{}</p>\n'.format(link(internal_link(hunt, 'index') + ".html", 'Hunt Overview')),
390      '        <div>\n',
391      '            <table class="center sortable">\n',
392      '                <thead>\n',
393      '                    <tr>\n',
394      '                        <th>Puzzle Title/Slack</th>\n',
395      '                        <th>Puzzle Link</th>\n',
396      '                        <th>Sheet</th>\n',
397      '                        <th>Overview</th>\n',
398      '                        <th>Answer</th>\n',
399      '                        <th>Round(s)</th>\n',
400      '                        <th>Tags</th>\n',
401      '                    </tr>\n',
402      '                </thead>\n',
403      '                <tbody>\n']
404     solved_code = []
405     unsolved_code = []
406     for puzzle in solved_puzzles:
407         if puzzle['type'] == 'meta':
408             meta = ' [META]'
409         else:
410             meta = ''
411         slack_url = channel_url(puzzle['channel_id'])
412         if 'rounds' in puzzle:
413             round_url = link(internal_link(hunt, filename_from_name(puzzle['rounds'][0])) + "_round.html", puzzle['rounds'][0])
414         else:
415             round_url = ''
416         #assuming one round per puzzle for now
417
418         solved_code += ['                    <tr>\n',
419          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
420          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
421          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
422          '                        <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
423          '                        <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
424          '                        <td>{}</td>\n'.format(round_url),
425          '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
426          '                    </tr>\n']
427     for puzzle in unsolved_puzzles:
428         if puzzle['type'] == 'meta':
429             meta = ' [META]'
430         else:
431             meta = ''
432         slack_url = channel_url(puzzle['channel_id'])
433         if 'rounds' in puzzle:
434             round_url = link(internal_link(hunt, filename_from_name(puzzle['rounds'][0])) + "_round.html", puzzle['rounds'][0])
435         else:
436             round_url = ''
437         #assuming one round per puzzle for now
438
439         unsolved_code += ['                    <tr>\n',
440          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
441          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
442          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
443          '                        <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
444          '                        <td></td>\n',
445          '                        <td>{}</td>\n'.format(round_url),
446          '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
447          '                    </tr>\n']
448     end = ['                </tbody>\n',
449     '            </table>\n',
450     '        </div>\n',
451     '    </body>\n',
452     '</html>\n']
453     if filt == "All":
454         file1 = hunt_file(hunt, 'all.html')
455         f = open(file1 + ".tmp", "w")
456         for line in start + unsolved_code + solved_code + end:
457             f.write(line)
458         f.close()
459         os.rename(file1 + ".tmp", file1)
460     elif filt == "Solved":
461         file2 = hunt_file(hunt, 'solved.html')
462         f = open(file2 + ".tmp", 'w')
463         for line in start + solved_code + end:
464             f.write(line)
465         f.close()
466         os.rename(file2 + ".tmp", file2)
467     elif filt == "Unsolved":
468         file3 = hunt_file(hunt, 'unsolved.html')
469         f = open(file3 + ".tmp", 'w')
470         for line in start + unsolved_code + end:
471             f.write(line)
472         f.close()
473         os.rename(file3 + ".tmp", file3)
474     return None
475
476 def generate_for_hunt_id(table, hunt_id):
477     hunt, puzzles, rounds = hunt_info(table, hunt_id)
478
479     # Create a directory for the hunt in the WEBROOT
480     root = hunt_file(hunt, "")
481     try:
482         os.mkdir(root)
483     except FileExistsError:
484         #  We're happy as a clam if the directory already exists
485         pass
486
487     overview(hunt, puzzles, rounds)
488     for rnd in rounds:
489         round_overview(hunt, rnd, puzzles)
490     for puzzle in puzzles:
491         puzzle_overview(hunt, puzzle)
492     puzzle_lists(hunt, puzzles, "All")
493     puzzle_lists(hunt, puzzles, "Solved")
494     puzzle_lists(hunt, puzzles, "Unsolved")
495             
496
497 # Initialize AWS resources to talk to the database
498 db = boto3.resource('dynamodb')
499 table = db.Table("turbot")
500
501 def usage():
502     print("Usage: {} hunt_id [...]")
503     print("")
504     print("Generates pages (under {}) ".format(WEBROOT))
505     print("for the specified hunt_id(s).")
506
507 if len(sys.argv) < 2:
508     usage()
509     sys.exit(1)
510
511 for hunt_id in sys.argv[1:]:
512     generate_for_hunt_id(table, hunt_id)