2013-07-30 23:02:10 +02:00
|
|
|
var search_suggestion = (function () {
|
|
|
|
|
|
|
|
var exports = {};
|
|
|
|
|
|
|
|
function phrase_match(phrase, q) {
|
|
|
|
// match "tes" to "test" and "stream test" but not "hostess"
|
|
|
|
var i;
|
|
|
|
q = q.toLowerCase();
|
|
|
|
|
2013-08-07 15:40:47 +02:00
|
|
|
phrase = phrase.toLowerCase();
|
|
|
|
if (phrase.indexOf(q) === 0) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
var parts = phrase.split(' ');
|
|
|
|
for (i = 0; i < parts.length; i++) {
|
2013-08-07 15:40:47 +02:00
|
|
|
if (parts[i].indexOf(q) === 0) {
|
2013-07-30 23:02:10 +02:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
function person_matches_query(person, q) {
|
|
|
|
return phrase_match(person.full_name, q) || phrase_match(person.email, q);
|
|
|
|
}
|
|
|
|
|
|
|
|
function stream_matches_query(stream_name, q) {
|
|
|
|
return phrase_match(stream_name, q);
|
|
|
|
}
|
|
|
|
|
2013-10-07 20:48:23 +02:00
|
|
|
function operator_to_prefix(operator) {
|
|
|
|
switch (operator) {
|
|
|
|
case 'stream':
|
|
|
|
return 'Narrow to stream';
|
|
|
|
|
|
|
|
case 'near':
|
|
|
|
return 'Narrow to messages around';
|
|
|
|
|
|
|
|
case 'id':
|
|
|
|
return 'Narrow to message ID';
|
|
|
|
|
|
|
|
case 'topic':
|
|
|
|
return 'Narrow to topic';
|
|
|
|
|
|
|
|
case 'sender':
|
|
|
|
return 'Narrow to messages sent by';
|
|
|
|
|
|
|
|
case 'pm-with':
|
|
|
|
return 'Narrow to private messages with';
|
|
|
|
|
|
|
|
case 'search':
|
|
|
|
return 'Search for';
|
|
|
|
|
|
|
|
case 'in':
|
|
|
|
return 'Narrow to messages in';
|
|
|
|
}
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
// Convert a list of operators to a human-readable description.
|
|
|
|
function describe(operators) {
|
|
|
|
if (operators.length === 0) {
|
|
|
|
return 'Go to Home view';
|
|
|
|
}
|
|
|
|
|
|
|
|
var parts = [];
|
|
|
|
|
|
|
|
if (operators.length >= 2) {
|
|
|
|
if (operators[0][0] === 'stream' && operators[1][0] === 'topic') {
|
|
|
|
var stream = operators[0][1];
|
|
|
|
var topic = operators[1][1];
|
|
|
|
var part = 'Narrow to ' + stream + ' > ' + topic;
|
|
|
|
parts = [part];
|
|
|
|
operators = operators.slice(2);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var more_parts = _.map(operators, function (elem) {
|
|
|
|
var operand = elem[1];
|
2013-10-07 20:48:23 +02:00
|
|
|
var canonicalized_operator = Filter.canonicalize_operator(elem[0]);
|
|
|
|
if (canonicalized_operator ==='is') {
|
2013-07-30 23:02:10 +02:00
|
|
|
if (operand === 'private') {
|
|
|
|
return 'Narrow to all private messages';
|
|
|
|
} else if (operand === 'starred') {
|
|
|
|
return 'Narrow to starred messages';
|
|
|
|
} else if (operand === 'mentioned') {
|
|
|
|
return 'Narrow to mentioned messages';
|
2013-08-30 21:15:01 +02:00
|
|
|
} else if (operand === 'alerted') {
|
|
|
|
return 'Narrow to alerted messages';
|
2013-07-30 23:02:10 +02:00
|
|
|
}
|
2013-10-07 20:48:23 +02:00
|
|
|
} else {
|
|
|
|
var prefix_for_operator = operator_to_prefix(canonicalized_operator);
|
|
|
|
if (prefix_for_operator !== '') {
|
|
|
|
return prefix_for_operator + ' ' + operand;
|
|
|
|
}
|
2013-07-30 23:02:10 +02:00
|
|
|
}
|
|
|
|
return 'Narrow to (unknown operator)';
|
|
|
|
});
|
|
|
|
return parts.concat(more_parts).join(', ');
|
|
|
|
}
|
|
|
|
|
|
|
|
function highlight_person(query, person) {
|
|
|
|
var hilite = typeahead_helper.highlight_query_in_phrase;
|
|
|
|
return hilite(query, person.full_name) + " <" + hilite(query, person.email) + ">";
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_stream_suggestions(operators) {
|
|
|
|
var query;
|
|
|
|
|
|
|
|
switch (operators.length) {
|
|
|
|
case 0:
|
|
|
|
query = '';
|
|
|
|
break;
|
|
|
|
case 1:
|
|
|
|
var operand = operators[0][0];
|
|
|
|
query = operators[0][1];
|
|
|
|
if (!(operand === 'stream' || operand === 'search')) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
2013-08-15 21:11:07 +02:00
|
|
|
var streams = stream_data.subscribed_streams();
|
2013-07-30 23:02:10 +02:00
|
|
|
|
|
|
|
streams = _.filter(streams, function (stream) {
|
|
|
|
return stream_matches_query(stream, query);
|
|
|
|
});
|
|
|
|
|
|
|
|
streams = typeahead_helper.sorter(query, streams);
|
|
|
|
|
|
|
|
var objs = _.map(streams, function (stream) {
|
|
|
|
var prefix = 'Narrow to stream';
|
|
|
|
var highlighted_stream = typeahead_helper.highlight_query_in_phrase(query, stream);
|
|
|
|
var description = prefix + ' ' + highlighted_stream;
|
2013-08-22 01:29:28 +02:00
|
|
|
var search_string = Filter.unparse([['stream', stream]]);
|
2013-07-30 23:02:10 +02:00
|
|
|
return {description: description, search_string: search_string};
|
|
|
|
});
|
|
|
|
|
|
|
|
return objs;
|
|
|
|
}
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
function get_private_suggestions(all_people, operators, person_operator_matches) {
|
2013-07-30 23:02:10 +02:00
|
|
|
if (operators.length === 0) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
var ok = false;
|
|
|
|
if ((operators[0][0] === 'is') && (operators[0][1] === 'private')) {
|
|
|
|
operators = operators.slice(1);
|
|
|
|
ok = true;
|
2013-10-07 20:48:25 +02:00
|
|
|
} else {
|
|
|
|
_.each(person_operator_matches, function (item) {
|
|
|
|
if (operators[0][0] === item) {
|
|
|
|
ok = true;
|
|
|
|
}
|
|
|
|
});
|
2013-07-30 23:02:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!ok) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
var query, matching_operator;
|
2013-07-30 23:02:10 +02:00
|
|
|
|
|
|
|
if (operators.length === 0) {
|
|
|
|
query = '';
|
2013-10-07 20:48:25 +02:00
|
|
|
matching_operator = person_operator_matches[0];
|
2013-07-30 23:02:10 +02:00
|
|
|
} else if (operators.length === 1) {
|
|
|
|
var operator = operators[0][0];
|
2013-10-07 20:48:25 +02:00
|
|
|
|
|
|
|
if (operator === 'search') {
|
2013-07-30 23:02:10 +02:00
|
|
|
query = operators[0][1];
|
2013-10-07 20:48:25 +02:00
|
|
|
} else {
|
|
|
|
_.each(person_operator_matches, function (item) {
|
|
|
|
if (operator === item) {
|
|
|
|
query = operators[0][1];
|
|
|
|
matching_operator = item;
|
|
|
|
}
|
|
|
|
});
|
2013-07-30 23:02:10 +02:00
|
|
|
}
|
2013-10-07 20:48:25 +02:00
|
|
|
|
|
|
|
if (query === undefined) {
|
2013-07-30 23:02:10 +02:00
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var people = _.filter(all_people, function (person) {
|
|
|
|
return (query === '') || person_matches_query(person, query);
|
|
|
|
});
|
|
|
|
|
|
|
|
people.sort(typeahead_helper.compare_by_pms);
|
|
|
|
|
|
|
|
// Take top 15 people, since they're ordered by pm_recipient_count.
|
|
|
|
people = people.slice(0, 15);
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
var prefix = operator_to_prefix(matching_operator);
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
var suggestions = _.map(people, function (person) {
|
|
|
|
var name = highlight_person(query, person);
|
2013-10-07 20:48:25 +02:00
|
|
|
var description = prefix + ' ' + name;
|
|
|
|
var search_string = Filter.unparse([[matching_operator, person.email]]);
|
2013-07-30 23:02:10 +02:00
|
|
|
return {description: description, search_string: search_string};
|
|
|
|
});
|
|
|
|
|
|
|
|
suggestions.push({
|
|
|
|
search_string: 'is:private',
|
|
|
|
description: 'Private messages'
|
|
|
|
});
|
|
|
|
|
|
|
|
return suggestions;
|
|
|
|
}
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
function get_person_suggestions(all_people, query, autocomplete_operator) {
|
2013-07-30 23:02:10 +02:00
|
|
|
if (query === '') {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
var people = _.filter(all_people, function (person) {
|
|
|
|
return person_matches_query(person, query);
|
|
|
|
});
|
|
|
|
|
|
|
|
people.sort(typeahead_helper.compare_by_pms);
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
var prefix = operator_to_prefix(autocomplete_operator);
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
var objs = _.map(people, function (person) {
|
|
|
|
var name = highlight_person(query, person);
|
|
|
|
var description = prefix + ' ' + name;
|
2013-10-07 20:48:25 +02:00
|
|
|
var search_string = autocomplete_operator + ':' + person.email;
|
2013-07-30 23:02:10 +02:00
|
|
|
return {description: description, search_string: search_string};
|
|
|
|
});
|
|
|
|
|
|
|
|
return objs;
|
|
|
|
}
|
|
|
|
|
2013-08-06 19:08:39 +02:00
|
|
|
function get_default_suggestion(operators) {
|
|
|
|
// Here we return the canonical suggestion for the full query that the
|
|
|
|
// user typed. (The caller passes us the parsed query as "operators".)
|
2013-08-22 01:29:28 +02:00
|
|
|
var search_string = Filter.unparse(operators);
|
2013-07-30 23:02:10 +02:00
|
|
|
var description = describe(operators);
|
|
|
|
description = Handlebars.Utils.escapeExpression(description);
|
|
|
|
return {description: description, search_string: search_string};
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_topic_suggestions(query_operators) {
|
|
|
|
if (query_operators.length === 0) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
var last_term = query_operators.slice(-1)[0];
|
2013-08-10 01:31:31 +02:00
|
|
|
var operator = Filter.canonicalize_operator(last_term[0]);
|
2013-07-30 23:02:10 +02:00
|
|
|
var operand = last_term[1];
|
|
|
|
var stream;
|
|
|
|
var guess;
|
|
|
|
var filter;
|
|
|
|
|
|
|
|
// stream:Rome -> show all Rome topics
|
|
|
|
// stream:Rome topic: -> show all Rome topics
|
|
|
|
// stream:Rome f -> show all Rome topics with a word starting in f
|
|
|
|
// stream:Rome topic:f -> show all Rome topics with a word starting in f
|
|
|
|
// stream:Rome topic:f -> show all Rome topics with a word starting in f
|
|
|
|
|
|
|
|
// When narrowed to a stream:
|
|
|
|
// topic: -> show all topics in current stream
|
|
|
|
// foo -> show all topics in current stream with words starting with foo
|
|
|
|
|
|
|
|
// If somebody explicitly types search:, then we might
|
|
|
|
// not want to suggest topics, but I feel this is a very
|
2013-08-22 01:29:28 +02:00
|
|
|
// minor issue, and Filter.parse() is currently lossy
|
2013-07-30 23:02:10 +02:00
|
|
|
// in terms of telling us whether they provided the operator,
|
|
|
|
// i.e. "foo" and "search:foo" both become [['search', 'foo']].
|
|
|
|
switch (operator) {
|
|
|
|
case 'stream':
|
2013-08-10 01:31:31 +02:00
|
|
|
filter = new Filter(query_operators);
|
2013-07-30 23:02:10 +02:00
|
|
|
if (filter.has_operator('topic')) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
guess = '';
|
|
|
|
stream = operand;
|
|
|
|
break;
|
|
|
|
case 'topic':
|
|
|
|
case 'search':
|
|
|
|
guess = operand;
|
|
|
|
query_operators = query_operators.slice(0, -1);
|
2013-08-10 01:31:31 +02:00
|
|
|
filter = new Filter(query_operators);
|
2013-07-30 23:02:10 +02:00
|
|
|
if (filter.has_operator('topic')) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
if (filter.has_operator('stream')) {
|
|
|
|
stream = filter.operands('stream')[0];
|
|
|
|
} else {
|
|
|
|
stream = narrow.stream();
|
|
|
|
query_operators.push(['stream', stream]);
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!stream) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
2013-08-07 23:56:51 +02:00
|
|
|
var topics = recent_subjects.get(stream);
|
2013-07-30 23:02:10 +02:00
|
|
|
|
2013-08-20 16:47:27 +02:00
|
|
|
stream = stream_data.get_name(stream);
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
if (!topics) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Be defensive here in case recent_subjects gets super huge, but
|
|
|
|
// still slice off enough topics to find matches.
|
2013-08-08 15:36:57 +02:00
|
|
|
topics = topics.slice(0, 300);
|
2013-07-30 23:02:10 +02:00
|
|
|
|
|
|
|
topics = _.map(topics, function (topic) {
|
|
|
|
return topic.subject; // "subject" is just the name of the topic
|
|
|
|
});
|
|
|
|
|
|
|
|
if (guess !== '') {
|
|
|
|
topics = _.filter(topics, function (topic) {
|
|
|
|
return phrase_match(topic, guess);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2013-11-25 20:26:46 +01:00
|
|
|
topics = topics.slice(0, 10);
|
|
|
|
|
2013-07-30 23:02:10 +02:00
|
|
|
// Just use alphabetical order. While recency and read/unreadness of
|
|
|
|
// subjects do matter in some contexts, you can get that from the left sidebar,
|
|
|
|
// and I'm leaning toward high scannability for autocompletion. I also don't
|
|
|
|
// care about case.
|
|
|
|
topics.sort();
|
|
|
|
|
|
|
|
return _.map(topics, function (topic) {
|
|
|
|
var topic_operator = ['topic', topic];
|
|
|
|
var operators = query_operators.concat([topic_operator]);
|
2013-08-22 01:29:28 +02:00
|
|
|
var search_string = Filter.unparse(operators);
|
2013-07-30 23:02:10 +02:00
|
|
|
var description = describe(operators);
|
|
|
|
return {description: description, search_string: search_string};
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_operator_subset_suggestions(query, operators) {
|
|
|
|
// For stream:a topic:b search:c, suggest:
|
|
|
|
// stream:a topic:b
|
|
|
|
// stream:a
|
|
|
|
if (operators.length < 1) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
var i;
|
|
|
|
var suggestions = [];
|
|
|
|
|
2013-08-05 18:35:46 +02:00
|
|
|
for (i = operators.length - 1; i >= 1; --i) {
|
2013-07-30 23:02:10 +02:00
|
|
|
var subset = operators.slice(0, i);
|
2013-08-22 01:29:28 +02:00
|
|
|
var search_string = Filter.unparse(subset);
|
2013-07-30 23:02:10 +02:00
|
|
|
var description = describe(subset);
|
|
|
|
var suggestion = {description: description, search_string: search_string};
|
|
|
|
suggestions.push(suggestion);
|
|
|
|
}
|
|
|
|
|
|
|
|
return suggestions;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function get_special_filter_suggestions(query, operators) {
|
|
|
|
if (operators.length >= 2) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
var suggestions = [
|
|
|
|
{
|
|
|
|
search_string: '',
|
|
|
|
description: 'Home'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
search_string: 'in:all',
|
|
|
|
description: 'All messages'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
search_string: 'is:private',
|
|
|
|
description: 'Private messages'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
search_string: 'is:starred',
|
|
|
|
description: 'Starred messages'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
search_string: 'is:mentioned',
|
|
|
|
description: '@-mentions'
|
|
|
|
},
|
2013-08-30 21:15:01 +02:00
|
|
|
{
|
|
|
|
search_string: 'is:alerted',
|
|
|
|
description: 'Alerted messages'
|
|
|
|
},
|
2013-07-30 23:02:10 +02:00
|
|
|
{
|
|
|
|
search_string: 'sender:' + page_params.email,
|
|
|
|
description: 'Sent by me'
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
|
|
|
query = query.toLowerCase();
|
|
|
|
|
|
|
|
suggestions = _.filter(suggestions, function (s) {
|
|
|
|
if (s.search_string.toLowerCase() === query) {
|
|
|
|
return false; // redundant
|
|
|
|
}
|
|
|
|
if (query === '') {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return (s.search_string.toLowerCase().indexOf(query) === 0) ||
|
|
|
|
(s.description.toLowerCase().indexOf(query) === 0);
|
|
|
|
});
|
|
|
|
|
|
|
|
return suggestions;
|
|
|
|
}
|
|
|
|
|
|
|
|
exports.get_suggestions = function (query) {
|
|
|
|
// This method works in tandem with the typeahead library to generate
|
|
|
|
// search suggestions. If you want to change its behavior, be sure to update
|
|
|
|
// the tests. Its API is partly shaped by the typeahead library, which wants
|
|
|
|
// us to give it strings only, but we also need to return our caller a hash
|
|
|
|
// with information for subsequent callbacks.
|
|
|
|
var result = [];
|
|
|
|
var suggestion;
|
|
|
|
var suggestions;
|
|
|
|
|
|
|
|
// Add an entry for narrow by operators.
|
2013-08-22 01:29:28 +02:00
|
|
|
var operators = Filter.parse(query);
|
2013-08-06 19:08:39 +02:00
|
|
|
suggestion = get_default_suggestion(operators);
|
2013-07-30 23:02:10 +02:00
|
|
|
result = [suggestion];
|
|
|
|
|
|
|
|
suggestions = get_special_filter_suggestions(query, operators);
|
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
|
|
|
suggestions = get_stream_suggestions(operators);
|
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
|
|
|
var people = page_params.people_list;
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
suggestions = get_person_suggestions(people, query, 'pm-with');
|
2013-07-30 23:02:10 +02:00
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
suggestions = get_person_suggestions(people, query, 'sender');
|
2013-07-30 23:02:10 +02:00
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
2013-10-07 20:48:25 +02:00
|
|
|
suggestions = get_private_suggestions(people, operators, ['pm-with', 'sender']);
|
2013-07-30 23:02:10 +02:00
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
|
|
|
suggestions = get_topic_suggestions(operators);
|
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
|
|
|
suggestions = get_operator_subset_suggestions(query, operators);
|
|
|
|
result = result.concat(suggestions);
|
|
|
|
|
|
|
|
// Typeahead expects us to give it strings, not objects, so we maintain our own hash
|
|
|
|
// back to our objects, and we also filter duplicates here.
|
|
|
|
var lookup_table = {};
|
|
|
|
var unique_suggestions = [];
|
|
|
|
_.each(result, function (obj) {
|
|
|
|
if (!lookup_table[obj.search_string]) {
|
|
|
|
lookup_table[obj.search_string] = obj;
|
|
|
|
unique_suggestions.push(obj);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
var strings = _.map(unique_suggestions, function (obj) {
|
|
|
|
return obj.search_string;
|
|
|
|
});
|
|
|
|
return {
|
|
|
|
strings: strings,
|
|
|
|
lookup_table: lookup_table
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return exports;
|
|
|
|
}());
|
|
|
|
if (typeof module !== 'undefined') {
|
|
|
|
module.exports = search_suggestion;
|
|
|
|
}
|