Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py?rev=1437987&r1=1437986&r2=1437987&view=diff ============================================================================== --- incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py (original) +++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py Thu Jan 24 13:12:01 2013 @@ -19,15 +19,16 @@ # under the License. r"""Bloodhound Search user interface.""" +import copy import pkg_resources import re -from trac.core import * +from trac.core import Component, implements, TracError from genshi.builder import tag from trac.perm import IPermissionRequestor from trac.search import shorten_result -from trac.config import OrderedExtensionsOption +from trac.config import OrderedExtensionsOption, ListOption from trac.util.presentation import Paginator from trac.util.datefmt import format_datetime, user_time from trac.web import IRequestHandler @@ -50,48 +51,65 @@ class RequestParameters(object): """ QUERY = "q" PAGE = "page" - FILTER = "fl" TYPE = "type" NO_QUICK_JUMP = "noquickjump" PAGELEN = "pagelen" + FILTER_QUERY = "fq" def __init__(self, req): self.req = req - self.query = req.args.get(RequestParameters.QUERY) + self.query = req.args.getfirst(RequestParameters.QUERY) if self.query == None: self.query = "" #TODO: add quick jump functionality self.noquickjump = 1 - #TODO: add filters support - self.filters = [] + self.filter_queries = req.args.getlist(RequestParameters.FILTER_QUERY) + self.filter_queries = self._remove_possible_duplications( + self.filter_queries) #TODO: retrieve sort from query string self.sort = DEFAULT_SORT - self.pagelen = int(req.args.get( + self.pagelen = int(req.args.getfirst( RequestParameters.PAGELEN, DEFAULT_RESULTS_PER_PAGE)) - self.page = int(req.args.get(RequestParameters.PAGE, '1')) - self.type = req.args.get(RequestParameters.TYPE, None) + self.page = int(req.args.getfirst(RequestParameters.PAGE, '1')) + self.type = req.args.getfirst(RequestParameters.TYPE, None) self.params = { self.NO_QUICK_JUMP: self.noquickjump, + RequestParameters.FILTER_QUERY: [] } if self.query: self.params[self.QUERY] = self.query if self.pagelen != DEFAULT_RESULTS_PER_PAGE: - self.params[self.PAGELEN]=self.pagelen + self.params[self.PAGELEN] = self.pagelen if self.page > 1: - self.params[self.PAGE]=self.page + self.params[self.PAGE] = self.page if self.type: self.params[self.TYPE] = self.type + if self.filter_queries: + self.params[RequestParameters.FILTER_QUERY] = self.filter_queries - def create_href(self, page = None, type=None, skip_type = False, - skip_page = False): - params = dict(self.params) + def _remove_possible_duplications(self, parameters_list): + seen = set() + return [parameter for parameter in parameters_list + if parameter not in seen and not seen.add(parameter)] + + def create_href( + self, + page = None, + type=None, + skip_type = False, + skip_page = False, + filter_query = None, + skip_filter_query = False, + force_filters = None + ): + params = copy.deepcopy(self.params) if page: params[self.PAGE] = page @@ -102,11 +120,20 @@ class RequestParameters(object): params[self.TYPE] = type if skip_type and self.TYPE in params: - #show all does not require type parameter del(params[self.TYPE]) + if skip_filter_query: + params[self.FILTER_QUERY] = [] + elif filter_query and filter_query not in params[self.FILTER_QUERY]: + params[self.FILTER_QUERY].append(filter_query) + elif force_filters is not None: + params[self.FILTER_QUERY] = force_filters + return self.req.href.bhsearch(**params) + def is_show_all_mode(self): + return self.type is None + class BloodhoundSearchModule(Component): """Main search page""" @@ -123,6 +150,10 @@ class BloodhoundSearchModule(Component): ) + default_facets_all = ListOption('bhsearch', 'default_facets_all', + doc="""Default facets applied to search through all resources""") + + # INavigationContributor methods def get_active_navigation_item(self, req): return 'bhsearch' @@ -144,33 +175,41 @@ class BloodhoundSearchModule(Component): def process_request(self, req): req.perm.assert_permission(SEARCH_PERMISSION) parameters = RequestParameters(req) + query_string = parameters.query #TODO add quick jump support allowed_participants = self._get_allowed_participants(req) data = { - 'query': parameters.query, + 'query': query_string, } + self._prepare_allowed_types(allowed_participants, parameters, data) + self._prepare_active_filter_queries( + parameters, + data, + ) - #todo: filters check, tickets etc - if not any((parameters.query, )): - return self._return_data(req, data) + #TBD: should search return results on empty query? +# if not any(( +# query_string, +# parameters.type, +# parameters.filter_queries, +# )): +# return self._return_data(req, data) query_filter = self._prepare_query_filter( - parameters.type, - parameters.filters, + parameters, allowed_participants) - #todo: add proper facets functionality - facets = self._prepare_facets(req) + facets = self._prepare_facets(parameters, allowed_participants) - querySystem = BloodhoundSearchApi(self.env) - query_result = querySystem.query( - parameters.query, - pagenum = parameters.page, - pagelen = parameters.pagelen, - sort = parameters.sort, - facets = facets, + query_system = BloodhoundSearchApi(self.env) + query_result = query_system.query( + query_string, + pagenum=parameters.page, + pagelen=parameters.pagelen, + sort=parameters.sort, + facets=facets, filter=query_filter) ui_docs = [self._process_doc(doc, req, allowed_participants) @@ -199,66 +238,147 @@ class BloodhoundSearchModule(Component): prev_href = parameters.create_href(page = parameters.page - 1) add_link(req, 'prev', prev_href, _('Previous Page')) + data['results'] = results - self._prepare_type_grouping( - allowed_participants, + + self._prepare_result_facet_counts( parameters, - data) + query_result, + data, + ) - #TODO:add proper facet links - data['facets'] = query_result.facets data['page_href'] = parameters.create_href() return self._return_data(req, data) - def _prepare_query_filter(self, type, filters, allowed_participants): - query_filters = [] - - if type in allowed_participants: - query_filters.append((IndexFields.TYPE, type)) - else: - self.log.debug("Unsupported type in web request: %s", type) - - #TODO: handle other filters - return query_filters - - def _prepare_type_grouping(self, allowed_participants, parameters, data): + def _prepare_allowed_types(self, allowed_participants, parameters, data): active_type = parameters.type if active_type and active_type not in allowed_participants: raise TracError(_("Unsupported resource type: '%(name)s'", name=active_type)) - all_is_active = (active_type is None) - grouping = [ + allowed_types = [ dict( label=_("All"), - active=all_is_active, + active=(active_type is None), href=parameters.create_href( skip_type=True, - skip_page=not all_is_active) + skip_page=True, + force_filters=[], + ), ) ] - #we want to obtain the same order as specified in search_participants - # option + #we want obtain the same order as in search participants options participant_with_type = dict((participant, type) for type, participant in allowed_participants.iteritems()) for participant in self.search_participants: if participant in participant_with_type: type = participant_with_type[participant] - is_active = (type == active_type) - grouping.append(dict( + allowed_types.append(dict( label=_(participant.get_title()), - active=is_active, + active=(type ==active_type), href=parameters.create_href( type=type, - skip_page=not is_active - ) + skip_page=True, + force_filters=[], + ), )) - data["types"] = grouping - data["active_type"] = active_type + data["types"] = allowed_types + data["active_type"] = active_type + + - def _prepare_facets(self, req): - facets = [IndexFields.TYPE] - #TODO: add type specific default facets + def _prepare_active_filter_queries( + self, + parameters, + data): + active_filter_queries = [] + for filter_query in parameters.filter_queries: + active_filter_queries.append(dict( + href=parameters.create_href( + force_filters=self._cut_filters( + parameters.filter_queries, + filter_query)), + label=filter_query, + query=filter_query, + )) + data['active_filter_queries'] = active_filter_queries + + def _cut_filters(self, filter_queries, filer_to_cut_from): + return filter_queries[:filter_queries.index(filer_to_cut_from)] + + + def _prepare_result_facet_counts(self, parameters, query_result, data): + """ + + Sample query_result.facets content returned by query + { + 'component': {None:2}, + 'milestone': {None:1, 'm1':1}, + } + + returned facet_count contains href parameters: + { + 'component': {None: {'count':2, href:'...'}, + 'milestone': { + None: {'count':1,, href:'...'}, + 'm1':{'count':1, href:'...'} + }, + } + + """ + result_facets = query_result.facets + facet_counts = dict() + if result_facets: + for field, facets_dict in result_facets.iteritems(): + per_field_dict = dict() + for field_value, count in facets_dict.iteritems(): + if field==IndexFields.TYPE: + href = parameters.create_href( + skip_page=True, + skip_filter_query=True, + type=field_value) + else: + href = parameters.create_href( + skip_page=True, + filter_query=self._create_field_term_expression( + field, + field_value) + ) + per_field_dict[field_value] = dict( + count=count, + href=href + ) + facet_counts[_(field)] = per_field_dict + + data['facet_counts'] = facet_counts + + def _create_field_term_expression(self, field, field_value): + if field_value is None: + query = "NOT (%s:*)" % field + elif isinstance(field_value, basestring): + query = '%s:"%s"' % (field, field_value) + else: + query = '%s:%s' % (field, field_value) + return query + + def _prepare_query_filter(self, parameters, allowed_participants): + query_filters = list(parameters.filter_queries) + type = parameters.type + if type in allowed_participants: + query_filters.append( + self._create_field_term_expression(IndexFields.TYPE, type)) + else: + self.log.debug("Unsupported type in web request: %s", type) + return query_filters + + def _prepare_facets(self, parameters, allowed_participants): + #TODO: add possibility of specifying facets in query parameters + if parameters.is_show_all_mode(): + facets = [IndexFields.TYPE] + facets.extend(self.default_facets_all) + else: + type_participant = allowed_participants[parameters.type] + facets = type_participant.get_default_facets() return facets def _get_allowed_participants(self, req):
Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py?rev=1437987&r1=1437986&r2=1437987&view=diff ============================================================================== --- incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py (original) +++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py Thu Jan 24 13:12:01 2013 @@ -19,18 +19,20 @@ # under the License. r"""Whoosh specific backend for Bloodhound Search plugin.""" -from bhsearch.api import ISearchBackend, DESC, QueryResult, SCORE +from bhsearch.api import ISearchBackend, DESC, QueryResult, SCORE, \ + IDocIndexPreprocessor, IResultPostprocessor, IndexFields, \ + IQueryPreprocessor import os -from trac.core import * +from trac.core import Component, implements, TracError from trac.config import Option +from trac.util.text import empty from trac.util.datefmt import utc -from whoosh.fields import * +from whoosh.fields import Schema, ID, DATETIME, KEYWORD, TEXT from whoosh import index, sorting, query -from whoosh.searching import ResultsPage from whoosh.writing import AsyncWriter from datetime import datetime -UNIQUE_ID = 'unique_id' +UNIQUE_ID = "unique_id" class WhooshBackend(Component): """ @@ -43,19 +45,22 @@ class WhooshBackend(Component): directory of the environment.""") #This is schema prototype. It will be changed later - #TODO: add other fields support, add dynamic field support + #TODO: add other fields support, add dynamic field support. + #Schema must be driven by index participants SCHEMA = Schema( unique_id=ID(stored=True, unique=True), id=ID(stored=True), type=ID(stored=True), product=ID(stored=True), + milestone=ID(stored=True), time=DATETIME(stored=True), + due=DATETIME(stored=True), + completed=DATETIME(stored=True), author=ID(stored=True), - component=KEYWORD(stored=True), - status=KEYWORD(stored=True), - resolution=KEYWORD(stored=True), + component=ID(stored=True), + status=ID(stored=True), + resolution=ID(stored=True), keywords=KEYWORD(scorable=True), - milestone=TEXT(spelling=True), summary=TEXT(stored=True), content=TEXT(stored=True), changes=TEXT(), @@ -65,7 +70,7 @@ class WhooshBackend(Component): self.index_dir = self.index_dir_setting if not os.path.isabs(self.index_dir): self.index_dir = os.path.join(self.env.path, self.index_dir) - self.open_or_create_index_if_missing() + self.index = self._open_or_create_index_if_missing() #ISearchBackend methods def start_operation(self): @@ -80,16 +85,13 @@ class WhooshBackend(Component): The contents should be a dict with fields matching the search schema. The only required fields are type and id, everything else is optional. """ - # Really make sure it's unicode, because Whoosh won't have it any - # other way. is_local_writer = False if writer is None: is_local_writer = True writer = self._create_writer() - for key in doc: - doc[key] = self._to_whoosh_format(doc[key]) - doc["unique_id"] = self._create_unique_id(doc["type"], doc["id"]) + self._reformat_doc(doc) + doc[UNIQUE_ID] = self._create_unique_id(doc["type"], doc["id"]) self.log.debug("Doc to index: %s", doc) try: writer.update_document(**doc) @@ -98,9 +100,24 @@ class WhooshBackend(Component): except: if is_local_writer: writer.cancel() + raise + + def _reformat_doc(self, doc): + """ + Strings must be converted unicode format accepted by Whoosh. + """ + for key, value in doc.items(): + if key is None: + del doc[None] + elif value is None: + del doc[key] + elif isinstance(value, basestring) and value == "": + del doc[key] + else: + doc[key] = self._to_whoosh_format(value) - def delete_doc(self, type, id, writer=None): - unique_id = self._create_unique_id(type, id) + def delete_doc(self, doc_type, doc_id, writer=None): + unique_id = self._create_unique_id(doc_type, doc_id) self.log.debug('Removing document from the index: %s', unique_id) is_local_writer = False if writer is None: @@ -126,7 +143,7 @@ class WhooshBackend(Component): def cancel(self, writer): try: writer.cancel() - except Exception,ex: + except Exception, ex: self.env.log.error("Error during writer cancellation: %s", ex) def recreate_index(self): @@ -134,14 +151,21 @@ class WhooshBackend(Component): self._make_dir_if_not_exists() return index.create_in(self.index_dir, schema=self.SCHEMA) - def open_or_create_index_if_missing(self): + def _open_or_create_index_if_missing(self): if index.exists_in(self.index_dir): - self.index = index.open_dir(self.index_dir) + return index.open_dir(self.index_dir) else: - self.index = self.recreate_index() + return self.recreate_index() - def query(self, query, sort = None, fields = None, boost = None, filter = None, - facets = None, pagenum = 1, pagelen = 20): + def query(self, + query, + sort = None, + fields = None, + boost = None, + filter = None, + facets = None, + pagenum = 1, + pagelen = 20): """ Perform query. @@ -157,15 +181,18 @@ class WhooshBackend(Component): """ with self.index.searcher() as searcher: sortedby = self._prepare_sortedby(sort) + + #TODO: investigate how faceting is applied to multi-value fields + #e.g. keywords. For now, just pass facets lit to Whoosh API + #groupedby = self._prepare_groupedby(facets) groupedby = facets - query_filter = self._prepare_filter(filter) #workaround of Whoosh bug, read method __doc__ query = self._workaround_join_query_and_filter( query, - query_filter) + filter) - search_parameters = dict( + query_parameters = dict( query = query, pagenum = pagenum, pagelen = pagelen, @@ -173,12 +200,12 @@ class WhooshBackend(Component): groupedby = groupedby, maptype=sorting.Count, #workaround of Whoosh bug, read method __doc__ - #filter = query_filter, + #filter = filter, ) self.env.log.debug("Whoosh query to execute: %s", - search_parameters) - raw_page = searcher.search_page(**search_parameters) - results = self._process_results(raw_page, fields, search_parameters) + query_parameters) + raw_page = searcher.search_page(**query_parameters) + results = self._process_results(raw_page, fields, query_parameters) return results def _workaround_join_query_and_filter( @@ -189,19 +216,8 @@ class WhooshBackend(Component): return query_expression return query.And((query_expression, query_filter)) - def _prepare_filter(self, filters): - if not filters: - return None - and_filters = [] - for filter in filters: - and_filters.append(query.Term( - unicode(filter[0]), - unicode(filter[1]))) - return query.And(and_filters) - - - def _create_unique_id(self, type, id): - return u"%s:%s" % (type, id) + def _create_unique_id(self, doc_type, doc_id): + return u"%s:%s" % (doc_type, doc_id) def _to_whoosh_format(self, value): if isinstance(value, basestring): @@ -212,9 +228,9 @@ class WhooshBackend(Component): def _convert_date_to_tz_naive_utc(self, value): """Convert datetime to naive utc datetime - Whoosh can not read from index datetime value with + Whoosh can not read from index datetime values passed from Trac with tzinfo=trac.util.datefmt.FixedOffset because of non-empty - constructor""" + constructor of FixedOffset""" if value.tzinfo: utc_time = value.astimezone(utc) value = utc_time.replace(tzinfo=None) @@ -225,13 +241,16 @@ class WhooshBackend(Component): value = utc.localize(value) return value -# def _prepare_groupedby(self, facets): -# if not facets: -# return None -# groupedby = sorting.Facets() -# for facet_name in facets: -# groupedby.add_field(facet_name, allow_overlap=True, maptype=sorting.Count) -# return groupedby + def _prepare_groupedby(self, facets): + if not facets: + return None + groupedby = sorting.Facets() + for facet_name in facets: + groupedby.add_field( + facet_name, + allow_overlap=True, + maptype=sorting.Count) + return groupedby def _prepare_sortedby(self, sort): if not sort: @@ -240,12 +259,15 @@ class WhooshBackend(Component): for (field, order) in sort: if field.lower() == SCORE: if self._is_desc(order): - #We can implement later our own ScoreFacet with + #We can implement tis later by our own ScoreFacet with # "score DESC" support - raise TracError("Whoosh does not support DESC score ordering.") + raise TracError( + "Whoosh does not support DESC score ordering.") sort_condition = sorting.ScoreFacet() else: - sort_condition = sorting.FieldFacet(field, reverse=self._is_desc(order)) + sort_condition = sorting.FieldFacet( + field, + reverse=self._is_desc(order)) sortedby.append(sort_condition) return sortedby @@ -267,7 +289,7 @@ class WhooshBackend(Component): results.facets = self._load_facets(page) docs = [] - for doc_offset, retrieved_record in enumerate(page): + for retrieved_record in page: result_doc = self._process_record(fields, retrieved_record) docs.append(result_doc) results.docs = docs @@ -313,3 +335,73 @@ class WhooshBackend(Component): current user." % self.index_dir) + +class WhooshEmptyFacetErrorWorkaround(Component): + """ + Whoosh 2.4.1 raises "IndexError: list index out of range" + when search contains facets on field that is missing in at least one + document in the index. The error manifests only when index contains + more than one segment. + + The goal of this class is to temporary solve the problem for + prototype phase. Fro non-prototype phase, the problem should be solved + by the next version of Whoosh. + + Remove this class when fixed version of Whoosh is introduced. + """ + implements(IDocIndexPreprocessor) + implements(IResultPostprocessor) + implements(IQueryPreprocessor) + + NULL_MARKER = u"empty" + + should_not_be_empty_fields = [ + IndexFields.STATUS, + IndexFields.MILESTONE, + IndexFields.COMPONENT, + ] + + #IDocIndexPreprocessor methods + def pre_process(self, doc): + for field in self.should_not_be_empty_fields: + if field not in doc or doc[field] is None or doc[field] == empty: + doc[field] = self.NULL_MARKER + + #IResultPostprocessor methods + def post_process(self, query_result): + #fix facets + if query_result.facets: + for count_dict in query_result.facets.values(): + for field, count in count_dict.iteritems(): + if field == self.NULL_MARKER: + count_dict[None] = count + del count_dict[self.NULL_MARKER] + #we can fix query_result.docs later if needed + + #IQueryPreprocessor methods + def query_pre_process(self, query_parameters): + """ + Go through filter queries and replace "NOT (field_name:*)" query with + "field_name:NULL_MARKER" query. + + This is really quick fix to make prototype working with hope that + the next Whoosh version will be released soon. + """ + if "filter" in query_parameters and query_parameters["filter"]: + self._find_and_fix_condition(query_parameters["filter"]) + if "query" in query_parameters and query_parameters["query"]: + self._find_and_fix_condition(query_parameters["query"]) + + def _find_and_fix_condition(self, filter_condition): + if isinstance(filter_condition, query.CompoundQuery): + subqueries = list(filter_condition.subqueries) + for i, subquery in enumerate(subqueries): + term_to_replace = self._find_and_fix_condition(subquery) + if term_to_replace: + filter_condition.subqueries[i] = term_to_replace + elif isinstance(filter_condition, query.Not): + not_query = filter_condition.query + if isinstance(not_query, query.Every) and \ + not_query.fieldname in self.should_not_be_empty_fields: + return query.Term(not_query.fieldname, self.NULL_MARKER) + return None Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/wiki_search.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/wiki_search.py?rev=1437987&r1=1437986&r2=1437987&view=diff ============================================================================== --- incubator/bloodhound/trunk/bloodhound_search/bhsearch/wiki_search.py (original) +++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/wiki_search.py Thu Jan 24 13:12:01 2013 @@ -21,16 +21,15 @@ r"""Wiki specifics for Bloodhound Search plugin.""" from bhsearch.api import ISearchParticipant, BloodhoundSearchApi, \ IIndexParticipant, IndexFields -from trac.core import * -from trac.config import Option +from bhsearch.base import BaseIndexer +from trac.core import implements, Component +from trac.config import ListOption from trac.wiki import IWikiChangeListener, WikiSystem, WikiPage -WIKI_TYPE = "wiki" +WIKI_TYPE = u"wiki" -class WikiIndexer(Component): +class WikiIndexer(BaseIndexer): implements(IWikiChangeListener, IIndexParticipant) - silence_on_error = Option('bhsearch', 'silence_on_error', "True", - """If true, do not throw an exception during indexing a resource""") #IWikiChangeListener methods def wiki_page_added(self, page): @@ -64,21 +63,26 @@ class WikiIndexer(Component): search_api = BloodhoundSearchApi(self.env) search_api.change_doc_id(doc, old_name) except Exception, e: - if self.silence_on_error.lower() == "true": - self.log.error("Error occurs during wiki indexing. \ - The error will not be propagated. Exception: %s", e) + if self.silence_on_error: + self.log.error("Error occurs during renaming wiki from %s \ + to %s. The error will not be propagated. Exception: %s", + old_name, page.name, e) else: raise - def _index_wiki(self, page, raise_exception = False): + def _index_wiki(self, page): try: doc = self.build_doc(page) search_api = BloodhoundSearchApi(self.env) search_api.add_doc(doc) except Exception, e: - if (not raise_exception) and self.silence_on_error.lower() == "true": - self.log.error("Error occurs during wiki indexing. \ - The error will not be propagated. Exception: %s", e) + page_name = None + if page is not None: + page_name = page.name + if self.silence_on_error: + self.log.error("Error occurs during wiki indexing: %s. \ + The error will not be propagated. Exception: %s", + page_name, e) else: raise @@ -97,7 +101,6 @@ class WikiIndexer(Component): return doc def get_entries_for_index(self): - #is there better way to get all tickets? page_names = WikiSystem(self.env).get_pages() for page_name in page_names: page = WikiPage(self.env, page_name) @@ -106,6 +109,9 @@ class WikiIndexer(Component): class WikiSearchParticipant(Component): implements(ISearchParticipant) + default_facets = ListOption('bhsearch', 'default_facets_wiki', + doc="""Default facets applied to search through wiki pages""") + #ISearchParticipant members def get_search_filters(self, req=None): if not req or 'WIKI_VIEW' in req.perm: @@ -114,6 +120,11 @@ class WikiSearchParticipant(Component): def get_title(self): return "Wiki" + def get_default_facets(self): + return self.default_facets + def format_search_results(self, res): return u'%s: %s...' % (res['id'], res['content'][:50]) + + Modified: incubator/bloodhound/trunk/bloodhound_search/setup.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/setup.py?rev=1437987&r1=1437986&r2=1437987&view=diff ============================================================================== --- incubator/bloodhound/trunk/bloodhound_search/setup.py (original) +++ incubator/bloodhound/trunk/bloodhound_search/setup.py Thu Jan 24 13:12:01 2013 @@ -125,6 +125,7 @@ ENTRY_POINTS = { 'bhsearch.admin = bhsearch.admin', 'bhsearch.ticket_search = bhsearch.ticket_search', 'bhsearch.wiki_search = bhsearch.wiki_search', + 'bhsearch.milestone_search = bhsearch.milestone_search', 'bhsearch.query_parser = bhsearch.query_parser', 'bhsearch.whoosh_backend = bhsearch.whoosh_backend', ],
