1#!/usr/bin/env python2.7
2# coding=utf-8
3#
4# Repository Codex Generator by <jaakko.keranen@iki.fi>
5# Generates a set of web pages out of tags in Git commit headlines.
6# License: GNU GPL version 2 (or later)
7
8import os, sys, time, string, base64
9
10OUT_DIR = 'codex'
11TITLE = 'Doomsday Codex'
12
13AUTHOR_ALIASES = {
14    u'Jaakko Keränen': u'skyjake',
15    u'Jaakko Keränen':u'skyjake'
16}
17
18if sys.argv > 1:
19    OUT_DIR = sys.argv[1]
20
21class Commit:
22    def __init__(self, subject, author, date, link, hash):
23        self.subject = subject
24        if author in AUTHOR_ALIASES:
25            self.author = AUTHOR_ALIASES[author]
26        else:
27            self.author = author
28        self.date = date
29        self.link = link
30        self.hash = hash
31        self.extract_tags()
32
33    def add_tag(self, tag):
34        if len(tag) <= 1: return
35        if tag.lower() not in map(lambda x: x.lower(), self.tags):
36            self.tags.append(tag)
37
38    def extract_tags(self):
39        self.tags = []
40
41        sub = self.subject
42
43        if sub.startswith('Revert "') and sub[-1] == '"':
44            sub = sub[8:-1]
45            self.tags.append(u'Revert')
46
47        self.subject = sub[sub.find(': ') + 1:].strip()
48
49        # Some tags are automatically applied based on the subject line.
50        sensitiveWords = self.subject.replace(';', ' ').replace(':', ' ').replace(',', ' ').split(' ')
51        words = map(lambda w: w.lower(), sensitiveWords)
52        if 'fixed' in words: self.add_tag(u'Fixed')
53        if 'cleanup' in words: self.add_tag(u'Cleanup')
54        if 'debug' in words: self.add_tag(u'Debug')
55        if 'fixed' in words: self.add_tag(u'Fixed')
56        if 'added' in words: self.add_tag(u'Added')
57        if 'refactor' in self.subject.lower(): self.add_tag(u'Refactor')
58        if 'GL' in sensitiveWords: self.add_tag(u'GL')
59        if 'performance' in words: self.add_tag(u'Performance')
60        if 'optimize' in words or 'optimized' in words or 'optimizing' in words:
61            self.add_tag(u'Optimize')
62
63        # Are there any actual tags specified?
64        if sub.find(': ') < 0: return
65
66        sub = sub[:sub.find(': ')].replace(',', '|').replace('/', '|').replace('(', '|').\
67            replace('&', '|').replace(')', '').replace('Changed ', 'Changed|')
68
69        for unclean in sub.split('|'):
70            unclean = unclean.replace('*nix', 'Unix')
71            tag = unclean.replace('[', '').replace(']', '').replace('*', '').replace('"', '').strip()
72            if 'ccmd' in tag.lower() or 'cvar' in tag.lower(): tag = u'Console'
73            if 'cleanup' in tag.lower() or 'clean up' in tag.lower(): tag = u'Cleanup'
74            if '_' in tag: continue
75            if tag.lower() == 'chex':
76                tag = u'Chex Quest'
77            if tag.lower() == 'hacx':
78                tag = u'HacX'
79            if 'common' in tag.lower():
80                tag = u'libcommon'
81            if 'doom64' in tag.lower():
82                tag = u'Doom64'
83            elif 'doom' in tag.lower():
84                tag = u'Doom'
85            elif 'heretic' in tag.lower():
86                tag = u'Heretic'
87            elif 'hexen' in tag.lower():
88                tag = u'Hexen'
89            if tag.lower() == 'add-on repository' or tag.lower() == 'addon repository':
90                tag = u'Add-on Repository'
91            if tag.lower() == 'bsp builder' or tag.lower() == 'bspbuilder':
92                tag = u'BSP Builder'
93            if tag.lower() == 'build repository' or tag.lower() == 'buildrepository':
94                tag = u'Build Repository'
95            if tag.lower() == 'deh reader' or tag.lower() == 'dehreader' or \
96                    tag.lower() == 'deh read' or tag.lower() == 'dehacked reader' or \
97                    tag.lower() == 'dehread':
98                tag = u'Deh Reader'
99            if tag.lower() == 'dpdehread':
100                tag = u'dpDehRead'
101            if tag.lower() == 'gcc':
102                tag = u'GCC'
103            if tag.lower() == 'glsandbox':
104                tag = u'GLSandbox'
105            if tag.lower() == 'dsdirectsound':
106                tag = u'dsDirectSound'
107            if tag.lower() == 'busy mode':
108                tag = u'Busy Mode'
109            if tag.lower() == 'dedicated server':
110                tag = u'Dedicated Server'
111            if tag.lower() == 'cmake':
112                tag = u'CMake'
113            if tag.lower().startswith('filesys') or tag.lower().startswith('file sys'):
114                tag = u'File System'
115            if tag.lower() == 'clang':
116                tag = u'Clang'
117            if 'zone' in tag.lower():
118                tag = u'Memory Zone'
119            if tag.lower() == 'infine':
120                tag = u'InFine'
121            if 'all games' in tag.lower():
122                self.add_tag(u'All Games')
123            if tag.lower() == 'lightgrid':
124                tag = u'Light Grid'
125            if tag.lower() == 'render':
126                tag = u'Renderer'
127            if tag.lower() == 'sfx':
128                tag = u'SFX'
129            if tag.lower() == 'taskbarwidth' or tag.lower() == 'taskbar' or tag.lower() == 'taskbarwidget':
130                tag = u'Task Bar'
131            if tag.lower() == 'texture manager':
132                tag = u'Texture Manager'
133            if 'refactoring' in tag.lower() or 'refactored' in tag.lower():
134                tag = u'Refactor'
135            if 'optimization' in tag.lower() or 'optimize' in tag.lower():
136                tag = u'Optimize'
137            if tag == 'osx' or tag.lower() == 'mac' or tag.lower() == 'mac os x' or tag.lower() == 'macos':
138                tag = u'OS X'
139            if tag.lower().startswith('fixed'):
140                tag = tag[5:].strip()
141                self.add_tag(u'Fixed')
142            if tag.lower().startswith('added'):
143                tag = tag[5:].strip()
144                self.add_tag(u'Added')
145            if len(tag) > 2 and (tag[0] == '(' and tag[-1] == ')'):
146                tag = tag[1:-1]
147            if tag == u'WadMapConverter':
148                tag = u'Wad Map Converter'
149            if tag.lower() == 'win32':
150                tag = u'Windows'
151            self.add_tag(tag)
152
153def contains_tag(subject):
154    pos = subject.find(': ')
155    if pos < 0:
156        # It might still have some recognized words.
157        for allowed in ['debug', 'optimize', 'cleanup', 'fixed', 'added', 'refactor']:
158            if allowed in subject.lower():
159                return True
160    if pos > 60: return False
161    for badWord in ['related ', ' up ', ' the ', ' a ', ' in ', ' of', ' on ',
162                    ' and ', ' then ', ' when', ' to ',  ' for ', ' with ', ' into ']:
163        if badWord in subject[:pos]:
164            return False
165    if subject[pos-4:pos+1] == 'TODO:': return False
166    # It can only contain colons after the tag marker.
167    p = subject.find(':')
168    if p >= 0 and p < pos: return False
169    p = subject.find('.')
170    if p >= 0 and p < pos: return False
171    return True
172
173def fetch_commits():
174    commits = {}
175
176    tmpName = '__ctmp'
177    format = '[[Subject]]%s[[/Subject]]' + \
178             '[[Author]]%an[[/Author]]' + \
179             '[[Date]]%ai[[/Date]]' + \
180             '[[Link]]http://github.com/skyjake/Doomsday-Engine/commit/%H[[/Link]]' + \
181             '[[Hash]]%H[[/Hash]]'
182    os.system("git log --format=\"%s\" >> %s" % (format, tmpName))
183    logText = unicode(file(tmpName, 'rt').read(), 'utf-8')
184    os.remove(tmpName)
185
186    pos = 0
187    while True:
188        pos = logText.find('[[Subject]]', pos)
189        if pos < 0: break # No more.
190        end = logText.find('[[/Subject]]', pos)
191        subject = logText[pos+11:end]
192
193        # Author.
194        pos = logText.find('[[Author]]', pos)
195        end = logText.find('[[/Author]]', pos)
196        author = logText[pos+10:end]
197
198        # Date.
199        pos = logText.find('[[Date]]', pos)
200        end = logText.find('[[/Date]]', pos)
201        date = logText[pos+8:end]
202
203        # Link.
204        pos = logText.find('[[Link]]', pos)
205        end = logText.find('[[/Link]]', pos)
206        link = logText[pos+8:end]
207
208        # Hash.
209        pos = logText.find('[[Hash]]', pos)
210        end = logText.find('[[/Hash]]', pos)
211        hash = logText[pos+8:end]
212
213        if contains_tag(subject):
214            commits[hash] = Commit(subject, author, date, link, hash)
215
216    return commits
217
218# Compile the commit database.
219byHash = fetch_commits()
220byTag = {}
221for commit in byHash.values():
222    # Index all commits by tag.
223    for tag in commit.tags:
224        if tag in byTag:
225            if commit not in byTag[tag]:
226                byTag[tag].append(commit)
227        else:
228            byTag[tag] = [commit]
229
230relatedTags = [
231    ['Fixed', 'Added', 'Refactor', 'Optimize', 'Revert', 'Cleanup', 'Debug'],
232    ['Windows', 'OS X', 'macOS', 'Linux', 'Unix', 'Debian', 'Ubuntu', 'FreeBSD', 'X11', '64-bit'],
233    ['Windows', 'Windows *'],
234    ['Qt', 'SDL'],
235    ['Builder', 'Builds', 'qmake', 'Project*', 'CMake', 'Distrib', 'GCC', 'MSVC', 'Clang', 'Git',
236     'TextMate', 'wikidocs'],
237    ['Test*', 'GLSandbox'],
238    ['Docs', 'Documentation', 'Codex', 'Readme', 'Doxygen', 'Amethyst'],
239    ['Homepage', 'Add-on Repository', 'Build Repository', 'CSS', 'RSS', 'DEW', 'Forums'],
240    ['libdeng2', 'libcore', 'App', 'Config', 'Log', 'LogBuffer', 'Widgets', 'Garbage', 'Garbages'],
241    ['libgui', 'DisplayMode', 'GL', 'OpenGL', 'GL*', 'Atlas*', 'Image', '*Bank', 'Font'],
242    ['libdeng', 'libdeng1', 'liblegacy', 'Str'],
243    ['libshell', 'Shell', 'AbstractLineEditor', 'Link'],
244    ['Client', 'Client UI', 'UI', 'Console', 'Control Panel', 'Default Style', 'GameSelectionWidget',
245     'Task Bar', 'Updater', 'WindowSystem', 'Client*'],
246    ['Server', 'Dedicated Server', 'Dedicated Mode', 'Server*'],
247    ['Snowberry', 'Shell', 'Amethyst', 'md2tool'],
248    ['All Games', 'Doom', 'Heretic', 'Hexen', 'Doom64', 'Chex Quest', 'HacX'],
249    ['Plugins', 'Wad Map Converter', 'Deh Reader', 'dsDirectSound', 'OpenAL', 'dpDehRead',
250     'dsWinMM', 'Dummy Audio', 'Example Plugin', 'exampleplugin', 'FluidSynth', 'FMOD'],
251    ['API', 'DMU', 'DMU API'],
252    ['DED', 'DED Parser', 'Ded Reader', 'Definitions', 'Info', 'ScriptedInfo'],
253    ['Ring Zero', 'GameSelectionWidget'],
254    ['Script*', 'scriptsys', 'Script', 'Record', 'Variable'],
255    ['File System', 'Folder', 'Feed', 'FS', 'FS1', 'File*', '*File'],
256    ['Resource*', 'Material*', 'Texture*', 'Uri'],
257    ['Renderer', '* Renderer', 'Model*', 'Light Grid'],
258    ['Network', 'Multiplayer', 'Server', 'Protocol'],
259    ['Concurrency', 'Task', 'libdeng2'],
260    ['libcommon', 'Game logic', 'Menu', 'Game Menu', 'Game Save', 'Games', 'Automap'],
261    ['World', 'GameMap', 'Map', 'BSP Builder', 'HEdge', 'Bsp*', 'Line*', 'Sector', 'SectionEdge', 'Wall*',
262     'Blockmap', 'Polyobj', 'Plane'],
263    ['Finale Interpreter', 'Finales', 'InFine'],
264    ['Input*', 'Bindings', 'Joystick', 'MouseEvent', 'KeyEvent', 'libgui'],
265    ['Audio*', 'SFX', 'Music', 'FluidSynth'],
266    ['Widget*', '*Widget', '*Rule', 'Rule*', 'Animation' ]
267]
268
269def find_related_tags(tag):
270    rels = []
271    for group in relatedTags:
272        # Any wildcards?
273        tags = []
274        for t in group:
275            if '*' not in t:
276                tags.append(t)
277            elif t[0] == '*':
278                for x in byTag.keys():
279                    if x.endswith(t[1:]):
280                        tags.append(x)
281            elif t[-1] == '*':
282                for x in byTag.keys():
283                    if x.startswith(t[:-1]):
284                        tags.append(x)
285        if tag not in tags: continue
286        for t in tags:
287            if t != tag and t not in rels:
288                rels.append(t)
289    return sorted(rels, key=lambda s: s.lower())
290
291if not os.path.exists(OUT_DIR): os.mkdir(OUT_DIR)
292
293def tag_filename(tag):
294    return base64.urlsafe_b64encode(tag)
295
296def encoded_text(logText):
297    logText = logText.replace(u'&', u'&amp;')
298    logText = logText.replace(u'ä', u'&auml;')
299    logText = logText.replace(u'ö', u'&ouml;')
300    logText = logText.replace(u'Ä', u'&Auml;')
301    logText = logText.replace(u'Ö', u'&Ouml;')
302    logText = logText.replace(u'<', u'&lt;')
303    logText = logText.replace(u'>', u'&gt;')
304    logText = filter(lambda c: c in string.whitespace or c > ' ', logText)
305    return logText
306
307def print_header(out, pageTitle):
308    print >> out, '<!DOCTYPE html>'
309    print >> out, '<html>'
310    print >> out, '<head>'
311    print >> out, '<meta charset="UTF-8">'
312    print >> out, '<title>%s - %s</title>' % (pageTitle, TITLE)
313    print >> out, '<style type="text/css">'
314    print >> out, 'body, table { font-family: "Helvetica Neue", sans-serif; }'
315    print >> out, 'a { color: black; text-decoration: none; }'
316    print >> out, 'a:hover { text-decoration: underline; }'
317    print >> out, '.skiplist { line-height: 150%; }'
318    print >> out, 'table { width: 100%; }'
319    print >> out, 'table td { vertical-align: top; }'
320    print >> out, 'td.commit-date { width: 100px; padding-left: 1.5ex; }'
321    print >> out, 'td.commit-tags { width: 200px; text-align: right; }'
322    print >> out, '.footer { text-align: center; font-size: 80%; }'
323    print >> out, '.end-symbol { font-size: 125%; }'
324    print >> out, '</style>'
325    print >> out, '</head>'
326    print >> out, '<body>'
327    print >> out, '<form id="findform" name="find" action="find_tag.php" method="get"><a href="index.html">Alphabetical Index</a> | <a href="index_by_size.html">Tags by Size</a> | Find tag: <input type="text" name="tag"> <input type="submit" value="Find"></form>'
328    print >> out, '<h1>%s</h1>' % pageTitle
329
330def print_footer(out):
331    print >> out, '<div class="footer"><p class="end-symbol">&oplus;</p><p>This is the <a href="http://dengine.net/">Doomsday Engine</a> <a href="http://github.com/skyjake/Doomsday-Engine/">source code repository</a> commit tag index.</p><p>Last updated: %s</p></div>' % time.asctime()
332    print >> out, '</body>'
333    print >> out, '</html>'
334
335def print_table(out, cells, cell_printer, numCols=4, tdStyle='', separateByFirstLetter=False, letterFunc=None):
336    colSize = (len(cells) + numCols - 1) / numCols
337    tdElem = '<td style="width: %i%%; %s">' % (100/numCols, tdStyle)
338    print >> out, '<table><tr>' + tdElem
339
340    idx = 0
341    inCol = 0
342    letter = ''
343
344    while idx < len(cells):
345        if separateByFirstLetter:
346            if inCol > 0 and letter != '' and letter != letterFunc(cells[idx]):
347                print >> out, '<br/>'
348            letter = letterFunc(cells[idx])
349
350        cell_printer(out, cells[idx])
351
352        idx += 1
353        inCol += 1
354        if inCol == colSize:
355            print >> out, tdElem
356            inCol = 0
357
358    print >> out, '</table>'
359
360# Create the index page with all tags sorted alphabetically.
361out = file(os.path.join(OUT_DIR, 'index.html'), 'wt')
362print_header(out, 'Alphabetical Tag Index')
363
364sortedTags = sorted(byTag.keys(), cmp=lambda a, b: cmp(a.lower(), b.lower()))
365
366def alpha_index_cell(out, tag):
367    count = len(byTag[tag])
368    if count > 100:
369        style = 'font-weight: bold'
370    elif count < 5:
371        style = 'color: #aaa'
372    else:
373        style = ''
374    print >> out, '<a href="tag_%s.html"><span style="%s">%s (%i)</span></a><br/>' % (
375        tag_filename(tag), style, encoded_text(tag), count)
376
377print_table(out, sortedTags, alpha_index_cell, separateByFirstLetter=True,
378            letterFunc=lambda t: t[0].lower())
379
380print_footer(out)
381
382# Create the index page with tags sorted by size.
383out = file(os.path.join(OUT_DIR, 'index_by_size.html'), 'wt')
384print_header(out, 'All Tags by Size')
385
386def tag_size_comp(a, b):
387    c = cmp(len(byTag[b]), len(byTag[a]))
388    if c == 0: return cmp(a.lower(), b.lower())
389    return c
390
391def size_index_cell(out, tag):
392    count = len(byTag[tag])
393    if count > 100:
394        style = 'font-weight: bold'
395    elif count < 5:
396        style = 'color: #aaa'
397    else:
398        style = ''
399    print >> out, '<a href="tag_group_%s.html"><span style="%s">%i %s</span></a><br/>' % (
400        tag_filename(tag), style, count, encoded_text(tag))
401
402print_table(out, sorted(byTag.keys(), cmp=tag_size_comp), size_index_cell)
403
404print_footer(out)
405
406def print_tags(out, com, tag, linkSuffix=''):
407    first = True
408    for other in sorted(com.tags, cmp=lambda a, b: cmp(a.lower(), b.lower())):
409        if other != tag:
410            if not first: print >> out, '| '
411            first = False
412            print >> out, '<b><a href="tag_%s%s.html">%s</a></b>' % (linkSuffix, \
413                    tag_filename(other), encoded_text(other))
414
415def print_commit(out, com, tag, linkSuffix=''):
416    print >> out, trElem + '<td class="commit-date"><a href="%s">' % com.link + com.date[:10] + '</a> '
417    print >> out, '<td class="commit-tags">'
418    print_tags(out, com, tag, linkSuffix)
419    print >> out, '<td>:'
420    print >> out, ('<td><a href="%s">' % com.link + encoded_text(com.subject)[:250] + '</a>').encode('utf8')
421
422colors = ['#f00', '#0a0', '#00f',
423          '#ed0', '#f80', '#0ce',
424          '#88f', '#b0b',
425          '#f99', '#8d8', '#222',
426          '#44a', '#940', '#888']
427
428def html_color(colIdx):
429    if colIdx == None: return '#000'
430    return colors[colIdx % len(colors)]
431
432def color_box(colIdx):
433    return ' <span style="padding-left:1em; background-color:%s">&nbsp;</span> ' % \
434        html_color(colIdx)
435
436def print_date_sorted_commits(out, coms, tag, linkSuffix='', colorIdx=None):
437    months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
438              'September', 'October', 'November', 'December']
439    curDate = ''
440    dateSorted = sorted(coms, key=lambda c: c.date, reverse=True)
441    print >> out, '<table>'
442    for com in dateSorted:
443        # Monthly headers.
444        if curDate == '' or curDate != com.date[:7]:
445            print >> out, '<tr><td colspan="4" style="padding-left:1ex; padding-top:1.5em; padding-bottom:0.5em; font-weight:bold; font-size:110%%; color:%s; border-bottom:1px dotted %s">' % \
446                (html_color(colorIdx), html_color(colorIdx))
447            print >> out, months[int(com.date[5:7]) - 1], com.date[:4]
448        curDate = com.date[:7]
449        print_commit(out, com, tag, linkSuffix)
450    print >> out, '</table>'
451
452# Create the tag redirecting PHP page.
453out = file(os.path.join(OUT_DIR, 'find_tag.php'), 'wt')
454print >> out, '<?php'
455
456print >> out, '$tags = array(', string.join(['"%s" => "%s"' % (tag, tag_filename(tag)) for tag in byTag.keys()], ', '), ');'
457print >> out, """
458$input = stripslashes(strip_tags($_GET["tag"]));
459$style = $_GET["style"];
460if($style == 'grouped') {
461  $style = 'group_';
462} else {
463  $style = '';
464}
465$destination = "index.html";
466$best = -1.0;
467if(strlen($input) > 0 && strlen($input) < 60) {
468  foreach($tags as $tag => $link) {
469    $lev = (float) levenshtein($input, $tag); // case sensitive
470    if(stripos($tag, $input) !== FALSE || stripos($input, $tag) !== FALSE) {
471        // Found as a case insensitive substring, increase likelihood.
472        $lev = $lev/2.0;
473    }
474    if(stripos($tag, $input) === 0) {
475        // Increase likelihood further if the match is in the beginning.
476        $lev = $lev/2.0;
477    }
478    if(!strcasecmp($tag, $input) == 0) {
479        // Case insensitive direct match, increase likelihood.
480        $lev = $lev/2.0;
481    }
482    if($lev == 0) {
483      $destination = "tag_$style$link.html";
484      break;
485    }
486    if($best < 0 || $lev < $best) {
487      $destination = "tag_$style$link.html";
488      $best = $lev;
489    }
490  }
491}
492header("Location: $destination");
493"""
494
495print >> out, "?>"
496
497def print_related_tags(out, tag, style=''):
498    rels = find_related_tags(tag)
499    if len(rels) == 0: return
500    print >> out, '<p>Related tags: '
501    print >> out, string.join(['<a href="tag_%s%s.html">%s</a>' % (style, tag_filename(t), t)
502                               for t in rels], ', ')
503    print >> out, '</p>'
504
505def percentage(part, total):
506    return int(round(float(part)/float(total) * 100))
507
508def print_authorship(out, tag):
509    authors = {}
510    total = len(byTag[tag])
511    for commit in byTag[tag]:
512        if commit.author in authors:
513            authors[commit.author] += 1
514        else:
515            authors[commit.author] = 1
516    sortedAuthors = sorted(authors.keys(), key=lambda a: authors[a], reverse=True)
517    print >> out, '<p>Authorship:', string.join(['%i%% %s' % (percentage(authors[a], total), a)
518                                                 for a in sortedAuthors], ', ').encode('utf8'), '</p>'
519
520#
521# Create pages for each tag.
522#
523for tag in byTag.keys():
524    #print 'Generating tag page:', tag
525
526    trElem = '<tr style="padding:1ex">'
527
528    # First a simple date-based list of commits in this tag.
529    out = file(os.path.join(OUT_DIR, 'tag_%s.html' % tag_filename(tag)), 'wt')
530    print_header(out, tag)
531    print_related_tags(out, tag)
532    print_authorship(out, tag)
533    print >> out, '<p><a href="tag_group_%s.html"><b>View commits by groups</b></a></p>' % tag_filename(tag)
534    print >> out, '<div style="margin-left:1em">'
535    print_date_sorted_commits(out, byTag[tag], tag)
536    print >> out, '</div>'
537    print_footer(out)
538
539    present = {}
540    for com in byTag[tag]:
541        for other in com.tags:
542            if other == tag: continue
543            if other not in present:
544                present[other] = [com]
545            else:
546                present[other].append(com)
547
548    def present_by_size(a, b):
549        c = cmp(len(present[b]), len(present[a]))
550        if c == 0: return cmp(a.lower(), b.lower())
551        return c
552
553    presentSorted = sorted(present.keys(), cmp=present_by_size)
554
555    # Then grouped by subgroup size.
556    out = file(os.path.join(OUT_DIR, 'tag_group_%s.html' % tag_filename(tag)), 'wt')
557    print_header(out, tag + ' (Grouped)')
558    print_related_tags(out, tag, 'group_')
559    print_authorship(out, tag)
560    print >> out, '<p><a id="top"></a><a href="tag_%s.html"><b>View commits by date</b></a></p>' % tag_filename(tag)
561
562    if len(byTag[tag]) > 10:
563        print >> out, '<div class="skiplist"><b>Jump down to:</b>'
564
565        class SkipTagPrinter:
566            def __init__(self):
567                self.color = 0
568
569            def __call__(self, out, tag):
570                print >> out, '<span style="white-space:nowrap;">' + color_box(self.color)
571                self.color += 1
572                print >> out, '<a href="#%s">%s (%i)</a></span><br/>' % (tag_filename(tag),
573                    tag, len(present[tag]))
574
575        print_table(out, presentSorted, SkipTagPrinter(), numCols=5, tdStyle='line-height:150%')
576
577        print >> out, '</div>'
578
579    # First the commits without any subgroups.
580    print >> out, '<div style="margin-left:1em">'
581    print_date_sorted_commits(out, filter(lambda c: len(c.tags) == 1, byTag[tag]), tag)
582    print >> out, '</div>'
583
584    color = 0
585    for tag2 in presentSorted:
586        print >> out, '<h2><a id="%s"></a>%s<a href="tag_group_%s.html">%s</a> (%i) <span style="color:#ccc">&mdash; %s</span></h2>' % (
587            tag_filename(tag2), color_box(color), tag_filename(tag2), tag2, len(present[tag2]), tag)
588        print >> out, '<div style="margin-left:0.85em; border-left:4px solid %s">' % html_color(color)
589        print_date_sorted_commits(out, filter(lambda c: tag2 in c.tags, byTag[tag]), tag, 'group_',
590            colorIdx=color)
591        print >> out, '</div>'
592        print >> out, '<p><small><a href="#top">&uarr; Back to top</a></small></p>'
593        color += 1
594
595    print_footer(out)
596