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