| 7 | | import sys, inspect |
|---|
| 8 | | |
|---|
| 9 | | |
|---|
| 10 | | def try_int(s): |
|---|
| 11 | | "Convert to integer if possible." |
|---|
| 12 | | try: return int(s) |
|---|
| 13 | | except: return s |
|---|
| 14 | | |
|---|
| 15 | | def natsort_key(s): |
|---|
| 16 | | "Used internally to get a tuple by which s is sorted." |
|---|
| 17 | | import re |
|---|
| 18 | | return map(try_int, re.findall(r'(\d+|\D+)', s)) |
|---|
| 19 | | |
|---|
| 20 | | def natcmp(a, b): |
|---|
| 21 | | "Natural string comparison, case sensitive." |
|---|
| 22 | | return cmp(natsort_key(a), natsort_key(b)) |
|---|
| 23 | | |
|---|
| 24 | | def natcasecmp(a, b): |
|---|
| 25 | | "Natural string comparison, ignores case." |
|---|
| 26 | | return natcmp(a.lower(), b.lower()) |
|---|
| 27 | | |
|---|
| 28 | | def natsort(seq, cmp=natcmp): |
|---|
| 29 | | "In-place natural string sort." |
|---|
| 30 | | seq.sort(cmp) |
|---|
| 31 | | |
|---|
| 32 | | def natsorted(seq, cmp=natcmp): |
|---|
| 33 | | "Returns a copy of seq, sorted by natural string sort." |
|---|
| 34 | | temp = list(seq) |
|---|
| 35 | | natsort(temp, cmp) |
|---|
| 36 | | return temp |
|---|
| 37 | | |
|---|
| 38 | | |
|---|
| 39 | | class TracHacksMacros(Component): |
|---|
| 40 | | """ List of meta types """ |
|---|
| 41 | | implements(IWikiMacroProvider) |
|---|
| 42 | | |
|---|
| 43 | | # IWikiMacroProvider methods |
|---|
| 44 | | def get_macros(self): |
|---|
| 45 | | yield 'ListTypes' |
|---|
| 46 | | |
|---|
| 47 | | def get_macro_description(self, name): |
|---|
| 48 | | return "Main hack type listing" |
|---|
| 49 | | |
|---|
| 50 | | def render_macro(self, req, name, content): |
|---|
| 51 | | from StringIO import StringIO |
|---|
| 52 | | from trac.wiki import wiki_to_html |
|---|
| 53 | | from trac.wiki.model import WikiPage |
|---|
| 54 | | from trac.util import Markup |
|---|
| 55 | | from tractags.api import TagEngine |
|---|
| 56 | | import re |
|---|
| 57 | | |
|---|
| 58 | | tagspace = TagEngine(self.env).tagspace.wiki |
|---|
| 59 | | |
|---|
| 60 | | out = StringIO() |
|---|
| 61 | | pages = tagspace.get_tagged_names(tags=['type']) |
|---|
| 62 | | pages = sorted(pages) |
|---|
| 63 | | |
|---|
| 64 | | out.write('<form style="text-align: right; padding-top: 1em; margin-right: 5em;" method="get">') |
|---|
| 65 | | out.write('<span style="font-size: xx-small">') |
|---|
| 66 | | out.write('Show hacks for releases: ') |
|---|
| 67 | | releases = natsorted(tagspace.get_tagged_names(tags=['release'])) |
|---|
| 68 | | if 'update_th_filter' in req.args: |
|---|
| 69 | | show_releases = req.args.get('release', ['0.10']) |
|---|
| 70 | | if isinstance(show_releases, basestring): |
|---|
| 71 | | show_releases = [show_releases] |
|---|
| 72 | | req.session['th_release_filter'] = ','.join(show_releases) |
|---|
| | 14 | from trac.wiki.model import WikiPage |
|---|
| | 15 | from trac.util.compat import sorted |
|---|
| | 16 | from trac.web.api import IRequestHandler |
|---|
| | 17 | from trac.web.chrome import ITemplateProvider, INavigationContributor, \ |
|---|
| | 18 | add_stylesheet, add_script, add_ctxtnav |
|---|
| | 19 | from trac.resource import get_resource_url |
|---|
| | 20 | from tractags.api import TagSystem |
|---|
| | 21 | from tractags.macros import render_cloud |
|---|
| | 22 | from tracvote import VoteSystem |
|---|
| | 23 | from genshi.builder import tag as builder |
|---|
| | 24 | |
|---|
| | 25 | |
|---|
| | 26 | |
|---|
| | 27 | def pluralise(n, word): |
|---|
| | 28 | """Return a (naively) pluralised phrase from a count and a singular |
|---|
| | 29 | word.""" |
|---|
| | 30 | if n == 0: |
|---|
| | 31 | return 'No %ss' % word |
|---|
| | 32 | elif n == 1: |
|---|
| | 33 | return '%i %s' % (n, word) |
|---|
| | 34 | else: |
|---|
| | 35 | return '%i %ss' % (n, word) |
|---|
| | 36 | |
|---|
| | 37 | |
|---|
| | 38 | def natural_sort(l): |
|---|
| | 39 | """Sort the given list in the way that humans expect.""" |
|---|
| | 40 | convert = lambda text: int(text) if text.isdigit() else text |
|---|
| | 41 | alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)] |
|---|
| | 42 | return sorted(l, key=alphanum_key) |
|---|
| | 43 | |
|---|
| | 44 | |
|---|
| | 45 | class TracHacksHandler(Component): |
|---|
| | 46 | """Trac-Hacks request handler.""" |
|---|
| | 47 | implements(INavigationContributor, IRequestHandler, ITemplateProvider) |
|---|
| | 48 | |
|---|
| | 49 | limit = IntOption('trachacks', 'limit', 25, |
|---|
| | 50 | 'Default maximum number of hacks to display.') |
|---|
| | 51 | |
|---|
| | 52 | path_match = re.compile(r'/hacks/?(.+)?') |
|---|
| | 53 | title_extract = re.compile(r'=\s+([^=]*)=', re.MULTILINE | re.UNICODE) |
|---|
| | 54 | |
|---|
| | 55 | # IRequestHandler methods |
|---|
| | 56 | def match_request(self, req): |
|---|
| | 57 | return self.path_match.match(req.path_info) |
|---|
| | 58 | |
|---|
| | 59 | def process_request(self, req): |
|---|
| | 60 | data = {} |
|---|
| | 61 | tag_system = TagSystem(self.env) |
|---|
| | 62 | |
|---|
| | 63 | match = self.path_match.match(req.path_info) |
|---|
| | 64 | view = 'cloud' |
|---|
| | 65 | if match.group(1): |
|---|
| | 66 | view = match.group(1) |
|---|
| | 67 | |
|---|
| | 68 | # Hack types |
|---|
| | 69 | types = [r.id for r, _ in tag_system.query(req, 'realm:wiki type')] |
|---|
| | 70 | # Trac releases |
|---|
| | 71 | releases = natural_sort([r.id for r, _ in |
|---|
| | 72 | tag_system.query(req, 'realm:wiki release')]) |
|---|
| | 73 | |
|---|
| | 74 | hacks = self.fetch_hacks(req, data, types) |
|---|
| | 75 | |
|---|
| | 76 | add_stylesheet(req, 'tags/css/tractags.css') |
|---|
| | 77 | add_stylesheet(req, 'hacks/css/trachacks.css') |
|---|
| | 78 | add_script(req, 'hacks/js/trachacks.js') |
|---|
| | 79 | |
|---|
| | 80 | views = ['cloud', 'list'] |
|---|
| | 81 | for v in views: |
|---|
| | 82 | if v != view: |
|---|
| | 83 | add_ctxtnav(req, builder.a(v.title(), href=req.href.hacks(v))) |
|---|
| | 84 | else: |
|---|
| | 85 | add_ctxtnav(req, v.title()) |
|---|
| | 86 | if view == 'cloud': |
|---|
| | 87 | return self.render_cloud(req, data, hacks) |
|---|
| | 88 | elif view == 'list': |
|---|
| | 89 | return self.render_list(req, data, hacks) |
|---|
| | 90 | |
|---|
| | 91 | def fetch_hacks(self, req, data, types): |
|---|
| | 92 | """Return a list of hacks in the form |
|---|
| | 93 | |
|---|
| | 94 | [votes, rank, resource, tags, title] |
|---|
| | 95 | """ |
|---|
| | 96 | tag_system = TagSystem(self.env) |
|---|
| | 97 | vote_system = VoteSystem(self.env) |
|---|
| | 98 | hacks = [] |
|---|
| | 99 | global limit |
|---|
| | 100 | ALL = 9999 |
|---|
| | 101 | limit = req.args.get('limit', self.limit) |
|---|
| | 102 | |
|---|
| | 103 | # Custom tag query modifiers |
|---|
| | 104 | def top_modifier(name, node, context): |
|---|
| | 105 | """top:<n> Only show the top N results.""" |
|---|
| | 106 | global limit |
|---|
| | 107 | if node.value == 'all': |
|---|
| | 108 | limit = ALL |
|---|
| | 109 | return True |
|---|
| | 110 | try: |
|---|
| | 111 | assert node.type == node.TERM |
|---|
| | 112 | limit = int(node.value) |
|---|
| | 113 | except (AssertionError, ValueError): |
|---|
| | 114 | raise TracError('top: expects an integer') |
|---|
| | 115 | return True |
|---|
| | 116 | |
|---|
| | 117 | data['tag_query'] = req.args.get('q', '') |
|---|
| | 118 | |
|---|
| | 119 | # Get list of hacks from tag system |
|---|
| | 120 | query = 'realm:wiki (%s)' % ' or '.join(types) |
|---|
| | 121 | if req.args.get('q'): |
|---|
| | 122 | query += ' (' + req.args.get('q', '') + ')' |
|---|
| | 123 | self.env.log.debug('Hack query: %s', query) |
|---|
| | 124 | attribute_handlers={'top': top_modifier,} |
|---|
| | 125 | try: |
|---|
| | 126 | tagged = list(tag_system.query(req, query, |
|---|
| | 127 | attribute_handlers=attribute_handlers)) |
|---|
| | 128 | except TracError, e: |
|---|
| | 129 | tagged = [] |
|---|
| | 130 | tagged = tag_system.query(req, 'realm:wiki (#s)' % ' or '.join(types), |
|---|
| | 131 | attribute_handlers=attribute_handlers) |
|---|
| | 132 | data['tag_query_error'] = str(e) |
|---|
| | 133 | |
|---|
| | 134 | self.env.log.debug(limit) |
|---|
| | 135 | if limit != ALL: |
|---|
| | 136 | data['limit'] = 'top %s' % limit |
|---|
| 74 | | show_releases = req.session.get('th_release_filter', '0.10').split(',') |
|---|
| 75 | | for version in releases: |
|---|
| 76 | | checked = version in show_releases |
|---|
| 77 | | out.write('<input type="checkbox" name="release" value="%s"%s>%s\n' % (version, checked and ' checked' or '', version)) |
|---|
| 78 | | out.write('<input name="update_th_filter" type="submit" style="font-size: xx-small; padding: 0; border: solid 1px black" value="Update"/>') |
|---|
| 79 | | out.write('</span>') |
|---|
| 80 | | out.write('</form>') |
|---|
| 81 | | for i, pagename in enumerate(pages): |
|---|
| 82 | | page = WikiPage(self.env, pagename) |
|---|
| 83 | | if page.text: |
|---|
| 84 | | topmargin = '0em' |
|---|
| 85 | | if i < len(pages) - 1: |
|---|
| 86 | | bottommargin = '0em' |
|---|
| 87 | | else: |
|---|
| 88 | | bottommargin = '2em' |
|---|
| 89 | | |
|---|
| 90 | | out.write('<fieldset style="padding: 1em; margin: %s 5em %s 5em; border: 1px solid #999;">\n' % (topmargin, bottommargin)) |
|---|
| 91 | | body = page.text |
|---|
| 92 | | title = re.search('=+\s([^=]*)=+', body) |
|---|
| 93 | | if title: |
|---|
| 94 | | title = title.group(1).strip() |
|---|
| 95 | | body = re.sub('=+\s([^=]*)=+', '', body, 1) |
|---|
| 96 | | else: |
|---|
| 97 | | title = pagename |
|---|
| 98 | | body = re.sub('\\[\\[TagIt.*', '', body) |
|---|
| 99 | | out.write('<legend style="color: #999;"><a href="%s">%s</a></legend>\n' % (self.env.href.wiki(pagename), title)) |
|---|
| 100 | | body = wiki_to_html(body, self.env, req) |
|---|
| 101 | | # Dear God, the horror! |
|---|
| 102 | | for line in body.splitlines(): |
|---|
| 103 | | show = False |
|---|
| 104 | | for release in show_releases: |
|---|
| 105 | | self.env.log.debug(release) |
|---|
| 106 | | if '>%s</a>' % release in line: |
|---|
| 107 | | show = True |
|---|
| 108 | | break |
|---|
| 109 | | if show or not '<li>' in line: |
|---|
| 110 | | out.write(line) |
|---|
| 111 | | |
|---|
| 112 | | out.write('</fieldset>\n') |
|---|
| 113 | | |
|---|
| 114 | | return out.getvalue() |
|---|
| | 138 | data['limit'] = 'all' |
|---|
| | 139 | |
|---|
| | 140 | # Build hacks list |
|---|
| | 141 | for resource, tags in tagged: |
|---|
| | 142 | page = WikiPage(self.env, resource.id) |
|---|
| | 143 | _, count, _ = vote_system.get_vote_counts(resource) |
|---|
| | 144 | match = self.title_extract.search(page.text) |
|---|
| | 145 | count_string = pluralise(count, 'vote') |
|---|
| | 146 | if match: |
|---|
| | 147 | title = '%s (%s)' % (match.group(1).strip(), count_string) |
|---|
| | 148 | else: |
|---|
| | 149 | title = '%s' % count_string |
|---|
| | 150 | hacks.append([count, None, resource, tags, title]) |
|---|
| | 151 | |
|---|
| | 152 | # Rank |
|---|
| | 153 | hacks = sorted(hacks, key=lambda i: -i[0])[:limit] |
|---|
| | 154 | for i, hack in enumerate(hacks): |
|---|
| | 155 | hack[1] = i |
|---|
| | 156 | return hacks |
|---|
| | 157 | |
|---|
| | 158 | def render_list(self, req, data, hacks): |
|---|
| | 159 | ul = builder.ul() |
|---|
| | 160 | for votes, rank, resource, tags, title in sorted(hacks, key=lambda h: h[2].id): |
|---|
| | 161 | li = builder.li(builder.a(resource.id, |
|---|
| | 162 | href=req.href.wiki(resource.id)), |
|---|
| | 163 | ' - ', title) |
|---|
| | 164 | ul(li) |
|---|
| | 165 | data['tag_body'] = ul |
|---|
| | 166 | return 'hacks_view.html', data, None |
|---|
| | 167 | |
|---|
| | 168 | def render_cloud(self, req, data, hacks): |
|---|
| | 169 | by_name = dict([(r[2].id, r) for r in hacks]) |
|---|
| | 170 | |
|---|
| | 171 | def link_renderer(tag, count, percent): |
|---|
| | 172 | votes, rank, resource, tags, title = by_name[tag] |
|---|
| | 173 | href = req.href.wiki(resource.id) |
|---|
| | 174 | font_size = 10.0 + (percent * 20.0) |
|---|
| | 175 | colour = 128.0 - (percent * 128.0) |
|---|
| | 176 | colour = '#%02x%02x%02x' % ((colour,) * 3) |
|---|
| | 177 | a = builder.a(tag, rel='tag', title=title, href=href, class_='tag', |
|---|
| | 178 | style='font-size: %ipx; color: %s' % (font_size, colour)) |
|---|
| | 179 | return a |
|---|
| | 180 | |
|---|
| | 181 | cloud_hacks = dict([(hack[2].id, hack[0]) for hack in hacks]) |
|---|
| | 182 | data['tag_body'] = render_cloud(self.env, req, cloud_hacks, link_renderer) |
|---|
| | 183 | |
|---|
| | 184 | return 'hacks_view.html', data, None |
|---|
| | 185 | |
|---|
| | 186 | # INavigationContributor methods |
|---|
| | 187 | def get_active_navigation_item(self, req): |
|---|
| | 188 | return 'hacks' |
|---|
| | 189 | |
|---|
| | 190 | def get_navigation_items(self, req): |
|---|
| | 191 | yield ('mainnav', 'hacks', |
|---|
| | 192 | builder.a('Hacks', href=req.href.hacks(), accesskey='H')) |
|---|
| | 193 | |
|---|
| | 194 | # ITemplateProvider methods |
|---|
| | 195 | def get_templates_dirs(self): |
|---|
| | 196 | """ |
|---|
| | 197 | Return the absolute path of the directory containing the provided |
|---|
| | 198 | ClearSilver templates. |
|---|
| | 199 | """ |
|---|
| | 200 | from pkg_resources import resource_filename |
|---|
| | 201 | return [resource_filename(__name__, 'templates')] |
|---|
| | 202 | |
|---|
| | 203 | def get_htdocs_dirs(self): |
|---|
| | 204 | """Return the absolute path of a directory containing additional |
|---|
| | 205 | static resources (such as images, style sheets, etc). |
|---|
| | 206 | """ |
|---|
| | 207 | from pkg_resources import resource_filename |
|---|
| | 208 | return [('hacks', resource_filename(__name__, 'htdocs'))] |
|---|
| | 209 | |
|---|
| | 210 | |
|---|
| 149 | | |
|---|
| 150 | | class TracHacksRPC(Component): |
|---|
| 151 | | """ Allow inspection of hacks on TracHacks. """ |
|---|
| 152 | | implements(IXMLRPCHandler) |
|---|
| 153 | | |
|---|
| 154 | | def xmlrpc_namespace(self): |
|---|
| 155 | | return 'trachacks' |
|---|
| 156 | | |
|---|
| 157 | | def xmlrpc_methods(self): |
|---|
| 158 | | yield ('XML_RPC', ((list, str, str),), self.getHacks) |
|---|
| 159 | | yield ('XML_RPC', ((list,),), self.getReleases) |
|---|
| 160 | | yield ('XML_RPC', ((list,),), self.getTypes) |
|---|
| 161 | | yield ('XML_RPC', ((dict,str),), self.getDetails) |
|---|
| 162 | | |
|---|
| 163 | | # Other methods |
|---|
| 164 | | def getReleases(self, req): |
|---|
| 165 | | """ Return a list of Trac releases TracHacks is aware of. """ |
|---|
| 166 | | from tractags.api import TagEngine |
|---|
| 167 | | return TagEngine(self.env).tagspace.wiki.get_tagged_names(['release']) |
|---|
| 168 | | |
|---|
| 169 | | def getTypes(self, req): |
|---|
| 170 | | """ Return a list of known Hack types. """ |
|---|
| 171 | | from tractags.api import TagEngine |
|---|
| 172 | | return TagEngine(self.env).tagspace.wiki.get_tagged_names(['type']) |
|---|
| 173 | | |
|---|
| 174 | | def getHacks(self, req, release, type): |
|---|
| 175 | | """ Fetch a list of hacks for Trac release, of type. """ |
|---|
| 176 | | from trac.versioncontrol.api import Node |
|---|
| 177 | | from tractags.api import TagEngine |
|---|
| 178 | | repo = self.env.get_repository(req.authname) |
|---|
| 179 | | wikitags = TagEngine(self.env).tagspace.wiki |
|---|
| 180 | | repo_rev = repo.get_youngest_rev() |
|---|
| 181 | | releases = wikitags.get_tagged_names([release]) |
|---|
| 182 | | types = wikitags.get_tagged_names([type]) |
|---|
| 183 | | for plugin in releases.intersection(types): |
|---|
| 184 | | if plugin.startswith('tags/'): continue |
|---|
| 185 | | path = '%s/%s' % (plugin.lower(), release) |
|---|
| 186 | | rev = 0 |
|---|
| 187 | | if repo.has_node(str(path), repo_rev): |
|---|
| 188 | | node = repo.get_node(path) |
|---|
| 189 | | rev = node.rev |
|---|
| 190 | | yield (plugin, rev) |
|---|
| 191 | | |
|---|
| 192 | | def getDetails(self, req, hack): |
|---|
| 193 | | """ Fetch hack details. Returns dict with name, dependencies and |
|---|
| 194 | | description. """ |
|---|
| 195 | | from tractags.api import TagEngine |
|---|
| 196 | | wikitags = TagEngine(self.env).tagspace.wiki |
|---|
| 197 | | tags = wikitags.get_tags(hack) |
|---|
| 198 | | types = self.getTypes() |
|---|
| 199 | | hacks = wikitags.get_tagged_names(types) |
|---|
| 200 | | |
|---|
| 201 | | dependencies = hacks.intersection(tags) |
|---|
| 202 | | href, htmllink, description = wikitags.name_details(hack) |
|---|
| 203 | | return {'name': hack, 'dependencies': tuple(dependencies), |
|---|
| 204 | | 'description': description} |
|---|