39
|
1 # -*- coding: utf-8 -*-
|
|
2 #
|
|
3 # Copyright (C) 2003-2004 Edgewall Software
|
|
4 # Copyright (C) 2003-2004 Jonas Borgström <jonas@edgewall.com>
|
|
5 # All rights reserved.
|
|
6 #
|
|
7 # This software is licensed as described in the file COPYING, which
|
|
8 # you should have received as part of this distribution. The terms
|
|
9 # are also available at http://trac.edgewall.com/license.html.
|
|
10 #
|
|
11 # This software consists of voluntary contributions made by many
|
|
12 # individuals. For exact contribution history, see the revision
|
|
13 # history and logs, available at http://projects.edgewall.com/trac/.
|
|
14 #
|
|
15 # Author: Jonas Borgström <jonas@edgewall.com>
|
|
16
|
|
17 import re
|
|
18 import time
|
|
19
|
|
20 from trac.config import IntOption
|
|
21 from trac.core import *
|
|
22 from trac.perm import IPermissionRequestor
|
|
23 from trac.util.datefmt import format_datetime
|
|
24 from trac.util.markup import escape, html, Element
|
|
25 from trac.web import IRequestHandler
|
|
26 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
|
|
27 from trac.wiki import IWikiSyntaxProvider, wiki_to_link
|
|
28
|
|
29
|
|
30 class ISearchSource(Interface):
|
|
31 """
|
|
32 Extension point interface for adding search sources to the Trac
|
|
33 Search system.
|
|
34 """
|
|
35
|
|
36 def get_search_filters(self, req):
|
|
37 """
|
|
38 Return a list of filters that this search source supports. Each
|
|
39 filter must be a (name, label[, default]) tuple, where `name` is the
|
|
40 internal name, `label` is a human-readable name for display and
|
|
41 `default` is an optional boolean for determining whether this filter
|
|
42 is searchable by default.
|
|
43 """
|
|
44
|
|
45 def get_search_results(self, req, terms, filters):
|
|
46 """
|
|
47 Return a list of search results matching each search term in `terms`.
|
|
48 The `filters` parameters is a list of the enabled
|
|
49 filters, each item being the name of the tuples returned by
|
|
50 `get_search_events`.
|
|
51
|
|
52 The events returned by this function must be tuples of the form
|
|
53 (href, title, date, author, excerpt).
|
|
54 """
|
|
55
|
|
56
|
|
57 def search_terms(q):
|
|
58 """
|
|
59 Break apart a search query into its various search terms. Terms are
|
|
60 grouped implicitly by word boundary, or explicitly by (single or double)
|
|
61 quotes.
|
|
62 """
|
|
63 results = []
|
|
64 for term in re.split('(".*?")|(\'.*?\')|(\s+)', q):
|
|
65 if term != None and term.strip() != '':
|
|
66 if term[0] == term[-1] == "'" or term[0] == term[-1] == '"':
|
|
67 term = term[1:-1]
|
|
68 results.append(term)
|
|
69 return results
|
|
70
|
|
71 def search_to_sql(db, columns, terms):
|
|
72 """
|
|
73 Convert a search query into a SQL condition string and corresponding
|
|
74 parameters. The result is returned as a (string, params) tuple.
|
|
75 """
|
|
76 if len(columns) < 1 or len(terms) < 1:
|
|
77 raise TracError('Empty search attempt, this should really not happen.')
|
|
78
|
|
79 likes = ['%s %s' % (i, db.like()) for i in columns]
|
|
80 c = ' OR '.join(likes)
|
|
81 sql = '(' + ') AND ('.join([c] * len(terms)) + ')'
|
|
82 args = []
|
|
83 for t in terms:
|
|
84 args.extend(['%'+db.like_escape(t)+'%'] * len(columns))
|
|
85 return sql, tuple(args)
|
|
86
|
|
87 def shorten_result(text='', keywords=[], maxlen=240, fuzz=60):
|
|
88 if not text: text = ''
|
|
89 text_low = text.lower()
|
|
90 beg = -1
|
|
91 for k in keywords:
|
|
92 i = text_low.find(k.lower())
|
|
93 if (i > -1 and i < beg) or beg == -1:
|
|
94 beg = i
|
|
95 excerpt_beg = 0
|
|
96 if beg > fuzz:
|
|
97 for sep in ('.', ':', ';', '='):
|
|
98 eb = text.find(sep, beg - fuzz, beg - 1)
|
|
99 if eb > -1:
|
|
100 eb += 1
|
|
101 break
|
|
102 else:
|
|
103 eb = beg - fuzz
|
|
104 excerpt_beg = eb
|
|
105 if excerpt_beg < 0: excerpt_beg = 0
|
|
106 msg = text[excerpt_beg:beg+maxlen]
|
|
107 if beg > fuzz:
|
|
108 msg = '... ' + msg
|
|
109 if beg < len(text)-maxlen:
|
|
110 msg = msg + ' ...'
|
|
111 return msg
|
|
112
|
|
113
|
|
114 class SearchModule(Component):
|
|
115
|
|
116 implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
|
|
117 IWikiSyntaxProvider)
|
|
118
|
|
119 search_sources = ExtensionPoint(ISearchSource)
|
|
120
|
|
121 RESULTS_PER_PAGE = 10
|
|
122
|
|
123 min_query_length = IntOption('search', 'min_query_length', 3,
|
|
124 """Minimum length of query string allowed when performing a search.""")
|
|
125
|
|
126 # INavigationContributor methods
|
|
127
|
|
128 def get_active_navigation_item(self, req):
|
|
129 return 'search'
|
|
130
|
|
131 def get_navigation_items(self, req):
|
|
132 if not req.perm.has_permission('SEARCH_VIEW'):
|
|
133 return
|
|
134 yield ('mainnav', 'search',
|
|
135 html.A('Search', href=req.href.search(), accesskey=4))
|
|
136
|
|
137 # IPermissionRequestor methods
|
|
138
|
|
139 def get_permission_actions(self):
|
|
140 return ['SEARCH_VIEW']
|
|
141
|
|
142 # IRequestHandler methods
|
|
143
|
|
144 def match_request(self, req):
|
|
145 return re.match(r'/search/?', req.path_info) is not None
|
|
146
|
|
147 def process_request(self, req):
|
|
148 req.perm.assert_permission('SEARCH_VIEW')
|
|
149
|
|
150 available_filters = []
|
|
151 for source in self.search_sources:
|
|
152 available_filters += source.get_search_filters(req)
|
|
153
|
|
154 filters = [f[0] for f in available_filters if req.args.has_key(f[0])]
|
|
155 if not filters:
|
|
156 filters = [f[0] for f in available_filters
|
|
157 if len(f) < 3 or len(f) > 2 and f[2]]
|
|
158
|
|
159 req.hdf['search.filters'] = [
|
|
160 { 'name': filter[0],
|
|
161 'label': filter[1],
|
|
162 'active': filter[0] in filters
|
|
163 } for filter in available_filters]
|
|
164
|
|
165 req.hdf['title'] = 'Search'
|
|
166
|
|
167 query = req.args.get('q')
|
|
168 if query:
|
|
169 page = int(req.args.get('page', '1'))
|
|
170 noquickjump = int(req.args.get('noquickjump', '0'))
|
|
171 link_elt = self.quickjump(req, query)
|
|
172 if link_elt is not None:
|
|
173 quickjump_href = link_elt.attr['href']
|
|
174 if noquickjump:
|
|
175 req.hdf['search.quickjump'] = {
|
|
176 'href': quickjump_href,
|
|
177 'name': html.EM(link_elt.children),
|
|
178 'description': link_elt.attr.get('title', '')
|
|
179 }
|
|
180 else:
|
|
181 req.redirect(quickjump_href)
|
|
182 elif query.startswith('!'):
|
|
183 query = query[1:]
|
|
184 terms = search_terms(query)
|
|
185 # Refuse queries that obviously would result in a huge result set
|
|
186 if len(terms) == 1 and len(terms[0]) < self.min_query_length:
|
|
187 raise TracError('Search query too short. '
|
|
188 'Query must be at least %d characters long.' % \
|
|
189 self.min_query_length, 'Search Error')
|
|
190 results = []
|
|
191 for source in self.search_sources:
|
|
192 results += list(source.get_search_results(req, terms, filters))
|
|
193 results.sort(lambda x,y: cmp(y[2], x[2]))
|
|
194 page_size = self.RESULTS_PER_PAGE
|
|
195 n = len(results)
|
|
196 n_pages = (n-1) / page_size + 1
|
|
197 results = results[(page-1) * page_size: page * page_size]
|
|
198
|
|
199 req.hdf['title'] = 'Search Results'
|
|
200 req.hdf['search.q'] = req.args.get('q')
|
|
201 req.hdf['search.page'] = page
|
|
202 req.hdf['search.n_hits'] = n
|
|
203 req.hdf['search.n_pages'] = n_pages
|
|
204 req.hdf['search.page_size'] = page_size
|
|
205 if page < n_pages:
|
|
206 next_href = req.href.search(zip(filters, ['on'] * len(filters)),
|
|
207 q=req.args.get('q'), page=page + 1)
|
|
208 add_link(req, 'next', next_href, 'Next Page')
|
|
209 if page > 1:
|
|
210 prev_href = req.href.search(zip(filters, ['on'] * len(filters)),
|
|
211 q=req.args.get('q'), page=page - 1)
|
|
212 add_link(req, 'prev', prev_href, 'Previous Page')
|
|
213 req.hdf['search.page_href'] = req.href.search(zip(filters, ['on'] * len(filters)),
|
|
214 q=req.args.get('q'))
|
|
215 req.hdf['search.result'] = [
|
|
216 { 'href': result[0],
|
|
217 'title': result[1],
|
|
218 'date': format_datetime(result[2]),
|
|
219 'author': result[3],
|
|
220 'excerpt': result[4]
|
|
221 } for result in results]
|
|
222
|
|
223 add_stylesheet(req, 'common/css/search.css')
|
|
224 return 'search.cs', None
|
|
225
|
|
226 def quickjump(self, req, kwd):
|
|
227 # Source quickjump
|
|
228 if kwd[0] == '/':
|
|
229 return req.href.browser(kwd)
|
|
230 link = wiki_to_link(kwd, self.env, req)
|
|
231 if isinstance(link, Element):
|
|
232 return link
|
|
233
|
|
234 # IWikiSyntaxProvider methods
|
|
235
|
|
236 def get_wiki_syntax(self):
|
|
237 return []
|
|
238
|
|
239 def get_link_resolvers(self):
|
|
240 yield ('search', self._format_link)
|
|
241
|
|
242 def _format_link(self, formatter, ns, target, label):
|
|
243 path, query, fragment = formatter.split_link(target)
|
|
244 if query:
|
|
245 href = formatter.href.search() + query.replace(' ', '+')
|
|
246 else:
|
|
247 href = formatter.href.search(q=path)
|
|
248 return html.A(label, class_='search', href=href)
|