Quicksearch.pm 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. # -*- Mode: perl; indent-tabs-mode: nil -*-
  2. #
  3. # The contents of this file are subject to the Mozilla Public
  4. # License Version 1.1 (the "License"); you may not use this file
  5. # except in compliance with the License. You may obtain a copy of
  6. # the License at http://www.mozilla.org/MPL/
  7. #
  8. # Software distributed under the License is distributed on an "AS
  9. # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
  10. # implied. See the License for the specific language governing
  11. # rights and limitations under the License.
  12. #
  13. # The Original Code is the Bugzilla Bug Tracking System.
  14. #
  15. # Contributor(s): C. Begle
  16. # Jesse Ruderman
  17. # Andreas Franke <afranke@mathweb.org>
  18. # Stephen Lee <slee@uk.bnsmc.com>
  19. # Marc Schumann <wurblzap@gmail.com>
  20. package Bugzilla::Search::Quicksearch;
  21. # Make it harder for us to do dangerous things in Perl.
  22. use strict;
  23. use Bugzilla::Error;
  24. use Bugzilla::Constants;
  25. use Bugzilla::Keyword;
  26. use Bugzilla::Status;
  27. use Bugzilla::Field;
  28. use Bugzilla::Util;
  29. use base qw(Exporter);
  30. @Bugzilla::Search::Quicksearch::EXPORT = qw(quicksearch);
  31. # Word renamings
  32. use constant MAPPINGS => {
  33. # Status, Resolution, Platform, OS, Priority, Severity
  34. "status" => "bug_status",
  35. "resolution" => "resolution", # no change
  36. "platform" => "rep_platform",
  37. "os" => "op_sys",
  38. "opsys" => "op_sys",
  39. "priority" => "priority", # no change
  40. "pri" => "priority",
  41. "severity" => "bug_severity",
  42. "sev" => "bug_severity",
  43. # People: AssignedTo, Reporter, QA Contact, CC, Added comment (?)
  44. "owner" => "assigned_to", # deprecated since bug 76507
  45. "assignee" => "assigned_to",
  46. "assignedto" => "assigned_to",
  47. "reporter" => "reporter", # no change
  48. "rep" => "reporter",
  49. "qa" => "qa_contact",
  50. "qacontact" => "qa_contact",
  51. "cc" => "cc", # no change
  52. # Product, Version, Component, Target Milestone
  53. "product" => "product", # no change
  54. "prod" => "product",
  55. "version" => "version", # no change
  56. "ver" => "version",
  57. "component" => "component", # no change
  58. "comp" => "component",
  59. "milestone" => "target_milestone",
  60. "target" => "target_milestone",
  61. "targetmilestone" => "target_milestone",
  62. # Summary, Description, URL, Status whiteboard, Keywords
  63. "summary" => "short_desc",
  64. "shortdesc" => "short_desc",
  65. "desc" => "longdesc",
  66. "description" => "longdesc",
  67. #"comment" => "longdesc", # ???
  68. # reserve "comment" for "added comment" email search?
  69. "longdesc" => "longdesc",
  70. "url" => "bug_file_loc",
  71. "whiteboard" => "status_whiteboard",
  72. "statuswhiteboard" => "status_whiteboard",
  73. "sw" => "status_whiteboard",
  74. "keywords" => "keywords", # no change
  75. "kw" => "keywords",
  76. "group" => "bug_group",
  77. "flag" => "flagtypes.name",
  78. "requestee" => "requestees.login_name",
  79. "req" => "requestees.login_name",
  80. "setter" => "setters.login_name",
  81. "set" => "setters.login_name",
  82. # Attachments
  83. "attachment" => "attachments.description",
  84. "attachmentdesc" => "attachments.description",
  85. "attachdesc" => "attachments.description",
  86. "attachmentdata" => "attach_data.thedata",
  87. "attachdata" => "attach_data.thedata",
  88. "attachmentmimetype" => "attachments.mimetype",
  89. "attachmimetype" => "attachments.mimetype"
  90. };
  91. # We might want to put this into localconfig or somewhere
  92. use constant PLATFORMS => ('pc', 'sun', 'macintosh', 'mac');
  93. use constant OPSYSTEMS => ('windows', 'win', 'linux');
  94. use constant PRODUCT_EXCEPTIONS => (
  95. 'row', # [Browser]
  96. # ^^^
  97. 'new', # [MailNews]
  98. # ^^^
  99. );
  100. use constant COMPONENT_EXCEPTIONS => (
  101. 'hang' # [Bugzilla: Component/Keyword Changes]
  102. # ^^^^
  103. );
  104. # Quicksearch-wide globals for boolean charts.
  105. our ($chart, $and, $or);
  106. sub quicksearch {
  107. my ($searchstring) = (@_);
  108. my $cgi = Bugzilla->cgi;
  109. my $urlbase = correct_urlbase();
  110. $chart = 0;
  111. $and = 0;
  112. $or = 0;
  113. # Remove leading and trailing commas and whitespace.
  114. $searchstring =~ s/(^[\s,]+|[\s,]+$)//g;
  115. ThrowUserError('buglist_parameters_required') unless ($searchstring);
  116. if ($searchstring =~ m/^[0-9,\s]*$/) {
  117. # Bug number(s) only.
  118. # Allow separation by comma or whitespace.
  119. $searchstring =~ s/[,\s]+/,/g;
  120. if (index($searchstring, ',') < $[) {
  121. # Single bug number; shortcut to show_bug.cgi.
  122. print $cgi->redirect(-uri => "${urlbase}show_bug.cgi?id=$searchstring");
  123. exit;
  124. }
  125. else {
  126. # List of bug numbers.
  127. $cgi->param('bug_id', $searchstring);
  128. $cgi->param('order', 'bugs.bug_id');
  129. $cgi->param('bugidtype', 'include');
  130. }
  131. }
  132. else {
  133. # It's not just a bug number or a list of bug numbers.
  134. # Maybe it's an alias?
  135. if ($searchstring =~ /^([^,\s]+)$/) {
  136. if (Bugzilla->dbh->selectrow_array(q{SELECT COUNT(*)
  137. FROM bugs
  138. WHERE alias = ?},
  139. undef,
  140. $1)) {
  141. print $cgi->redirect(-uri => "${urlbase}show_bug.cgi?id=$1");
  142. exit;
  143. }
  144. }
  145. # It's no alias either, so it's a more complex query.
  146. my $legal_statuses = get_legal_field_values('bug_status');
  147. my $legal_resolutions = get_legal_field_values('resolution');
  148. # Globally translate " AND ", " OR ", " NOT " to space, pipe, dash.
  149. $searchstring =~ s/\s+AND\s+/ /g;
  150. $searchstring =~ s/\s+OR\s+/|/g;
  151. $searchstring =~ s/\s+NOT\s+/ -/g;
  152. my @words = splitString($searchstring);
  153. my $searchComments =
  154. $#words < Bugzilla->params->{'quicksearch_comment_cutoff'};
  155. my @openStates = BUG_STATE_OPEN;
  156. my @closedStates;
  157. my @unknownFields;
  158. my (%states, %resolutions);
  159. foreach (@$legal_statuses) {
  160. push(@closedStates, $_) unless is_open_state($_);
  161. }
  162. foreach (@openStates) { $states{$_} = 1 }
  163. if ($words[0] eq 'ALL') {
  164. foreach (@$legal_statuses) { $states{$_} = 1 }
  165. shift @words;
  166. }
  167. elsif ($words[0] eq 'OPEN') {
  168. shift @words;
  169. }
  170. elsif ($words[0] =~ /^\+[A-Z]+(,[A-Z]+)*$/) {
  171. # e.g. +DUP,FIX
  172. if (matchPrefixes(\%states,
  173. \%resolutions,
  174. [split(/,/, substr($words[0], 1))],
  175. \@closedStates,
  176. $legal_resolutions)) {
  177. shift @words;
  178. # Allowing additional resolutions means we need to keep
  179. # the "no resolution" resolution.
  180. $resolutions{'---'} = 1;
  181. }
  182. else {
  183. # Carry on if no match found.
  184. }
  185. }
  186. elsif ($words[0] =~ /^[A-Z]+(,[A-Z]+)*$/) {
  187. # e.g. NEW,ASSI,REOP,FIX
  188. undef %states;
  189. if (matchPrefixes(\%states,
  190. \%resolutions,
  191. [split(/,/, $words[0])],
  192. $legal_statuses,
  193. $legal_resolutions)) {
  194. shift @words;
  195. }
  196. else {
  197. # Carry on if no match found
  198. foreach (@openStates) { $states{$_} = 1 }
  199. }
  200. }
  201. else {
  202. # Default: search for unresolved bugs only.
  203. # Put custom code here if you would like to change this behaviour.
  204. }
  205. # If we have wanted resolutions, allow closed states
  206. if (keys(%resolutions)) {
  207. foreach (@closedStates) { $states{$_} = 1 }
  208. }
  209. $cgi->param('bug_status', keys(%states));
  210. $cgi->param('resolution', keys(%resolutions));
  211. # Loop over all main-level QuickSearch words.
  212. foreach my $qsword (@words) {
  213. my $negate = substr($qsword, 0, 1) eq '-';
  214. if ($negate) {
  215. $qsword = substr($qsword, 1);
  216. }
  217. my $firstChar = substr($qsword, 0, 1);
  218. my $baseWord = substr($qsword, 1);
  219. my @subWords = split(/[\|,]/, $baseWord);
  220. if ($firstChar eq '+') {
  221. foreach (@subWords) {
  222. addChart('short_desc', 'substring', $qsword, $negate);
  223. }
  224. }
  225. elsif ($firstChar eq '#') {
  226. addChart('short_desc', 'anywords', $baseWord, $negate);
  227. if ($searchComments) {
  228. addChart('longdesc', 'anywords', $baseWord, $negate);
  229. }
  230. }
  231. elsif ($firstChar eq ':') {
  232. foreach (@subWords) {
  233. addChart('product', 'substring', $_, $negate);
  234. addChart('component', 'substring', $_, $negate);
  235. }
  236. }
  237. elsif ($firstChar eq '@') {
  238. foreach (@subWords) {
  239. addChart('assigned_to', 'substring', $_, $negate);
  240. }
  241. }
  242. elsif ($firstChar eq '[') {
  243. addChart('short_desc', 'substring', $baseWord, $negate);
  244. addChart('status_whiteboard', 'substring', $baseWord, $negate);
  245. }
  246. elsif ($firstChar eq '!') {
  247. addChart('keywords', 'anywords', $baseWord, $negate);
  248. }
  249. else { # No special first char
  250. # Split by '|' to get all operands for a boolean OR.
  251. foreach my $or_operand (split(/\|/, $qsword)) {
  252. if ($or_operand =~ /^votes:([0-9]+)$/) {
  253. # votes:xx ("at least xx votes")
  254. addChart('votes', 'greaterthan', $1 - 1, $negate);
  255. }
  256. elsif ($or_operand =~ /^(?:flag:)?([^\?]+\?)([^\?]*)$/) {
  257. # Flag and requestee shortcut
  258. addChart('flagtypes.name', 'substring', $1, $negate);
  259. $chart++; $and = $or = 0; # Next chart for boolean AND
  260. addChart('requestees.login_name', 'substring', $2, $negate);
  261. }
  262. elsif ($or_operand =~ /^([^:]+):([^:]+)$/) {
  263. # generic field1,field2,field3:value1,value2 notation
  264. my @fields = split(/,/, $1);
  265. my @values = split(/,/, $2);
  266. foreach my $field (@fields) {
  267. # Skip and record any unknown fields
  268. if (!defined(MAPPINGS->{$field})) {
  269. push(@unknownFields, $field);
  270. next;
  271. }
  272. $field = MAPPINGS->{$field};
  273. foreach (@values) {
  274. addChart($field, 'substring', $_, $negate);
  275. }
  276. }
  277. }
  278. else {
  279. # Having ruled out the special cases, we may now split
  280. # by comma, which is another legal boolean OR indicator.
  281. foreach my $word (split(/,/, $or_operand)) {
  282. # Platform and operating system
  283. if (grep({lc($word) eq $_} PLATFORMS)
  284. || grep({lc($word) eq $_} OPSYSTEMS)) {
  285. addChart('rep_platform', 'substring',
  286. $word, $negate);
  287. addChart('op_sys', 'substring',
  288. $word, $negate);
  289. }
  290. # Priority
  291. elsif ($word =~ m/^[pP]([1-5](-[1-5])?)$/) {
  292. addChart('priority', 'regexp',
  293. "[$1]", $negate);
  294. }
  295. # Severity
  296. elsif (grep({lc($word) eq substr($_, 0, 3)}
  297. @{get_legal_field_values('bug_severity')})) {
  298. addChart('bug_severity', 'substring',
  299. $word, $negate);
  300. }
  301. # Votes (votes>xx)
  302. elsif ($word =~ m/^votes>([0-9]+)$/) {
  303. addChart('votes', 'greaterthan',
  304. $1, $negate);
  305. }
  306. # Votes (votes>=xx, votes=>xx)
  307. elsif ($word =~ m/^votes(>=|=>)([0-9]+)$/) {
  308. addChart('votes', 'greaterthan',
  309. $2-1, $negate);
  310. }
  311. else { # Default QuickSearch word
  312. if (!grep({lc($word) eq $_}
  313. PRODUCT_EXCEPTIONS) &&
  314. length($word)>2
  315. ) {
  316. addChart('product', 'substring',
  317. $word, $negate);
  318. }
  319. if (!grep({lc($word) eq $_}
  320. COMPONENT_EXCEPTIONS) &&
  321. length($word)>2
  322. ) {
  323. addChart('component', 'substring',
  324. $word, $negate);
  325. }
  326. if (grep({lc($word) eq lc($_)}
  327. map($_->name, Bugzilla::Keyword->get_all))) {
  328. addChart('keywords', 'substring',
  329. $word, $negate);
  330. if (length($word)>2) {
  331. addChart('short_desc', 'substring',
  332. $word, $negate);
  333. addChart('status_whiteboard',
  334. 'substring',
  335. $word, $negate);
  336. }
  337. }
  338. else {
  339. addChart('short_desc', 'substring',
  340. $word, $negate);
  341. addChart('status_whiteboard', 'substring',
  342. $word, $negate);
  343. }
  344. if ($searchComments) {
  345. addChart('longdesc', 'substring',
  346. $word, $negate);
  347. }
  348. }
  349. # URL field (for IP addrs, host.names,
  350. # scheme://urls)
  351. if ($word =~ m/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/
  352. || $word =~ /^[A-Za-z]+(\.[A-Za-z]+)+/
  353. || $word =~ /:[\\\/][\\\/]/
  354. || $word =~ /localhost/
  355. || $word =~ /mailto[:]?/
  356. # || $word =~ /[A-Za-z]+[:][0-9]+/ #host:port
  357. ) {
  358. addChart('bug_file_loc', 'substring',
  359. $word, $negate);
  360. }
  361. } # foreach my $word (split(/,/, $qsword))
  362. } # votes and generic field detection
  363. } # foreach (split(/\|/, $_))
  364. } # "switch" $firstChar
  365. $chart++;
  366. $and = 0;
  367. $or = 0;
  368. } # foreach (@words)
  369. # Inform user about any unknown fields
  370. if (scalar(@unknownFields)) {
  371. ThrowUserError("quicksearch_unknown_field",
  372. { fields => \@unknownFields });
  373. }
  374. # Make sure we have some query terms left
  375. scalar($cgi->param())>0 || ThrowUserError("buglist_parameters_required");
  376. }
  377. # List of quicksearch-specific CGI parameters to get rid of.
  378. my @params_to_strip = ('quicksearch', 'load', 'run');
  379. my $modified_query_string = $cgi->canonicalise_query(@params_to_strip);
  380. if ($cgi->param('load')) {
  381. # Param 'load' asks us to display the query in the advanced search form.
  382. print $cgi->redirect(-uri => "${urlbase}query.cgi?format=advanced&amp;"
  383. . $modified_query_string);
  384. }
  385. # Otherwise, pass the modified query string to the caller.
  386. # We modified $cgi->params, so the caller can choose to look at that, too,
  387. # and disregard the return value.
  388. $cgi->delete(@params_to_strip);
  389. return $modified_query_string;
  390. }
  391. ###########################################################################
  392. # Helpers
  393. ###########################################################################
  394. # Split string on whitespace, retaining quoted strings as one
  395. sub splitString {
  396. my $string = shift;
  397. my @quoteparts;
  398. my @parts;
  399. my $i = 0;
  400. # Now split on quote sign; be tolerant about unclosed quotes
  401. @quoteparts = split(/"/, $string);
  402. foreach my $part (@quoteparts) {
  403. # After every odd quote, quote special chars
  404. $part = url_quote($part) if $i++ % 2;
  405. }
  406. # Join again
  407. $string = join('"', @quoteparts);
  408. # Now split on unescaped whitespace
  409. @parts = split(/\s+/, $string);
  410. foreach (@parts) {
  411. # Protect plus signs from becoming a blank.
  412. # If "+" appears as the first character, leave it alone
  413. # as it has a special meaning. Strings which start with
  414. # "+" must be quoted.
  415. s/(?<!^)\+/%2B/g;
  416. # Remove quotes
  417. s/"//g;
  418. }
  419. return @parts;
  420. }
  421. # Expand found prefixes to states or resolutions
  422. sub matchPrefixes {
  423. my $hr_states = shift;
  424. my $hr_resolutions = shift;
  425. my $ar_prefixes = shift;
  426. my $ar_check_states = shift;
  427. my $ar_check_resolutions = shift;
  428. my $foundMatch = 0;
  429. foreach my $prefix (@$ar_prefixes) {
  430. foreach (@$ar_check_states) {
  431. if (/^$prefix/) {
  432. $$hr_states{$_} = 1;
  433. $foundMatch = 1;
  434. }
  435. }
  436. foreach (@$ar_check_resolutions) {
  437. if (/^$prefix/) {
  438. $$hr_resolutions{$_} = 1;
  439. $foundMatch = 1;
  440. }
  441. }
  442. }
  443. return $foundMatch;
  444. }
  445. # Negate comparison type
  446. sub negateComparisonType {
  447. my $comparisonType = shift;
  448. if ($comparisonType eq 'substring') {
  449. return 'notsubstring';
  450. }
  451. elsif ($comparisonType eq 'anywords') {
  452. return 'nowords';
  453. }
  454. elsif ($comparisonType eq 'regexp') {
  455. return 'notregexp';
  456. }
  457. else {
  458. # Don't know how to negate that
  459. ThrowCodeError('unknown_comparison_type');
  460. }
  461. }
  462. # Add a boolean chart
  463. sub addChart {
  464. my ($field, $comparisonType, $value, $negate) = @_;
  465. $negate && ($comparisonType = negateComparisonType($comparisonType));
  466. makeChart("$chart-$and-$or", $field, $comparisonType, $value);
  467. if ($negate) {
  468. $and++;
  469. $or = 0;
  470. }
  471. else {
  472. $or++;
  473. }
  474. }
  475. # Create the CGI parameters for a boolean chart
  476. sub makeChart {
  477. my ($expr, $field, $type, $value) = @_;
  478. my $cgi = Bugzilla->cgi;
  479. $cgi->param("field$expr", $field);
  480. $cgi->param("type$expr", $type);
  481. $cgi->param("value$expr", url_decode($value));
  482. }
  483. 1;