zulip/static/js/search_suggestion.js

680 lines
22 KiB
JavaScript

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();
phrase = phrase.toLowerCase();
if (phrase.indexOf(q) === 0) {
return true;
}
var parts = phrase.split(' ');
for (i = 0; i < parts.length; i += 1) {
if (parts[i].indexOf(q) === 0) {
return true;
}
}
return false;
}
function stream_matches_query(stream_name, q) {
return phrase_match(stream_name, q);
}
function highlight_person(query, person) {
var hilite = typeahead_helper.highlight_query_in_phrase;
return hilite(query, person.full_name) + " &lt;" + hilite(query, person.email) + "&gt;";
}
function match_criteria(operators, criteria) {
var filter = new Filter(operators);
return _.any(criteria, function (cr) {
if (_.has(cr, 'operand')) {
return filter.has_operand(cr.operator, cr.operand);
}
return filter.has_operator(cr.operator);
});
}
function check_validity(last, operators, valid, invalid) {
// valid: list of strings valid for the last operator
// invalid: list of operators invalid for any previous operators except last.
if (valid.indexOf(last.operator) === -1) {
return false;
}
if (match_criteria(operators, invalid)) {
return false;
}
return true;
}
function format_as_suggestion(terms) {
return {
description: Filter.describe(terms),
search_string: Filter.unparse(terms),
};
}
function compare_by_huddle(huddle) {
huddle = _.map(huddle.slice(0, -1), function (person) {
person = people.get_by_email(person);
if (person) {
return person.user_id;
}
});
// Construct dict for all huddles, so we can lookup each's recency
var huddles = activity.get_huddles();
var huddle_dict = {};
for (var i = 0; i < huddles.length; i += 1) {
huddle_dict[huddles[i]] = i + 1;
}
return function (person1, person2) {
var huddle1 = huddle.concat(person1.user_id).sort().join(',');
var huddle2 = huddle.concat(person2.user_id).sort().join(',');
// If not in the dict, assign an arbitrarily high index
var score1 = huddle_dict[huddle1] || 100;
var score2 = huddle_dict[huddle2] || 100;
var diff = score1 - score2;
if (diff !== 0) {
return diff;
}
return typeahead_helper.compare_by_pms(person1, person2);
};
}
function get_stream_suggestions(last, operators) {
var valid = ['stream', 'search', ''];
var invalid = [
{operator: 'stream'},
{operator: 'is', operand: 'private'},
{operator: 'pm-with'},
];
if (!check_validity(last, operators, valid, invalid)) {
return [];
}
var query = last.operand;
var streams = stream_data.subscribed_streams();
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 = 'stream';
var highlighted_stream = typeahead_helper.highlight_with_escaping(query, stream);
var verb = last.negated ? 'exclude ' : '';
var description = verb + prefix + ' ' + highlighted_stream;
var term = {
operator: 'stream',
operand: stream,
negated: last.negated,
};
var search_string = Filter.unparse([term]);
return {description: description, search_string: search_string};
});
return objs;
}
function get_group_suggestions(all_persons, last, operators) {
if (!check_validity(last, operators, ['pm-with'], [{operator: 'stream'}])) {
return [];
}
var operand = last.operand;
var negated = last.negated;
// The operand has the form "part1,part2,pa", where all but the last part
// are emails, and the last part is an arbitrary query.
//
// We only generate group suggestions when there's more than one part, and
// we only use the last part to generate suggestions.
var all_but_last_part;
var last_part;
var last_comma_index = operand.lastIndexOf(',');
if (last_comma_index < 0) {
return [];
}
// Neither all_but_last_part nor last_part include the final comma.
all_but_last_part = operand.slice(0, last_comma_index);
last_part = operand.slice(last_comma_index + 1);
// We don't suggest a person if their email is already present in the
// operand (not including the last part).
var parts = all_but_last_part.split(',').concat(people.my_current_email());
var persons = _.filter(all_persons, function (person) {
if (_.contains(parts, person.email)) {
return false;
}
return last_part === '' || people.person_matches_query(person, last_part);
});
persons.sort(compare_by_huddle(parts));
// Take top 15 persons, since they're ordered by pm_recipient_count.
persons = persons.slice(0, 15);
var prefix = Filter.operator_to_prefix('pm-with', negated);
var suggestions = _.map(persons, function (person) {
var term = {
operator: 'pm-with',
operand: all_but_last_part + ',' + person.email,
negated: negated,
};
var name = highlight_person(last_part, person);
var description = prefix + ' ' + Handlebars.Utils.escapeExpression(all_but_last_part) + ',' + name;
var terms = [term];
if (negated) {
terms = [{operator: 'is', operand: 'private'}, term];
}
var search_string = Filter.unparse(terms);
return {description: description, search_string: search_string};
});
return suggestions;
}
// Possible args for autocomplete_operator: pm-with, sender, from
function get_person_suggestions(all_persons, last, operators, autocomplete_operator) {
if (last.operator === "is" && last.operand === "private") {
// Interpret 'is:private' as equivalent to 'pm-with:'
last = {operator: "pm-with", operand: "", negated: false};
}
var query = last.operand;
// Be especially strict about the less common "from" operator.
if (autocomplete_operator === 'from' && last.operator !== 'from') {
return [];
}
var valid = ['search', autocomplete_operator];
var invalid;
if (autocomplete_operator === 'pm-with') {
invalid = [{operator: 'pm-with'}, {operator: 'stream'}];
} else {
// If not pm-with, then this must either be 'sender' or 'from'
invalid = [{operator: 'sender'}, {operator: 'from'}];
}
if (!check_validity(last, operators, valid, invalid)) {
return [];
}
var persons = _.filter(all_persons, function (person) {
return people.person_matches_query(person, query);
});
persons.sort(typeahead_helper.compare_by_pms);
var prefix = Filter.operator_to_prefix(autocomplete_operator, last.negated);
var objs = _.map(persons, function (person) {
var name = highlight_person(query, person);
var description = prefix + ' ' + name;
var terms = [{
operator: autocomplete_operator,
operand: person.email,
negated: last.negated,
}];
if (autocomplete_operator === 'pm-with' && last.negated) {
// In the special case of '-pm-with', add 'is:private' before it
// because we assume the user still wants to narrow to PMs
terms.unshift({operator: 'is', operand: 'private'});
}
var search_string = Filter.unparse(terms);
return {description: description, search_string: search_string};
});
return objs;
}
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".)
if (operators.length === 0) {
return {description: '', search_string: ''};
}
return format_as_suggestion(operators);
}
function get_topic_suggestions(last, operators) {
var invalid = [
{operator: 'pm-with'},
{operator: 'is', operand: 'private'},
{operator: 'topic'},
];
if (!check_validity(last, operators, ['stream', 'topic', 'search'], invalid)) {
return [];
}
var operator = Filter.canonicalize_operator(last.operator);
var operand = last.operand;
var negated = operator === 'topic' && last.negated;
var stream;
var guess;
var filter = new Filter(operators);
var suggest_operators = [];
// 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
// minor issue, and Filter.parse() is currently lossy
// in terms of telling us whether they provided the operator,
// i.e. "foo" and "search:foo" both become [{operator: 'search', operand: 'foo'}].
switch (operator) {
case 'stream':
guess = '';
stream = operand;
suggest_operators.push(last);
break;
case 'topic':
case 'search':
guess = operand;
if (filter.has_operator('stream')) {
stream = filter.operands('stream')[0];
} else {
stream = narrow_state.stream();
suggest_operators.push({operator: 'stream', operand: stream});
}
break;
}
if (!stream) {
return [];
}
var stream_id = stream_data.get_stream_id(stream);
if (!stream_id) {
return [];
}
var topics = topic_data.get_recent_names(stream_id);
if (!topics || !topics.length) {
return [];
}
// Be defensive here in case stream_data.get_recent_topics gets
// super huge, but still slice off enough topics to find matches.
topics = topics.slice(0, 300);
if (guess !== '') {
topics = _.filter(topics, function (topic) {
return phrase_match(topic, guess);
});
}
topics = topics.slice(0, 10);
// Just use alphabetical order. While recency and read/unreadness of
// topics 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_term = {operator: 'topic', operand: topic, negated: negated};
var operators = suggest_operators.concat([topic_term]);
return format_as_suggestion(operators);
});
}
function get_operator_subset_suggestions(operators) {
// For stream:a topic:b search:c, suggest:
// stream:a topic:b
// stream:a
if (operators.length < 1) {
return [];
}
var i;
var suggestions = [];
for (i = operators.length - 1; i >= 1; i -= 1) {
var subset = operators.slice(0, i);
suggestions.push(format_as_suggestion(subset));
}
return suggestions;
}
function get_special_filter_suggestions(last, operators, suggestions) {
var is_search_operand_negated = last.operator === 'search' && last.operand[0] === '-';
// Negating suggestions on is_search_operand_negated is required for
// suggesting negated operators.
if (last.negated || is_search_operand_negated) {
suggestions = _.map(suggestions, function (suggestion) {
return {
search_string: '-' + suggestion.search_string,
description: 'exclude ' + suggestion.description,
invalid: suggestion.invalid,
};
});
}
var last_string = Filter.unparse([last]).toLowerCase();
suggestions = _.filter(suggestions, function (s) {
if (match_criteria(operators, s.invalid)) {
return false;
}
if (last_string === '') {
return true;
}
// returns the substring after the ":" symbol.
var suggestion_operand = s.search_string.substring(s.search_string.indexOf(":") + 1);
// e.g for `att` search query, `has:attachment` should be suggested.
var show_operator_suggestions = last.operator === 'search' && suggestion_operand.toLowerCase().indexOf(last_string) === 0;
return s.search_string.toLowerCase().indexOf(last_string) === 0 ||
show_operator_suggestions ||
s.description.toLowerCase().indexOf(last_string) === 0;
});
// Only show home if there's an empty bar
if (operators.length === 0 && last_string === '') {
suggestions.unshift({search_string: '', description: 'All messages'});
}
return suggestions;
}
function get_is_filter_suggestions(last, operators) {
var suggestions = [
{
search_string: 'is:private',
description: 'private messages',
invalid: [
{operator: 'is', operand: 'private'},
{operator: 'stream'},
{operator: 'pm-with'},
{operator: 'in'},
],
},
{
search_string: 'is:starred',
description: 'starred messages',
invalid: [
{operator: 'is', operand: 'starred'},
],
},
{
search_string: 'is:mentioned',
description: '@-mentions',
invalid: [
{operator: 'is', operand: 'mentioned'},
],
},
{
search_string: 'is:alerted',
description: 'alerted messages',
invalid: [
{operator: 'is', operand: 'alerted'},
],
},
{
search_string: 'is:unread',
description: 'unread messages',
invalid: [
{operator: 'is', operand: 'unread'},
],
},
];
return get_special_filter_suggestions(last, operators, suggestions);
}
function get_has_filter_suggestions(last, operators) {
var suggestions = [
{
search_string: 'has:link',
description: 'messages with one or more link',
invalid: [
{operator: 'has', operand: 'link'},
],
},
{
search_string: 'has:image',
description: 'messages with one or more image',
invalid: [
{operator: 'has', operand: 'image'},
],
},
{
search_string: 'has:attachment',
description: 'messages with one or more attachment',
invalid: [
{operator: 'has', operand: 'attachment'},
],
},
];
return get_special_filter_suggestions(last, operators, suggestions);
}
function get_sent_by_me_suggestions(last, operators) {
var last_string = Filter.unparse([last]).toLowerCase();
var negated = last.negated || last.operator === 'search' && last.operand[0] === '-';
var negated_symbol = negated ? '-' : '';
var verb = negated ? 'exclude ' : '';
var sender_query = negated_symbol + 'sender:' + people.my_current_email();
var from_query = negated_symbol + 'from:' + people.my_current_email();
var sender_me_query = negated_symbol + 'sender:me';
var from_me_query = negated_symbol + 'from:me';
var sent_string = negated_symbol + 'sent';
var description = verb + 'sent by me';
var invalid = [
{operator: 'sender'},
{operator: 'from'},
];
if (match_criteria(operators, invalid)) {
return [];
}
if (last.operator === '' || sender_query.indexOf(last_string) === 0 ||
sender_me_query.indexOf(last_string) === 0 || last_string === sent_string) {
return [
{
search_string: sender_query,
description: description,
},
];
} else if (from_query.indexOf(last_string) === 0 || from_me_query.indexOf(last_string) === 0) {
return [
{
search_string: from_query,
description: description,
},
];
}
return [];
}
function get_operator_suggestions(last) {
if (!(last.operator === 'search')) {
return [];
}
var last_operand = last.operand;
var negated = false;
if (last_operand.indexOf("-") === 0) {
negated = true;
last_operand = last_operand.slice(1);
}
var choices = ['stream', 'topic', 'pm-with', 'sender', 'near', 'from', 'group-pm-with'];
choices = _.filter(choices, function (choice) {
return phrase_match(choice, last_operand);
});
return _.map(choices, function (choice) {
var op = [{operator: choice, operand: '', negated: negated}];
return format_as_suggestion(op);
});
}
function attach_suggestions(result, base, suggestions) {
_.each(suggestions, function (suggestion) {
if (base.description.length > 0) {
suggestion.search_string = base.search_string + " " + suggestion.search_string;
suggestion.description = base.description + ", " + suggestion.description;
}
result.push(suggestion);
});
}
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 base; //base, default suggestion
var suggestions;
// Add an entry for narrow by operators.
var operators = Filter.parse(query);
var last = {operator: '', operand: '', negated: false};
if (operators.length > 0) {
last = operators.slice(-1)[0];
}
var person_suggestion_ops = ['sender', 'pm-with', 'from', 'group-pm'];
var operators_len = operators.length;
// Handle spaces in person name in new suggestions only. Checks if the last operator is
// 'search' and the second last operator is one out of person_suggestion_ops.
// e.g for `sender:Ted sm`, initially last = {operator: 'search', operand: 'sm'....}
// and second last is {operator: 'sender', operand: 'sm'....}. If the second last operand
// is an email of a user, both of these operators remain unchanged. Otherwise search operator
// will be deleted and new last will become {operator:'sender', operand: 'Ted sm`....}.
if (operators_len > 1 &&
last.operator === 'search' &&
person_suggestion_ops.indexOf(operators[operators_len - 2].operator) !== -1) {
var person_op = operators[operators_len - 2];
if (!people.get_by_email(person_op.operand)) {
last = {
operator: person_op.operator,
operand: person_op.operand + ' ' + last.operand,
negated: person_op.negated,
};
operators[operators_len - 2] = last;
operators.splice(-1, 1);
}
}
// Display the default first
// `has` and `is` operators work only on predefined categories. Default suggestion
// is not displayed in that case. e.g. `messages with one or more abc` as
// a suggestion for `has:abc`does not make sense.
if (last.operator !== '' && last.operator !== 'has' && last.operator !== 'is') {
suggestion = get_default_suggestion(operators);
result = [suggestion];
}
var base_operators = [];
if (operators.length > 1) {
base_operators = operators.slice(0, -1);
}
base = get_default_suggestion(base_operators);
// Get all individual suggestions, and then attach_suggestions
// mutates the list 'result' to add a properly-formatted suggestion
suggestions = get_is_filter_suggestions(last, base_operators);
attach_suggestions(result, base, suggestions);
suggestions = get_sent_by_me_suggestions(last, base_operators);
attach_suggestions(result, base, suggestions);
suggestions = get_stream_suggestions(last, base_operators);
attach_suggestions(result, base, suggestions);
var persons = people.get_all_persons();
suggestions = get_person_suggestions(persons, last, base_operators, 'sender');
attach_suggestions(result, base, suggestions);
suggestions = get_person_suggestions(persons, last, base_operators, 'pm-with');
attach_suggestions(result, base, suggestions);
suggestions = get_person_suggestions(persons, last, base_operators, 'from');
attach_suggestions(result, base, suggestions);
suggestions = get_person_suggestions(persons, last, base_operators, 'group-pm-with');
attach_suggestions(result, base, suggestions);
suggestions = get_group_suggestions(persons, last, base_operators);
attach_suggestions(result, base, suggestions);
suggestions = get_topic_suggestions(last, base_operators);
attach_suggestions(result, base, suggestions);
suggestions = get_operator_suggestions(last);
attach_suggestions(result, base, suggestions);
suggestions = get_has_filter_suggestions(last, base_operators);
attach_suggestions(result, base, suggestions);
suggestions = get_operator_subset_suggestions(operators);
result = result.concat(suggestions);
_.each(result, function (sug) {
var first = sug.description.charAt(0).toUpperCase();
sug.description = first + sug.description.slice(1);
});
// 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;
}