Browse Source

some security fix

Bachir Soussi Chiadmi 3 years ago
parent
commit
a0abb7b6e5
91 changed files with 11091 additions and 3418 deletions
  1. 0 0
      1
  2. BIN
      sites/all/libraries/plupload/examples/bg.jpg
  3. 0 94
      sites/all/libraries/plupload/examples/custom.html
  4. 0 36
      sites/all/libraries/plupload/examples/dump.php
  5. 0 196
      sites/all/libraries/plupload/examples/jquery/events.html
  6. 0 103
      sites/all/libraries/plupload/examples/jquery/jquery_ui_widget.html
  7. 0 174
      sites/all/libraries/plupload/examples/jquery/queue_widget.html
  8. 0 158
      sites/all/libraries/plupload/examples/jquery/s3.php
  9. 0 124
      sites/all/libraries/plupload/examples/upload.php
  10. 503 16
      sites/all/modules/contrib/search/search_api/CHANGELOG.txt
  11. 24 25
      sites/all/modules/contrib/search/search_api/README.txt
  12. 102 24
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
  13. 207 51
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
  14. 90 25
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
  15. 6 7
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.info
  16. 33 1
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.install
  17. 268 18
      sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.module
  18. 76 2
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/README.txt
  19. 48 9
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/display_facet_block.inc
  20. 37 18
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument.inc
  21. 161 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_date.inc
  22. 30 2
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_fulltext.inc
  23. 52 17
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_more_like_this.inc
  24. 31 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_string.inc
  25. 104 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_taxonomy_term.inc
  26. 0 17
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_text.inc
  27. 22 8
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter.inc
  28. 5 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_boolean.inc
  29. 86 9
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_date.inc
  30. 207 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_entity.inc
  31. 146 12
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_fulltext.inc
  32. 19 19
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_language.inc
  33. 248 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_numeric.inc
  34. 187 50
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_options.inc
  35. 335 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc
  36. 53 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_text.inc
  37. 79 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_user.inc
  38. 21 1
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_sort.inc
  39. 146 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/plugin_cache.inc
  40. 146 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/plugin_content_cache.inc
  41. 225 47
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/query.inc
  42. 34 0
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.api.php
  43. 16 10
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.info
  44. 12 4
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.install
  45. 26 3
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.module
  46. 151 16
      sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.views.inc
  47. 43 63
      sites/all/modules/contrib/search/search_api/includes/callback.inc
  48. 87 10
      sites/all/modules/contrib/search/search_api/includes/callback_add_aggregation.inc
  49. 28 54
      sites/all/modules/contrib/search/search_api/includes/callback_add_hierarchy.inc
  50. 6 1
      sites/all/modules/contrib/search/search_api/includes/callback_add_url.inc
  51. 14 7
      sites/all/modules/contrib/search/search_api/includes/callback_add_viewed_entity.inc
  52. 71 32
      sites/all/modules/contrib/search/search_api/includes/callback_bundle_filter.inc
  53. 46 0
      sites/all/modules/contrib/search/search_api/includes/callback_comment_access.inc
  54. 11 40
      sites/all/modules/contrib/search/search_api/includes/callback_language_control.inc
  55. 23 34
      sites/all/modules/contrib/search/search_api/includes/callback_node_access.inc
  56. 1 1
      sites/all/modules/contrib/search/search_api/includes/callback_node_status.inc
  57. 78 0
      sites/all/modules/contrib/search/search_api/includes/callback_role_filter.inc
  58. 57 0
      sites/all/modules/contrib/search/search_api/includes/callback_user_content.inc
  59. 31 0
      sites/all/modules/contrib/search/search_api/includes/callback_user_status.inc
  60. 337 222
      sites/all/modules/contrib/search/search_api/includes/datasource.inc
  61. 257 93
      sites/all/modules/contrib/search/search_api/includes/datasource_entity.inc
  62. 4 13
      sites/all/modules/contrib/search/search_api/includes/datasource_external.inc
  63. 360 0
      sites/all/modules/contrib/search/search_api/includes/datasource_multiple.inc
  64. 6 1
      sites/all/modules/contrib/search/search_api/includes/exception.inc
  65. 284 186
      sites/all/modules/contrib/search/search_api/includes/index_entity.inc
  66. 64 10
      sites/all/modules/contrib/search/search_api/includes/processor.inc
  67. 527 0
      sites/all/modules/contrib/search/search_api/includes/processor_highlight.inc
  68. 19 4
      sites/all/modules/contrib/search/search_api/includes/processor_html_filter.inc
  69. 9 1
      sites/all/modules/contrib/search/search_api/includes/processor_ignore_case.inc
  70. 731 0
      sites/all/modules/contrib/search/search_api/includes/processor_stemmer.inc
  71. 28 13
      sites/all/modules/contrib/search/search_api/includes/processor_stopwords.inc
  72. 26 7
      sites/all/modules/contrib/search/search_api/includes/processor_tokenizer.inc
  73. 20 0
      sites/all/modules/contrib/search/search_api/includes/processor_transliteration.inc
  74. 323 299
      sites/all/modules/contrib/search/search_api/includes/query.inc
  75. 176 8
      sites/all/modules/contrib/search/search_api/includes/server_entity.inc
  76. 121 117
      sites/all/modules/contrib/search/search_api/includes/service.inc
  77. 208 23
      sites/all/modules/contrib/search/search_api/search_api.admin.css
  78. 709 400
      sites/all/modules/contrib/search/search_api/search_api.admin.inc
  79. 157 11
      sites/all/modules/contrib/search/search_api/search_api.admin.js
  80. 91 15
      sites/all/modules/contrib/search/search_api/search_api.api.php
  81. 446 34
      sites/all/modules/contrib/search/search_api/search_api.drush.inc
  82. 13 6
      sites/all/modules/contrib/search/search_api/search_api.info
  83. 318 7
      sites/all/modules/contrib/search/search_api/search_api.install
  84. 521 166
      sites/all/modules/contrib/search/search_api/search_api.module
  85. 6 3
      sites/all/modules/contrib/search/search_api/search_api.rules.inc
  86. 595 163
      sites/all/modules/contrib/search/search_api/search_api.test
  87. 5 7
      sites/all/modules/contrib/search/search_api/tests/search_api_test.info
  88. 14 2
      sites/all/modules/contrib/search/search_api/tests/search_api_test.install
  89. 132 69
      sites/all/modules/contrib/search/search_api/tests/search_api_test.module
  90. 16 0
      sites/all/modules/contrib/search/search_api/tests/search_api_test_2.info
  91. 136 0
      sites/all/modules/contrib/search/search_api/tests/search_api_test_2.module

+ 0 - 0
1


BIN
sites/all/libraries/plupload/examples/bg.jpg


+ 0 - 94
sites/all/libraries/plupload/examples/custom.html

@@ -1,94 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-
-<title>Plupload - Queue widget example</title>
-
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(bg.jpg);
-	}
-</style>
-
-<script type="text/javascript" src="http://bp.yahooapis.com/2.4.21/browserplus-min.js"></script>
-
-<script type="text/javascript" src="../js/plupload.js"></script>
-<script type="text/javascript" src="../js/plupload.gears.js"></script>
-<script type="text/javascript" src="../js/plupload.silverlight.js"></script>
-<script type="text/javascript" src="../js/plupload.flash.js"></script>
-<script type="text/javascript" src="../js/plupload.browserplus.js"></script>
-<script type="text/javascript" src="../js/plupload.html4.js"></script>
-<script type="text/javascript" src="../js/plupload.html5.js"></script>
-
-<!-- <script type="text/javascript"  src="http://getfirebug.com/releases/lite/1.2/firebug-lite-compressed.js"></script> -->
-
-</head>
-<body>
-
-<form id="submit-form" method="post" action="dump.php">
-	<h1>Custom example</h1>
-
-	<p>Shows you how to use the core plupload API.</p>
-
-	<div>
-		<div id="filelist">No runtime found.</div>
-		<br />
-		<a id="pickfiles" href="javascript:;">[Select files]</a> 
-		<a id="uploadfiles" href="javascript:;">[Upload files]</a>
-	</div>
-    
-    <input type="submit" />
-</form>
-
-<script type="text/javascript">
-// Custom example logic
-function $(id) {
-	return document.getElementById(id);	
-}
-
-
-var uploader = new plupload.Uploader({
-	runtimes : 'gears,html5,flash,silverlight,browserplus',
-	browse_button : 'pickfiles',
-	max_file_size : '10mb',
-	url : 'upload.php',
-	resize : {width : 320, height : 240, quality : 90},
-	flash_swf_url : '../js/plupload.flash.swf',
-	silverlight_xap_url : '../js/plupload.silverlight.xap',
-	filters : [
-		{title : "Image files", extensions : "jpg,gif,png"},
-		{title : "Zip files", extensions : "zip"}
-	]
-});
-
-uploader.bind('Init', function(up, params) {
-	$('filelist').innerHTML = "<div>Current runtime: " + params.runtime + "</div>";
-});
-
-uploader.bind('FilesAdded', function(up, files) {
-	for (var i in files) {
-		$('filelist').innerHTML += '<div id="' + files[i].id + '">' + files[i].name + ' (' + plupload.formatSize(files[i].size) + ') <b></b></div>';
-	}
-});
-
-uploader.bind('UploadFile', function(up, file) {
-	$('submit-form').innerHTML += '<input type="hidden" name="file-' + file.id + '" value="' + file.name + '" />';
-});
-
-uploader.bind('UploadProgress', function(up, file) {
-	$(file.id).getElementsByTagName('b')[0].innerHTML = '<span>' + file.percent + "%</span>";
-});
-
-$('uploadfiles').onclick = function() {
-	uploader.start();
-	return false;
-};
-
-uploader.init();
-</script>
-</body>
-</html>

+ 0 - 36
sites/all/libraries/plupload/examples/dump.php

@@ -1,36 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-<link rel="stylesheet" href="css/plupload.css" type="text/css" media="screen" />
-<title>Plupload - Form dump</title>
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(bg.jpg);
-	}
-</style>
-</head>
-<body>
-
-<h1>Post dump</h1>
-
-<p>Shows the form items posted.</p>
-
-<table>
-	<tr>
-		<th>Name</th>
-		<th>Value</th>
-	</tr>
-	<?php $count = 0; foreach ($_POST as $name => $value) { ?>
-	<tr class="<?php echo $count % 2 == 0 ? 'alt' : ''; ?>">
-		<td><?php echo $name ?></td>
-		<td><?php echo nl2br(htmlentities(stripslashes($value))) ?></td>
-	</tr>
-	<?php } ?>
-</table>
-
-</body>
-</html>

+ 0 - 196
sites/all/libraries/plupload/examples/jquery/events.html

@@ -1,196 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-<link rel="stylesheet" href="../../js/jquery.plupload.queue/css/jquery.plupload.queue.css" type="text/css" media="screen" />
-<title>Plupload - Events example</title>
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(../bg.jpg);
-	}
-</style>
-
-<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js"></script>
-<script type="text/javascript" src="http://bp.yahooapis.com/2.4.21/browserplus-min.js"></script>
-
-<script type="text/javascript" src="../../js/plupload.js"></script>
-<script type="text/javascript" src="../../js/plupload.gears.js"></script>
-<script type="text/javascript" src="../../js/plupload.silverlight.js"></script>
-<script type="text/javascript" src="../../js/plupload.flash.js"></script>
-<script type="text/javascript" src="../../js/plupload.browserplus.js"></script>
-<script type="text/javascript" src="../../js/plupload.html4.js"></script>
-<script type="text/javascript" src="../../js/plupload.html5.js"></script>
-<script type="text/javascript" src="../../js/jquery.plupload.queue/jquery.plupload.queue.js"></script>
-
-</head>
-<body>
-
-<form method="post" action="dump.php">
-	<h1>Events example</h1>
-
-	<p>Shows how to bind and use all available events.</p>
-
-	<h3>Log messages</h3>
-	<textarea id="log" style="width: 100%; height: 150px; font-size: 11px" spellcheck="false" wrap="off"></textarea>
-
-	<h3>Queue widget</h3>
-	<div id="uploader" style="width: 450px; height: 330px;">You browser doesn't support upload.</div>
-	<a id="clear" href="#">Clear queue</a>
-</form>
-<script type="text/javascript">
-$(function() {
-	function log() {
-		var str = "";
-
-		plupload.each(arguments, function(arg) {
-			var row = "";
-
-			if (typeof(arg) != "string") {
-				plupload.each(arg, function(value, key) {
-					// Convert items in File objects to human readable form
-					if (arg instanceof plupload.File) {
-						// Convert status to human readable
-						switch (value) {
-							case plupload.QUEUED:
-								value = 'QUEUED';
-								break;
-
-							case plupload.UPLOADING:
-								value = 'UPLOADING';
-								break;
-
-							case plupload.FAILED:
-								value = 'FAILED';
-								break;
-
-							case plupload.DONE:
-								value = 'DONE';
-								break;
-						}
-					}
-
-					if (typeof(value) != "function") {
-						row += (row ? ', ': '') + key + '=' + value;
-					}
-				});
-
-				str += row + " ";
-			} else { 
-				str += arg + " ";
-			}
-		});
-
-		$('#log').val($('#log').val() + str + "\r\n");
-	}
-
-	$("#uploader").pluploadQueue({
-		// General settings
-		runtimes: 'html5,gears,browserplus,silverlight,flash,html4',
-		url: '../upload.php',
-		max_file_size: '10mb',
-		chunk_size: '1mb',
-		unique_names: true,
-
-		// Resize images on clientside if we can
-		resize: {width: 320, height: 240, quality: 90},
-
-		// Specify what files to browse for
-		filters: [
-			{title: "Image files", extensions: "jpg,gif,png"},
-			{title: "Zip files", extensions: "zip"}
-		],
-
-		// Flash/Silverlight paths
-		flash_swf_url: '../../js/plupload.flash.swf',
-		silverlight_xap_url: '../../js/plupload.silverlight.xap',
-
-		// PreInit events, bound before any internal events
-		preinit: {
-			Init: function(up, info) {
-				log('[Init]', 'Info:', info, 'Features:', up.features);
-			},
-
-			UploadFile: function(up, file) {
-				log('[UploadFile]', file);
-
-				// You can override settings before the file is uploaded
-				// up.settings.url = 'upload.php?id=' + file.id;
-				// up.settings.multipart_params = {param1: 'value1', param2: 'value2'};
-			}
-		},
-
-		// Post init events, bound after the internal events
-		init: {
-			Refresh: function(up) {
-				// Called when upload shim is moved
-				log('[Refresh]');
-			},
-
-			StateChanged: function(up) {
-				// Called when the state of the queue is changed
-				log('[StateChanged]', up.state == plupload.STARTED ? "STARTED": "STOPPED");
-			},
-
-			QueueChanged: function(up) {
-				// Called when the files in queue are changed by adding/removing files
-				log('[QueueChanged]');
-			},
-
-			UploadProgress: function(up, file) {
-				// Called while a file is being uploaded
-				log('[UploadProgress]', 'File:', file, "Total:", up.total);
-			},
-
-			FilesAdded: function(up, files) {
-				// Callced when files are added to queue
-				log('[FilesAdded]');
-
-				plupload.each(files, function(file) {
-					log('  File:', file);
-				});
-			},
-
-			FilesRemoved: function(up, files) {
-				// Called when files where removed from queue
-				log('[FilesRemoved]');
-
-				plupload.each(files, function(file) {
-					log('  File:', file);
-				});
-			},
-
-			FileUploaded: function(up, file, info) {
-				// Called when a file has finished uploading
-				log('[FileUploaded] File:', file, "Info:", info);
-			},
-
-			ChunkUploaded: function(up, file, info) {
-				// Called when a file chunk has finished uploading
-				log('[ChunkUploaded] File:', file, "Info:", info);
-			},
-
-			Error: function(up, args) {
-				// Called when a error has occured
-
-				// Handle file specific error and general error
-				if (args.file) {
-					log('[error]', args, "File:", args.file);
-				} else {
-					log('[error]', args);
-				}
-			}
-		}
-	});
-
-	$('#log').val('');
-	$('#clear').click(function(e) {
-		e.preventDefault();
-		$("#uploader").pluploadQueue().splice();
-	});
-});
-</script>
-</body>
-</html>

+ 0 - 103
sites/all/libraries/plupload/examples/jquery/jquery_ui_widget.html

@@ -1,103 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-<title>Plupload - Queue widget example</title>
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(../bg.jpg);
-	}
-</style>
-<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.9/themes/base/jquery-ui.css" type="text/css" />
-<link rel="stylesheet" href="../../js/jquery.ui.plupload/css/jquery.ui.plupload.css" type="text/css" />
-
-<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js"></script>
-<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.8.9/jquery-ui.min.js"></script>
-<script type="text/javascript" src="http://bp.yahooapis.com/2.4.21/browserplus-min.js"></script>
-
-<script type="text/javascript" src="../../js/plupload.js"></script>
-<script type="text/javascript" src="../../js/plupload.gears.js"></script>
-<script type="text/javascript" src="../../js/plupload.silverlight.js"></script>
-<script type="text/javascript" src="../../js/plupload.flash.js"></script>
-<script type="text/javascript" src="../../js/plupload.browserplus.js"></script>
-<script type="text/javascript" src="../../js/plupload.html4.js"></script>
-<script type="text/javascript" src="../../js/plupload.html5.js"></script>
-<script type="text/javascript" src="../../js/jquery.ui.plupload/jquery.ui.plupload.js"></script>
-
-<!--<script type="text/javascript" src="http://getfirebug.com/releases/lite/1.2/firebug-lite-compressed.js"></script>-->
-</head>
-<body>
-
-<h1>jQuery UI Widget</h1>
-
-<p>You can see this example with different themes on the <a href="http://plupload.com/example_jquery_ui.php">www.plupload.com</a> website.</p>
-
-<form  method="post" action="dump.php">
-	<div id="uploader">
-		<p>You browser doesn't have Flash, Silverlight, Gears, BrowserPlus or HTML5 support.</p>
-	</div>
-</form>
-<script type="text/javascript">
-// Convert divs to queue widgets when the DOM is ready
-$(function() {
-	$("#uploader").plupload({
-		// General settings
-		runtimes : 'flash,html5,browserplus,silverlight,gears,html4',
-		url : '../upload.php',
-		max_file_size : '1000mb',
-		max_file_count: 20, // user can add no more then 20 files at a time
-		chunk_size : '1mb',
-		unique_names : true,
-		multiple_queues : true,
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90},
-		
-		// Rename files by clicking on their titles
-		rename: true,
-		
-		// Sort files
-		sortable: true,
-
-		// Specify what files to browse for
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip,avi"}
-		],
-
-		// Flash settings
-		flash_swf_url : '../../js/plupload.flash.swf',
-
-		// Silverlight settings
-		silverlight_xap_url : '../../js/plupload.silverlight.xap'
-	});
-
-	// Client side form validation
-	$('form').submit(function(e) {
-		var uploader = $('#uploader').plupload('getUploader');
-
-		// Validate number of uploaded files
-		if (uploader.total.uploaded == 0) {
-			// Files in queue upload them first
-			if (uploader.files.length > 0) {
-				// When all files are uploaded submit form
-				uploader.bind('UploadProgress', function() {
-					if (uploader.total.uploaded == uploader.files.length)
-						$('form').submit();
-				});
-
-				uploader.start();
-			} else
-				alert('You must at least upload one file.');
-
-			e.preventDefault();
-		}
-	});
-
-});
-</script>
-</body>
-</html>

+ 0 - 174
sites/all/libraries/plupload/examples/jquery/queue_widget.html

@@ -1,174 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-<title>Plupload - Queue widget example</title>
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(../bg.jpg);
-	}
-</style>
-<link rel="stylesheet" href="../../js/jquery.plupload.queue/css/jquery.plupload.queue.css" type="text/css" media="screen" />
-
-<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js"></script>
-<script type="text/javascript" src="http://bp.yahooapis.com/2.4.21/browserplus-min.js"></script>
-
-<script type="text/javascript" src="../../js/plupload.js"></script>
-<script type="text/javascript" src="../../js/plupload.gears.js"></script>
-<script type="text/javascript" src="../../js/plupload.silverlight.js"></script>
-<script type="text/javascript" src="../../js/plupload.flash.js"></script>
-<script type="text/javascript" src="../../js/plupload.browserplus.js"></script>
-<script type="text/javascript" src="../../js/plupload.html4.js"></script>
-<script type="text/javascript" src="../../js/plupload.html5.js"></script>
-<script type="text/javascript" src="../../js/jquery.plupload.queue/jquery.plupload.queue.js"></script>
-
-<!-- <script type="text/javascript"  src="http://getfirebug.com/releases/lite/1.2/firebug-lite-compressed.js"></script> -->
-</head>
-<body>
-
-<form method="post" action="dump.php">
-	<h1>Queue widget example</h1>
-
-	<p>Shows the jQuery Plupload Queue widget and under different runtimes.</p>
-
-	<div style="float: left; margin-right: 20px">
-		<h3>Flash runtime</h3>
-		<div id="flash_uploader" style="width: 450px; height: 330px;">You browser doesn't have Flash installed.</div>
-
-		<h3>Gears runtime</h3>
-		<div id="gears_uploader" style="width: 450px; height: 330px;">You browser doesn't have Gears installed.</div>
-	</div>
-
-	<div style="float: left; margin-right: 20px">
-		<h3>Silverlight runtime</h3>
-		<div id="silverlight_uploader" style="width: 450px; height: 330px;">You browser doesn't have Silverlight installed.</div>
-
-		<h3>HTML 5 runtime</h3>
-		<div id="html5_uploader" style="width: 450px; height: 330px;">You browser doesn't support native upload. Try Firefox 3 or Safari 4.</div>
-	</div>
-
-	<div style="float: left; margin-right: 20px">
-		<h3>BrowserPlus runtime</h3>
-		<div id="browserplus_uploader" style="width: 450px; height: 330px;">You browser doesn't have BrowserPlus installed.</div>
-
-		<h3>HTML 4 runtime</h3>
-		<div id="html4_uploader" style="width: 450px; height: 330px;">You browser doesn't have HTML 4 support.</div>
-	</div>
-
-	<br style="clear: both" />
-
-	<input type="submit" value="Send" />
-</form>
-
-<script type="text/javascript">
-$(function() {
-	// Setup flash version
-	$("#flash_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'flash',
-		url : '../upload.php',
-		max_file_size : '10mb',
-		chunk_size : '1mb',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		],
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90},
-
-		// Flash settings
-		flash_swf_url : '../../js/plupload.flash.swf'
-	});
-
-	// Setup gears version
-	$("#gears_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'gears',
-		url : 'upload.php',
-		max_file_size : '10mb',
-		chunk_size : '1mb',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		],
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90}
-	});
-
-	// Setup silverlight version
-	$("#silverlight_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'silverlight',
-		url : 'upload.php',
-		max_file_size : '10mb',
-		chunk_size : '1mb',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		],
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90},
-
-		// Silverlight settings
-		silverlight_xap_url : '../../js/plupload.silverlight.xap'
-	});
-
-	// Setup html5 version
-	$("#html5_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'html5',
-		url : 'upload.php',
-		max_file_size : '10mb',
-		chunk_size : '1mb',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		],
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90}
-	});
-
-	// Setup browserplus version
-	$("#browserplus_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'browserplus',
-		url : 'upload.php',
-		max_file_size : '10mb',
-		chunk_size : '1mb',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		],
-
-		// Resize images on clientside if we can
-		resize : {width : 320, height : 240, quality : 90}
-	});
-
-	// Setup html4 version
-	$("#html4_uploader").pluploadQueue({
-		// General settings
-		runtimes : 'html4',
-		url : 'upload.php',
-		unique_names : true,
-		filters : [
-			{title : "Image files", extensions : "jpg,gif,png"},
-			{title : "Zip files", extensions : "zip"}
-		]
-	});
-});
-</script>
-
-</body>
-</html>

+ 0 - 158
sites/all/libraries/plupload/examples/jquery/s3.php

@@ -1,158 +0,0 @@
-<?php 
-/* 
-In order to upload files to S3 using Flash runtime, one should start by placing crossdomain.xml into the bucket.
-crossdomain.xml can be as simple as this:
-
-<?xml version="1.0"?>
-<!DOCTYPE cross-domain-policy SYSTEM "http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">
-<cross-domain-policy>
-<allow-access-from domain="*" secure="false" />
-</cross-domain-policy>
-
-In our tests SilverLight didn't require anything special and worked with this configuration just fine. It may fail back
-to the same crossdomain.xml as last resort.
-
-!!!Important!!! Plupload UI Widget here, is used only for demo purposes and is not required for uploading to S3.
-*/
-
-// important variables that will be used throughout this example
-$bucket = 'BUCKET';
-
-// these can be found on your Account page, under Security Credentials > Access Keys
-$accessKeyId = 'ACCESS_KEY_ID';
-$secret = 'SECRET_ACCESS_KEY';
-
-
-// hash_hmac — Generate a keyed hash value using the HMAC method 
-// (PHP 5 >= 5.1.2, PECL hash >= 1.1)
-if (!function_exists('hash_hmac')) :
-// based on: http://www.php.net/manual/en/function.sha1.php#39492
-function hash_hmac($algo, $data, $key, $raw_output = false)
-{
-	$blocksize = 64;
-    if (strlen($key) > $blocksize)
-        $key = pack('H*', $algo($key));
-    
-	$key = str_pad($key, $blocksize, chr(0x00));
-    $ipad = str_repeat(chr(0x36), $blocksize);
-    $opad = str_repeat(chr(0x5c), $blocksize);
-    $hmac = pack('H*', $algo(($key^$opad) . pack('H*', $algo(($key^$ipad) . $data))));
-	
-	return $raw_output ? $hmac : bin2hex($hmac);
-}
-endif;
-
-// prepare policy
-$policy = base64_encode(json_encode(array(
-	// ISO 8601 - date('c'); generates uncompatible date, so better do it manually
-	'expiration' => date('Y-m-d\TH:i:s.000\Z', strtotime('+1 day')),  
-	'conditions' => array(
-		array('bucket' => $bucket),
-		array('acl' => 'public-read'),
-		array('starts-with', '$key', ''),
-		// for demo purposes we are accepting only images
-		array('starts-with', '$Content-Type', 'image/'),
-		// "Some versions of the Adobe Flash Player do not properly handle HTTP responses that have an empty body. 
-		// To configure POST to return a response that does not have an empty body, set success_action_status to 201.
-		// When set, Amazon S3 returns an XML document with a 201 status code." 
-		// http://docs.amazonwebservices.com/AmazonS3/latest/dev/HTTPPOSTFlash.html
-		array('success_action_status' => '201'),
-		// Plupload internally adds name field, so we need to mention it here
-		array('starts-with', '$name', ''), 	
-		// One more field to take into account: Filename - gets silently sent by FileReference.upload() in Flash
-		// http://docs.amazonwebservices.com/AmazonS3/latest/dev/HTTPPOSTFlash.html
-		array('starts-with', '$Filename', ''), 
-	)
-)));
-
-// sign policy
-$signature = base64_encode(hash_hmac('sha1', $policy, $secret, true));
-
-?>
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
-<head>
-<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
-<title>Plupload to Amazon S3 Example</title>
-
-<style type="text/css">
-	body {
-		font-family:Verdana, Geneva, sans-serif;
-		font-size:13px;
-		color:#333;
-		background:url(../bg.jpg);
-	}
-</style>
-
-<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.9/themes/base/jquery-ui.css" type="text/css" />
-<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js" type="text/javascript"></script>
-<script src=" https://ajax.googleapis.com/ajax/libs/jqueryui/1.8.9/jquery-ui.min.js"></script>
-
-<!-- Load plupload and all it's runtimes and finally the UI widget -->
-<link rel="stylesheet" href="../../js/jquery.ui.plupload/css/jquery.ui.plupload.css" type="text/css" />
-
-<script type="text/javascript" src="../../js/plupload.js"></script>
-<script type="text/javascript" src="../../js/plupload.gears.js"></script>
-<script type="text/javascript" src="../../js/plupload.silverlight.js"></script>
-<script type="text/javascript" src="../../js/plupload.flash.js"></script>
-<script type="text/javascript" src="../../js/plupload.browserplus.js"></script>
-<script type="text/javascript" src="../../js/plupload.html4.js"></script>
-<script type="text/javascript" src="../../js/plupload.html5.js"></script>
-<script type="text/javascript" src="../../js/jquery.ui.plupload/jquery.ui.plupload.js"></script>
-<!--<script type="text/javascript" src="http://getfirebug.com/releases/lite/1.2/firebug-lite-compressed.js"></script>-->
-
-</head>
-<body>
-
-<h1>Plupload to Amazon S3 Example</h1>
-
-<div id="uploader">
-    <p>You browser doesn't have Flash, Silverlight, Gears, BrowserPlus or HTML5 support.</p>
-</div>
-
-<script type="text/javascript">
-// Convert divs to queue widgets when the DOM is ready
-$(function() {
-	$("#uploader").plupload({
-		runtimes : 'flash,silverlight',
-		url : 'http://<?php echo $bucket; ?>.s3.amazonaws.com/',
-		max_file_size : '10mb',
-		
-		multipart: true,
-		multipart_params: {
-			'key': '${filename}', // use filename as a key
-			'Filename': '${filename}', // adding this to keep consistency across the runtimes
-			'acl': 'public-read',
-			'Content-Type': 'image/jpeg',
-			'success_action_status': '201',
-			'AWSAccessKeyId' : '<?php echo $accessKeyId; ?>',		
-			'policy': '<?php echo $policy; ?>',
-			'signature': '<?php echo $signature; ?>'
-		},
-		
-		// !!!Important!!! 
-		// this is not recommended with S3, since it will force Flash runtime into the mode, with no progress indication
-		//resize : {width : 800, height : 600, quality : 60},  // Resize images on clientside, if possible 
-		
-		// optional, but better be specified directly
-		file_data_name: 'file',
-		
-		// re-use widget (not related to S3, but to Plupload UI Widget)
-		multiple_queues: true,
-
-		// Specify what files to browse for
-		filters : [
-			{title : "JPEG files", extensions : "jpg"}
-		],
-
-		// Flash settings
-		flash_swf_url : '../../js/plupload.flash.swf',
-
-		// Silverlight settings
-		silverlight_xap_url : '../../js/plupload.silverlight.xap'
-	});
-});
-</script>
-
-</body>
-</html>

+ 0 - 124
sites/all/libraries/plupload/examples/upload.php

@@ -1,124 +0,0 @@
-<?php
-/**
- * upload.php
- *
- * Copyright 2009, Moxiecode Systems AB
- * Released under GPL License.
- *
- * License: http://www.plupload.com/license
- * Contributing: http://www.plupload.com/contributing
- */
-
-// HTTP headers for no cache etc
-header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
-header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
-header("Cache-Control: no-store, no-cache, must-revalidate");
-header("Cache-Control: post-check=0, pre-check=0", false);
-header("Pragma: no-cache");
-
-// Settings
-//$targetDir = ini_get("upload_tmp_dir") . DIRECTORY_SEPARATOR . "plupload";
-$targetDir = 'uploads/';
-
-//$cleanupTargetDir = false; // Remove old files
-//$maxFileAge = 60 * 60; // Temp file age in seconds
-
-// 5 minutes execution time
-@set_time_limit(5 * 60);
-
-// Uncomment this one to fake upload time
-// usleep(5000);
-
-// Get parameters
-$chunk = isset($_REQUEST["chunk"]) ? $_REQUEST["chunk"] : 0;
-$chunks = isset($_REQUEST["chunks"]) ? $_REQUEST["chunks"] : 0;
-$fileName = isset($_REQUEST["name"]) ? $_REQUEST["name"] : '';
-
-// Clean the fileName for security reasons
-$fileName = preg_replace('/[^\w\._]+/', '', $fileName);
-
-// Make sure the fileName is unique but only if chunking is disabled
-if ($chunks < 2 && file_exists($targetDir . DIRECTORY_SEPARATOR . $fileName)) {
-	$ext = strrpos($fileName, '.');
-	$fileName_a = substr($fileName, 0, $ext);
-	$fileName_b = substr($fileName, $ext);
-
-	$count = 1;
-	while (file_exists($targetDir . DIRECTORY_SEPARATOR . $fileName_a . '_' . $count . $fileName_b))
-		$count++;
-
-	$fileName = $fileName_a . '_' . $count . $fileName_b;
-}
-
-// Create target dir
-if (!file_exists($targetDir))
-	@mkdir($targetDir);
-
-// Remove old temp files
-/* this doesn't really work by now
-	
-if (is_dir($targetDir) && ($dir = opendir($targetDir))) {
-	while (($file = readdir($dir)) !== false) {
-		$filePath = $targetDir . DIRECTORY_SEPARATOR . $file;
-
-		// Remove temp files if they are older than the max age
-		if (preg_match('/\\.tmp$/', $file) && (filemtime($filePath) < time() - $maxFileAge))
-			@unlink($filePath);
-	}
-
-	closedir($dir);
-} else
-	die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}');
-*/
-
-// Look for the content type header
-if (isset($_SERVER["HTTP_CONTENT_TYPE"]))
-	$contentType = $_SERVER["HTTP_CONTENT_TYPE"];
-
-if (isset($_SERVER["CONTENT_TYPE"]))
-	$contentType = $_SERVER["CONTENT_TYPE"];
-
-// Handle non multipart uploads older WebKit versions didn't support multipart in HTML5
-if (strpos($contentType, "multipart") !== false) {
-	if (isset($_FILES['file']['tmp_name']) && is_uploaded_file($_FILES['file']['tmp_name'])) {
-		// Open temp file
-		$out = fopen($targetDir . DIRECTORY_SEPARATOR . $fileName, $chunk == 0 ? "wb" : "ab");
-		if ($out) {
-			// Read binary input stream and append it to temp file
-			$in = fopen($_FILES['file']['tmp_name'], "rb");
-
-			if ($in) {
-				while ($buff = fread($in, 4096))
-					fwrite($out, $buff);
-			} else
-				die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
-			fclose($in);
-			fclose($out);
-			@unlink($_FILES['file']['tmp_name']);
-		} else
-			die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
-	} else
-		die('{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}');
-} else {
-	// Open temp file
-	$out = fopen($targetDir . DIRECTORY_SEPARATOR . $fileName, $chunk == 0 ? "wb" : "ab");
-	if ($out) {
-		// Read binary input stream and append it to temp file
-		$in = fopen("php://input", "rb");
-
-		if ($in) {
-			while ($buff = fread($in, 4096))
-				fwrite($out, $buff);
-		} else
-			die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
-
-		fclose($in);
-		fclose($out);
-	} else
-		die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
-}
-
-// Return JSON-RPC response
-die('{"jsonrpc" : "2.0", "result" : null, "id" : "id"}');
-
-?>

+ 503 - 16
sites/all/modules/contrib/search/search_api/CHANGELOG.txt

@@ -1,7 +1,494 @@
-Search API 1.x, dev (xx/xx/xxxx):
----------------------------------
+Search API 1.26 (2019-03-11):
+-----------------------------
+- #2324023 by drumm, drunken monkey: Changed Views field definition for to
+  float.
+- #3008849 by pamatt, drunken monkey: Fixed non-exposed numeric and date
+  filters in Views.
+- #3009744 by evgeny.chernyavskiy, drunken monkey: Fixed wrong "continue" in
+  search_api_server_tasks_check().
+- #3003742 by Jelle_S, drunken monkey: Fixed problems with Views date filters.
+- #3002043 by alonaoneill, drunken monkey: Fixed module name capitalization and
+  dependency namespacing in .info files.
+- #2990940 by drunken monkey: Fixed multi-byte handling of Highlight processor.
+- #3001424 by drunken monkey: Fixed notice when configuring the More Like This
+  contextual filter.
 
-Search API 1.4 (01/09/2013):
+Search API 1.25 (2018-09-17):
+-----------------------------
+- #2408727 by swim, drunken monkey: Added a batch operation for executing
+  pending tasks.
+- #2325917 by guillaumev, drunken monkey: Added a Views cache plugin based on
+  Views Content Cache.
+- #2989578 by KarlShea, drunken monkey: Fixed Views exposed form fields for
+  "not between" operator.
+- #2982167 by osopolar, drunken monkey: Added a Drush command for re-indexing
+  specific entities.
+- #1783746 by das-peter, sammys, SpadXIII, drunken monkey, ruloweb, KarlShea,
+  heshanlk, Anas_maw, pinkonomy, Damien Tournoud, rudiedirkx: Added support
+  for the "(not) between" operator.
+- #2408727 by drunken monkey, OliverColeman: Fixed out-of-memory errors when
+  executing pending tasks.
+-  Issue #2948820 by capysara, drunken monkey: Added a link to the "need to
+  reindex" message on the Filters tab.
+- #2828883 by JorgenSandstrom, drunken monkey: Fixed property type for
+  string-typed aggregated fields.
+- #2949899 by drunken monkey, DamienMcKenna: Added a warning against using
+  particular processors with Solr servers to the "Workflow" tab.
+
+Search API 1.24 (2018-04-05):
+-----------------------------
+- #2958201 by jcnventura, drunken monkey: Reverted issue #2566529: Added
+  support for the "Content access" processor for "Multiple types" indexes.
+
+Search API 1.23 (2018-03-31):
+-----------------------------
+- #2949562 by DamienMcKenna, drunken monkey: Fixed stemming of multi-word
+  tokens.
+- #1903004 by AndyF, joseph.olstad, drunken monkey: Fixed errors at feature
+  module installation in certain edge cases.
+- #2889989 by kevineinarsson, drunken monkey, kristofferwiklund: Fixed
+  highlighting for text with multi-byte characters.
+- #1393064 by xlyz, drunken monkey, jannis: Fixed handling of empty facet
+  filters.
+- #2927692 by drunken monkey, Kristi Wachter: Fixed exposed grouped Views
+  options filters.
+- #2928769 by jannis, drunken monkey: Fixed Views cache not being cleared when
+  enabling indexes.
+- #2566529 by Dylan Donkersgoed, drunken monkey, joachim, swirt: Added support
+  for the "Content access" processor for "Multiple types" indexes.
+- #2905445 by ciss, drunken monkey: Fixed error handling in Views term filter
+  handler.
+- #2904268 by pobster, drunken monkey: Added support for language hierarchy in
+  Views.
+
+Search API 1.22 (2017-07-18):
+-----------------------------
+- #1710212 by drunken monkey: Added a data alteration for indexing a user's
+  content.
+- #2879892 by blacklabel_tom, drunken monkey: Fixed link in description of
+  "Stemmer" processor.
+- #2788593 by drunken monkey: Fixed error in Views query settings for specific
+  setups.
+- #2749963 by drunken monkey: Fixed "Index hierarchy" not having values
+  numerically indexed.
+- #2875793 by drunken monkey: Fixed buggy error handling in Views.
+- #2860624 by drunken monkey: Fixed problem with empty words in Views fulltext
+  filter.
+- #2855447 by mparker17, drunken monkey: Added "Separator" option for
+  aggregated fields of type "Fulltext".
+- #2863445 by dbjpanda, drunken monkey: Fixed phrasing in README.txt.
+
+Search API 1.21 (2017-02-23):
+-----------------------------
+- #2780341 by Berdir: Fixed passing of custom ranges to date facets.
+- #2765317 by JorgenSandstrom, NWOM, drunken monkey: Added a "Last" aggregation
+  type.
+- #2842856 by drunken monkey: Fixed language filters for "Multiple types"
+  indexes.
+- #2844990 by drunken monkey: Made the "Role filter" data alteration available
+  for multi-type indexes.
+- #2837745 by drunken monkey, klausi: Fixed NULL tags on old serialized queries.
+- #2833482 by drunken monkey: Fixed undefined constant when uninstalling facets
+  module.
+- #2840261 by alan-ps: Fixed usage of outdated hash functions.
+- #1670420 by kyletaylored, dorficus, drunken monkey: Fixed potential fatal
+  error in facet adapter's getSearchKeys() method.
+- #2838075 by dsnopek: Fixed possible race condition in
+  hook_system_info_alter().
+- #2836687 by sarthak drupal: Fixed one doc comment typo.
+- #2632880 by drunken monkey, donquixote: Added possibility to change indexed
+  bundles on disabled indexes.
+- #2828380 by jansete: Fixed taxonomy term access tag in Views filter.
+- #2827717 by Fabien.Godineau, drunken monkey: Fixed disabling of search views
+  when reverting an index.
+- #2822836 by prince_zyxware: Fixed some Drupal coding standards violations.
+- #2822145 by drunken monkey: Fixed problem with phrase search in Views
+  fulltext filter.
+- #2778261 by drunken monkey, BAHbKA: Fixed "Index items immediately"
+  functionality for unindexed items.
+- #2358065 by Jelle_S, graper, drunken monkey: Added the option for
+  highlighting of partial matches to the processor.
+- #2779159 by mark_fullmer, drunken monkey: Added a Stemmer processor.
+- #2649412 by relaxnow, GoZ: Added support for minimum granularity to date
+  facets.
+- #2769021 by Plazik, drunken monkey: Added the generated Search API query to
+  the Views preview.
+- #2769877 by mfernea: Fixed database exception when filtering for anonymous
+  user.
+
+Search API 1.20 (2016-07-21):
+-----------------------------
+- #2731103 by drunken monkey: Fixed the default value for the taxonomy term
+  filter "multiple" setting.
+- #1818572 by morningtime, drunken monkey, lodey, guillaumev: Added pretty
+  paths support to the Views facets block.
+- #2753441 by Johnny vd Laar: Fixed translated field names in
+  language-independent cache.
+
+Search API 1.19 (2016-07-05):
+-----------------------------
+- #2724687 by StefanPr, drunken monkey: Fixed failed sanitization of NULL field
+  values.
+- #2744189 by nikolabintev, drunken monkey: Fixed highlighting for single-word
+  fields.
+- #2744995 by John Cook, drunken monkey: Fixed search views without pager.
+- #2742053 by tunic: Fixed change notification on node access records change.
+- #2733447 by jsacksick: Fixed translatability of our Views taxonomy term
+  filter.
+- #2720465 by drunken monkey: Fixed bundle filter's handling of entity types
+  with no bundles on multi-type indexes.
+- #2710893 by alan-ps, drunken monkey: Fixed creation of comment indexes when
+  no nodes exist.
+- #2707039 by alan-ps: Fixed indexes of flag entities with "bundles" setting.
+- #2700879 by drunken monkey: Fixed breadcrumbs on index tabs.
+- #1889940 by cspurk, Yaron Tal: Fixed "HTML filter" processor to recognize all
+  valid HTML tags.
+- #2700011 by drunken monkey: Fixed compatibility issues of facets from
+  different indexes.
+- #2665970 by andrei.colesnic, drunken monkey: Added "Limit list to selected
+  items" exposed option support for Views taxonomy term filters.
+- #2703675 by drunken monkey, heykarthikwithu: Fixed accidental assumption that
+  all facets are taxonomy terms.
+- #2419853 by drunken monkey: Fixed HTML filter leaves escaped entities in
+  field values sometimes.
+
+Search API 1.18 (2016-04-20):
+-----------------------------
+- Various security fixes – see https://www.drupal.org/node/2710063.
+- #2693425 by jojyja: Fixed a typo in search_api.info.
+
+Search API 1.17 (2016-03-14):
+-----------------------------
+- #2665586 by recrit, drunken monkey: Fixed parsing of invalid date facet
+  filters.
+- #2677900 by stefan.r, drunken monkey: Added the possibility to change date
+  facet formats.
+- #2678856 by stefan.r, drunken monkey: Fixed date facets showing wrong month
+  on certain days.
+- #2667872 by Les Lim: Added "0" to field boost options.
+- #2654328 by drunken monkey, donquixote: Fixed use of "<" and ">" for open
+  facet ranges.
+- #2639200 by joachim: Added sorting to "related fields" select box.
+- #2638740 by joachim, drunken monkey: Added a link to the index to the
+  "re-indexing necessary" message.
+- #2629136 by drunken monkey, deranga: Fixed wrong facet counts in edge cases
+  for active OR facets.
+- #2569461 by kraynuk.m, drunken monkey: Fixed existing table in update #7118.
+- #2631276 by tauno: Fixed the MLT handler for multi-entity indexes.
+- #2576265 by drunken monkey: Fixed view trying to search on non-fulltext field.
+- #2572487 by drunken monkey: Removed operator setting for date facets.
+- #2611714 by rakesh.gectcr, drunken monkey: Improved compliance with
+  documentation standards.
+- #2613054 by temkin: Fixed the "search-api-index" Drush command to allow
+  setting further options when indexing on all indexes.
+- #2611726 by Hubbs, rakesh.gectcr: Fixed several typos.
+- #2603500 by drunken monkey, krishna savithraj: Fixed Views fulltext searches
+  for keyword "All".
+- #2529262 by kingmackenzie, stefan.r: Added an option to Views date filters to
+  choose the format used by date popup.
+- #2583263 by drunken monkey: Fixed Views integration in combination with
+  Search API ET and similar modules.
+- #2592231 by drunken monkey, balintcsaba: Fixed ignored item language when
+  viewing translated items.
+- #2570879 by thePanz, drunken monkey: Added sorting of remembered search IDs.
+- #2565743 by drunken monkey: Fixed creation of comment indexes with specific
+  bundles.
+- #2563793 by drunken monkey, smitty, ReBa: Fixed Views base table definition
+  for "Multiple types" indexes.
+- #2567775 by joseph.olstad, drunken monkey: Fixed handling of broken HTML in
+  the "HTML filter" processor.
+- #2565005 by drunken monkey: Properly escape labels of "checkboxes"/"radios"
+  options
+- #2524314 by drunken monkey: Fixed bundle-setting for taxonomy term indexes.
+- #2550599 by ACF, drunken monkey: Fixed error on entity rebuilds.
+
+Search API 1.16 (2015-08-30):
+-----------------------------
+- #2502819: Fixed example code for hook_search_api_query_alter().
+- #2491175 by ptmkenny, drunken monkey: Added a data alteration for filtering
+  out blocked users.
+- #1197538 by thePanz, k4v, drunken monkey, ayalon, nadavoid, timodwhit, becw,
+  Elvar: Added support for the "Global: Random" sort in Views.
+- #2520934 by drunken monkey: Added an item type for indexing several types of
+  entities in one index.
+- #2533096 by drunken monkey: Fixed uncaught exception when deleting a server.
+- #2479453 by prics, drunken monkey: Added a Drush command to
+  list/enable/disable servers.
+- #2520684 by drunken monkey: Fixed "bundles" setting on indexes with "Index
+  immediately".
+- #2489882 by dww: Fixed Views taxonomy filter with "is (not) empty" operator
+- #2447213 by drunken monkey: Fixed issues with stale field settings for MLT
+  contextual filter.
+
+Search API 1.15 (2015-06-03):
+-----------------------------
+- #2190627 by m1n0, drunken monkey: Fixed fatal errors for views of disabled
+  indexes.
+- #2448849 by cgoffin: Added "year range" option for date filters.
+- #2414425 by Darren Oh, drunken monkey: Fixed backend form validation when
+  adding or editing a server.
+- #2450333 by drunken monkey: Added performance improvement when indexing
+  entity references.
+- #2450227 by StryKaizer, drunken monkey: Fixed OR facets on taxonomy terms.
+- #1184610 by drunken monkey: Added option to limit indexes to specific entity
+  bundles.
+- #1396222 by drunken monkey: Added a "First letter" aggregation type to the
+  "Aggregated fields" data alteration.
+- #2412895 by drunken monkey: Fixed entity load for Views entity access check.
+- #2414367 by Darren Oh, drunken monkey: Fixed detection of missing fields in
+  Views.
+- #2387161 by drunken monkey: Added a hook for altering search results.
+
+Search API 1.14 (2014-12-26):
+-----------------------------
+- #2382385 by illusionuk, drunken monkey: Fixed error handling when using
+  invalid fulltext or sort field in Views.
+- #2371099 by drunken monkey: Fixed display of active "Exclude" facets.
+- #1861134 by Cyberwolf, jackbravo, drunken monkey: Fixed indexing on multiple
+  indexes with Drush.
+- #2347367 by drunken monkey, das-peter: Fixed forgotten usages of
+  $index->item_type.
+- #2359201 by drunken monkey: Added a "List" option to "Aggregated fields".
+- #2364247 by drunken monkey: Fixed documentation for
+  SearchApiQueryFilterInterface::getFilters().
+- #2364875 by Xano: Fixed Views argument handler for fulltext fields.
+- #2174163 by drunken monkey: Fixed detection of field type changes by data
+  alterations.
+- #2305755 by drunken monkey, pfrenssen: Fixed invalidation of the stored index
+  fields cache.
+- #2334727 by Alex Bukach, drunken monkey: Fixed Views caching does not take
+  items_per_page into account.
+- #1372092 by drunken monkey: Added an error message when no service class is
+  available when creating a server.
+- #2305627 by drunken monkey, cpliakas: Fixed date facets not displayed when
+  the configured granularity is larger than the calculated granularity.
+- #2319263 by solotandem: Added easier way to subclass entity classes.
+- #2278737 by drunken monkey: Fixed use of multiple Views fulltext search
+  filters.
+
+Search API 1.13 (2014-07-23):
+-----------------------------
+- #2281535 by areynolds, nicola85: Adapted to latest changes in Views cache
+  plugins.
+- #2145547 by aaronbauman: Fixed duplicated sorts (one exposed) in Views.
+- #2146435 by alanmackenzie: Fixed Views paging with custom pager add-ons.
+- #2278791 by drunken monkey | tksmd: Fixed excerpt when searching single CJK
+  word.
+- #2272983 by idflood, drunken monkey: Fixed Highlighting processor for queries
+  without returned results.
+- #2216345 by bacardi55, fabianderijk, drunken monkey: Fixed array to string
+  conversion in Highlighting processor.
+
+Search API 1.12 (2014-05-23):
+-----------------------------
+- #2265349 by drunken monkey: Marked _search_api_settings_equals() as
+  deprecated.
+- #2256891 by justanothermark: Fixed "0" entity labels.
+- #2233749 by rjacobs, drunken monkey: Added drush support to change the server
+  used by an index.
+- #2219553 by drunken monkey: Fixed Views fulltext filter operators.
+- #2135697 by drunken monkey: Fixed handling of HTML attributes in the
+  Highlighting processor.
+- #2179755 by drunken monkey, fago: Fixed whitespaces after HTML filter.
+- #2204847 by drunken monkey, alanmackenzie: Fixed Views caching issues with
+  pagination.
+- #2198791 by drunken monkey: Fixed empty Views entity filters.
+- #2195469 by freakalis, drunken monkey: Added "Exclude fields" options to
+  Highlighting processor.
+- #2169455 by drunken monkey: Fixed "undefined index" in
+  search_api_update_7116().
+- #2219563 by drunken monkey: Added __toString() methods for queries and
+  filters.
+- #1888174 by drunken monkey, ipallian: Fixed problems with date facets.
+- #2187487 by drunken monkey: Fixed admin summary of language filter.
+- #2198261 by drunken monkey: Fixed fatal error on view editing.
+- #2168713 by idebr: Fixed highlighting of keys containing slashes.
+- #2150779 by hefox: Fixed "Overridden" detection for index features.
+- #1227702 by drunken monkey: Improved error handling.
+
+Search API 1.11 (2013-12-25):
+-----------------------------
+- #1879196 by drunken monkey: Fixed invalid old indexes causing errors.
+- #2155127 by drunken monkey: Clarified the scope of the "Node access" and
+  "Exclude unpublished nodes" data alterations.
+- #2155575 by drunken monkey: Fixed incorrect "Server index status" warnings.
+- #2159011 by idebr, drunken monkey: Fixed highlighting of keywords with PCRE
+  special characters.
+- #2155721 by rjacobs, drunken monkey: Added support for Views' get_total_rows
+  property.
+- #2158873 by drumm, drunken monkey: Fixed "all of" operator of Views entity
+  filter handler.
+- #2156021 by jgullstr: Fixed confirm message when disabling servers.
+- #2146435 by timkang: Fixed Views paging with custom pager add-ons.
+- #2150347 by drunken monkey: Added access callbacks for indexes and servers.
+
+Search API 1.10 (2013-12-09):
+-----------------------------
+- #2130819 by drunken monkey, Bojhan: Added UI improvements for the "View" tabs.
+- #2152327 by sirtet, miro_dietiker: Fixed typo in help text for drush sapi-c.
+- #2144531 by drunken monkey: Fixed cloning of queries to clone filters, too.
+- #2100671 by drunken monkey: Fixed stopwords processor to ignore missing
+  stopwords.
+- #2139239 by drunken monkey: Fixed highlighting for the last word of a field.
+- #1925114 by azinck: Fixed Views Facet Block integration with Panels.
+- #2139215 by drunken monkey: Fixed $context parameter of batch callback.
+- #2143659 by khiminrm: Fixed typo in update function 7116.
+- #2134509 by kscheirer, drunken monkey: Removed unused variables and
+  parameters.
+- #2136019 by drunken monkey: Fixed mapping callback for taxonomy term facets.
+- #2128001 by drunken monkey: Fixed the logic of the "contains none of these
+  words" fulltext operator.
+- #2128947 by stBorchert, drunken monkey: Fixed facet handling for multiple
+  searches on a page.
+- #2128529 by Frando, drunken monkey: Added a way for facet query type plugins
+  to pass options to the search query.
+- #1551302 by drunken monkey: Fixed the server tasks system.
+- #2135363 by drumm, drunken monkey: Added support for Views' use_count_query()
+  method.
+- #1390598 by Damien Tournoud, drunken monkey: Added the concept of query filter
+  tags.
+- #2135255 by dww: Fixed missing pager on first page of search results.
+- #1832334 by Damien Tournoud, drunken monkey: Fixed performance issues of
+  Views options filter handler for huge options lists.
+- #2118589 by mxr576, drunken monkey: Added node access for comment indexes.
+- #1961120 by drunken monkey: Fixed Views handling of short fulltext keywords.
+- #2100231 by drunken monkey: Renamed "Workflow" tab to "Filters".
+- #2100193 by drunken monkey: Turned operations in overview into D8 dropbuttons.
+- #2100199 by drunken monkey: Merged index tabs for a cleaner look.
+- #2115127 by drunken monkey: Fixed cron indexing logic to keep the right order.
+- #1750144 by jsacksick, drunken monkey: Fixed missing Boost option for custom
+  fulltext field types.
+- #1956650 by drunken monkey, wwhurley: Fixed trackItemChange not checking for
+  empty $item_ids.
+- #2100191 by drunken monkey, Bojhan: Added an admin description to the Search
+  API landing page.
+
+Search API 1.9 (2013-10-23):
+----------------------------
+- #2113277 by moonray, drunken monkey: Fixed date facet count for active item.
+- #2086783 by drunken monkey: Removed Views field handlers for "virtual" fields.
+- #2114593 by drunken monkey: Added list of floats to test module.
+- #2109247 by mmikitka, drunken monkey: Exposed the status and module
+  properties to Entity API.
+- #2091499 by sammys, drunken monkey: Added Views contextual filter handler for
+  dates.
+- #2109537 by hefox, drunken monkey: Added alter hooks for workflow plugin
+  definitions.
+- #2102111 by sergei_brill: Added hook_search_api_views_query_alter().
+- #2110315 by drumm, drunken monkey: Added specialized Views filters for users
+  and terms.
+- #2111273 by drunken monkey: Fixed Javascript states for exposed filter
+  operator.
+- #2102353 by aaronbauman: Fixed "smaller than" to read "less than".
+- #2097559 by thijsvdanker: Fixed the language of created search excerpts.
+- #2096275 by andrewbelcher: Fixed calling of Views pager pre/post execute
+  callbacks.
+- #2093023 by maciej.zgadzaj: Added Drush commands to enable and disable
+  indexes.
+- #2088905 by queenvictoria, drunken monkey: Fixed handling of Views
+  override_path option.
+- #2083481 by drunken monkey, nickgs: Added "exclude" option for facets.
+- #2084953 by Yaron Tal: Fixed issue with theme initialization.
+- #2075839 by leeomara, drunken monkey: Added descriptions to field lists for
+  'Aggregated Fields'.
+
+Search API 1.8 (2013-09-01):
+----------------------------
+- #1414048 by drunken monkey: Fixed exception in views.inc removes all Search
+  API tables.
+- #1921690 by drunken monkey: Fixed stale Views cache when indexed fields
+  change.
+- #2077035 by maciej.zgadzaj: Fixed whitespace recognition for search keys.
+- #2071229 by drunken monkey: Fixed use of core search constant.
+- #2069023 by drunken monkey: Fixed reaction to disabled modules.
+- #2057867 by drunken monkey: Fixed multiple values for taxonomy contextual
+  filter.
+- #2052701 by drunken monkey, erdos: Fixed cron queue state when disabling the
+  module.
+- #1878606 by drunken monkey: Fixed labels for boolean facets.
+- #2053171 by drunken monkey: Improved tests.
+- #1433720 by davidwbarratt, drunken monkey, JvE: Fixed handling of empty
+  selection for checkboxes.
+- #1414078 by drunken monkey, jaxxed: Fixed revert of exportables.
+- #2011396 by drunken monkey: Fixed support for several facets on a single
+  field.
+- #2050117 by izus, drunken monkey: Updated README.txt to reflect removed
+  sub-modules.
+- #2041365 by drunken monkey: Fixed error reporting for the MLT contextual
+  filter.
+- #2044711 by stBorchert, drunken monkey: Fixed facet adapter's
+  getCurrentSearch() method to not cache failed attempts.
+- #1411712 by Krasnyj, drunken monkey: Fixed notices in Views with groups.
+- #1959506 by jantoine, drunken monkey: Fixed "search id" for Views facets
+  block display.
+- #1902168 by rbruhn, drunken monkey, mpv: Fixed fatal error during Features
+  import.
+- #2040111 by arpieb: Fixed Views URL argument handler to allow multiple values.
+- #1064520 by drunken monkey: Added a processor for highlighting.
+
+Search API 1.7 (2013-07-01):
+----------------------------
+- #1612708 by drunken monkey: Fixed Views caching with facet blocks.
+- #2024189 by drunken monkey: Improved serialization of the query class.
+- #1311260 by drunken monkey: Fixed tokenizing of string fields.
+- #1246998 by drunken monkey: Fixed deletion of items in read-only indexes.
+- #1310970 by drunken monkey: Added improved UI help for determining which
+  fields are available for sorting.
+- #1886738 by chx, Jelle_S, drunken monkey: Added Role filter data alteration.
+- #1837782 by drunken monkey: Fixed enabling of indexes through the Status tab.
+- #1382170 by orakili, lliss, drunken monkey: Added OR filtering for Views
+  option filter.
+- #2012706 by drunken monkey: Fixed $reset parameter for load functions.
+- #1851204 by mvc: Fixed exception when indexing book hierarchy.
+- #1926030 by stella: Added field machine name to indexes' "Fields" tabs.
+- #1879102 by fearlsgroove: Fixed Drush attempting to index 0 items.
+- #1999858 by drunken monkey: Cleaned up API documentation for data alterations.
+- #2010116 by drunken monkey: Enabled "Index items immediately" for the default
+  node index.
+- #2013581 by drunken monkey: Added multi-valued field to test module.
+- #1288724 by brunodbo, drunken monkey, fearlsgroove: Added option for using OR
+  in Views fulltext search.
+- #1694832 by drunken monkey: Fixed index field settings getting stale when
+  Field API field settings change.
+- #1285794 by drunken monkey: Fixed "All" option in Views' exposed "Items per
+  page" setting.
+
+Search API 1.6 (2013-05-29):
+----------------------------
+- #1649976 by Berdir, ilari.stenroth, drunken monkey: Fixed memory error during
+  crons run for large indexes.
+- #1346276 by drunken monkey: Fixed Tokenizer should only run on fulltext
+  fields.
+- #1697246 by drunken monkey: Added 'Parse mode' option to views.
+- #1993536 by drunken monkey, jpieck: Fixed handling of empty values in
+  processors.
+- #1992228 by drunken monkey: Fixed current search block for empty keys.
+- #1696434 by orakili, ldweeks, drunken monkey: Added Views argument handler for
+  all indexed taxonomy term fields.
+- #1988238 by esbenvb, drunken monkey: Fixed Views result display for deleted
+  entities.
+- #872912 by drunken monkey: Expanded and fixed test cases.
+- #1760706 by jgraham, das-peter, drunken monkey: Added a flexible way for
+  determining whether an index contains entities.
+
+Search API 1.5 (2013-05-04):
+----------------------------
+- #1169254 by cslavoie, drunken monkey, DYdave: Added transliteration processor.
+- #1959088 by drunken monkey: Fixed titles for contextual filters.
+- #1792296 by andrewbelcher, drunken monkey: Added a group for Search API hooks.
+- #1407844 by nbucknor: Added "exclude" option for Views contextual filters.
+- #1278942 by Simon Georges, drunken monkey: Added an option to apply
+  entity_access() to Views results.
+- #1819412 by drunken monkey: Added clean way for retrieving an index's data
+  alterations and processors.
+- #1838134 by das-peter, drunken monkey: Added hook_search_api_items_indexed().
+- #1471310 by drunken monkey: Fixed handling of unset fields when indexing.
+- #1944394 by drunken monkey: Added caching to SearchApiIndex::getFields().
+- #1594762 by drunken monkey, alanom, esclapes: Fixed detection of deleted items
+  in the Hierarchy data alteration.
+- #1702604 by JvE, slucero: Added option for maximum date facet depth.
+
+Search API 1.4 (2013-01-09):
 ----------------------------
 - #1827272 by drunken monkey: Fixed regression introduced by #1777710.
 - #1807622 by drunken monkey: Fixed definition of the default node index.
@@ -11,7 +498,7 @@ Search API 1.4 (01/09/2013):
   filters.
 - #1823916 by aschiwi: Fixed batch_sise typos.
 
-Search API 1.3 (10/10/2012):
+Search API 1.3 (2012-10-10):
 ----------------------------
 - Patch by mr.baileys: Fixed "enable" function doesn't use security tokens.
 - #1318904 by becw, das-peter, orakili, drunken monkey: Added improved handling
@@ -27,7 +514,7 @@ Search API 1.3 (10/10/2012):
 - #1414138 by drunken monkey: Fixed internal static index property cache.
 - #1253320 by drunken monkey, fago: Fixed improper error handling.
 
-Search API 1.2 (07/07/2012):
+Search API 1.2 (2012-07-07):
 ----------------------------
 - #1368548 by das-peter: Do not index views results by entity id.
 - #1422750 by drunken monkey, sepgil: Fixed illegal modification of entity
@@ -40,7 +527,7 @@ Search API 1.2 (07/07/2012):
   changed.
 - #1528436 by jsacksick, drunken monkey: Fixed handling of exportable entities.
 
-Search API 1.1 (05/23/2012):
+Search API 1.1 (2012-05-23):
 ----------------------------
 - Fixed escaping of error messages.
 - #1330506 by drunken monkey: Removed the old Facets module.
@@ -51,7 +538,7 @@ Search API 1.1 (05/23/2012):
   $service->configurationFormValidate() for empty forms.
 - #1400882 by mh86: Fixed "Index hierarchy" for "All parents".
 
-Search API 1.0 (12/15/2011):
+Search API 1.0 (2011-12-15):
 ----------------------------
 - #1350322 by drunken monkey: Fixed regressions introduced with cron queue
   indexing.
@@ -69,7 +556,7 @@ Search API 1.0 (12/15/2011):
   dependency plugin.
 - #1337292 by drunken monkey: Fixed facet dependency system.
 
-Search API 1.0, RC 1 (11/10/2011):
+Search API 1.0, RC 1 (2011-11-10):
 ----------------------------------
 API changes:
 - #1260834 by drunken monkey: Added a way to define custom data types.
@@ -140,7 +627,7 @@ Others:
 - #1161532 by drunken monkey: Fixed discerning between delete and revert in
   hook_*_delete().
 
-Search API 1.0, Beta 10 (06/20/2011):
+Search API 1.0, Beta 10 (2011-06-20):
 -------------------------------------
 API changes:
 - #1068342 by drunken monkey: Added a 'fields to run on' option for processors.
@@ -154,7 +641,7 @@ Others:
 - #1133864 by agentrickard, awolfey, greg.1.anderson, drunken monkey: Added
   Drush integration.
 
-Search API 1.0, Beta 9 (06/06/2011):
+Search API 1.0, Beta 9 (2011-06-06):
 ------------------------------------
 API changes:
 - #1089758 by becw, drunken monkey: Updated Views field handlers to utilize new
@@ -196,7 +683,7 @@ Others:
 - #1120850 by drunken monkey, fangel: Fixed type of related entities in nested
   lists.
 
-Search API 1.0, Beta 8 (04/02/2011):
+Search API 1.0, Beta 8 (2011-04-02):
 ------------------------------------
 API changes:
 - #1012878 by drunken monkey: Added a way to index an entity directly.
@@ -215,12 +702,12 @@ Others:
   search_api_facets_by_block_status().
 - #1081666 by danielnolde: Fixed PHP notices when property labels are missing.
 
-Search API 1.0, Beta 7 (03/08/2011):
+Search API 1.0, Beta 7 (2011-03-08):
 ------------------------------------
 - #1083828 by drunken monkey: Added documentation on indexing custom data.
 - #1081244 by drunken monkey: Fixed debug line still contained in DB backend.
 
-Search API 1.0, Beta 6 (03/04/2011):
+Search API 1.0, Beta 6 (2011-03-04):
 ------------------------------------
 API changes:
 - #1075810 by drunken monkey: Added API function for marking entities as dirty.
@@ -263,7 +750,7 @@ Others:
 - #1024514: Error when preprocessing muli-valued fulltext fields.
 - #1020372: CSS classes for facets.
 
-Search API 1.0, Beta 5 (01/05/2011):
+Search API 1.0, Beta 5 (2011-01-05):
 ------------------------------------
 API changes:
 - #917998: Enhance data alterations by making them objects.
@@ -285,7 +772,7 @@ Others:
 - #985324: Add "Current search" block.
 - #984174: Bug in Index::prepareProcessors() when processors have not been set.
 
-Search API 1.0, Beta 4 (11/29/2010):
+Search API 1.0, Beta 4 (2010-11-29):
 ------------------------------------
 API changes:
 - #976876: Move Solr module into its own project.
@@ -327,7 +814,7 @@ Others:
 - #938982: Not all SearchApiQuery options are passed.
 - #931066 by luke_b: HTTP timeout not set correctly.
 
-Search API 1.0, Beta 3 (09/30/2010):
+Search API 1.0, Beta 3 (2010-09-30):
 ------------------------------------
 - API mostly stable.
 - Five contrib modules exist:

+ 24 - 25
sites/all/modules/contrib/search/search_api/README.txt

@@ -31,9 +31,9 @@ Terms as used in this module.
   Sphinx or any other professional or simple indexing mechanism. Takes care of
   the details of all operations, especially indexing or searching content.
 - Server:
-  One specific place for indexing data, using a set service class. Can
-  e.g. be some tables in a database, a connection to a Solr server or other
-  external services, etc.
+  One specific place for indexing data, using a specific service class. For
+  example this could be some tables in a database, a connection to a Solr server
+  or other external services, etc.
 - Index:
   A configuration object for indexing data of a specific type. What and how data
   is indexed is determined by its settings. Also keeps track of which items
@@ -90,7 +90,7 @@ IMPORTANT: Access checks
   results are displayed – either by only indexing such items, or by filtering
   appropriately at search time.
   For search on general site content (item type "Node"), this is already
-  supported by the Search API. To enable this, go to the index's "Workflow" tab
+  supported by the Search API. To enable this, go to the index's "Filters" tab
   and activate the "Node access" data alteration. This will add the necessary
   field, "Node access information", to the index (which you have to leave as
   "indexed"). If both this field and "Published" are set to be indexed, access
@@ -105,8 +105,10 @@ IMPORTANT: Access checks
   specific search types, if available.
 
 As stated above, you will need at least one other module to use the Search API,
-namely one that defines a service class (e.g. search_api_db ("Database search"),
-provided with this module).
+namely one that defines a service class (e.g., search_api_db ("Database search")
+which can be found at [3]).
+
+[3] http://drupal.org/project/search_api_db
 
 - Creating a server
   (Configuration > Search API > Add server)
@@ -169,8 +171,8 @@ form at the bottom of the page. For instance, you might want to index the
 author's username to the indexed data of a node, and you need to add the "Body"
 entity to the node when you want to index the actual text it contains.
 
-- Index workflow
-  (Configuration > Search API > [Index name] > Workflow)
+- Indexing workflow
+  (Configuration > Search API > [Index name] > Filters)
 
 This page lets you customize how the created index works, and what metadata will
 be available, by selecting data alterations and processors (see the glossary for
@@ -221,9 +223,9 @@ Information for developers
  | For custom field types to be available for indexing, provide a
  | "property_type" key in hook_field_info(), and optionally a callback at the
  | "property_callbacks" key.
- | Both processes are explained in [1].
+ | Both processes are explained in [4].
  |
- | [1] http://drupal.org/node/1021466
+ | [4] http://drupal.org/node/1021466
 
 Apart from improving the module itself, developers can extend search
 capabilities provided by the Search API by providing implementations for one (or
@@ -231,7 +233,9 @@ several) of the following classes. Detailed documentation on the methods that
 need to be implemented are always available as doc comments in the respective
 interface definition (all found in their respective files in the includes/
 directory). The details for hooks can be looked up in the search_api.api.php
-file.
+file. Note that all hooks provided by the Search API use the "search_api" hook
+group. Therefore, implementations of the hook can be moved into a
+MODULE.search_api.inc file in your module's directory.
 For all interfaces there are handy base classes which can (but don't need to) be
 used to ease custom implementations, since they provide sensible generic
 implementations for many methods. They, too, should be documented well enough
@@ -255,7 +259,9 @@ service class.
 The central methods here are the indexItems() and the search() methods, which
 always have to be overridden manually. The configurationForm() method allows
 services to provide custom settings for the user.
-See the SearchApiDbService class for an example implementation.
+See the SearchApiDbService class provided by [5] for an example implementation.
+
+[5] http://drupal.org/project/search_api_db
 
 - Query class
   Interface: SearchApiQueryInterface
@@ -334,15 +340,6 @@ See the processors in includes/processor.inc for examples.
 Included components
 -------------------
 
-- Service classes
-
-  * Database search
-    A search server implementation that uses the normal database for indexing
-    data. It isn't very fast and the results might also be less accurate than
-    with third-party solutions like Solr, but it's very easy to set up and good
-    for smaller applications or testing.
-    See contrib/search_api_db/README.txt for details.
-
 - Data alterations
 
   * URL field
@@ -388,17 +385,19 @@ Included components
     Enables the admin to specify a stopwords file, the words contained in which
     will be filtered out of the text data indexed. This can be used to exclude
     too common words from indexing, for servers not supporting this natively.
+  * Stem words
+    Uses the PorterStemmer method to reduce words to stems. A search for
+    "garden" will return results for "gardening" and "garden," as will a search
+    for "gardening."
 
 - Additional modules
 
-  * Search pages
-    This module lets you create simple search pages for indexes.
   * Search views
-    This integrates the Search API with the Views module [1], enabling the user
+    This integrates the Search API with the Views module [6], enabling the user
     to create views which display search results from any Search API index.
   * Search facets
     For service classes supporting this feature (e.g. Solr search), this module
     automatically provides configurable facet blocks on pages that execute
     a search query.
 
-[1] http://drupal.org/project/views
+[6] http://drupal.org/project/views

+ 102 - 24
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/adapter.inc

@@ -61,6 +61,10 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
   public function initActiveFilters($query) {
     $search_id = $query->getOption('search id');
     $index_id = $this->info['instance'];
+    // Only act on queries from the right index.
+    if ($index_id != $query->getIndex()->machine_name) {
+      return;
+    }
     $facets = facetapi_get_enabled_facets($this->info['name']);
     $this->fields = array();
 
@@ -78,21 +82,21 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       // displayed.
       $facet_search_ids = isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array();
 
+      // Remember this search ID, if necessary.
+      $this->rememberSearchId($index_id, $search_id);
+
       if (array_search($search_id, $facet_search_ids) === FALSE) {
-        $search_ids = variable_get('search_api_facets_search_ids', array());
-        if (empty($search_ids[$index_id][$search_id])) {
-          // Remember this search ID.
-          $search_ids[$index_id][$search_id] = $search_id;
-          variable_set('search_api_facets_search_ids', $search_ids);
-        }
         if (!$default_true) {
-          continue; // We are only to show facets for explicitly named search ids.
+          // We are only to show facets for explicitly named search ids.
+          continue;
         }
       }
       elseif ($default_true) {
-        continue; // The 'facet_search_ids' in the settings are to be excluded.
+        // The 'facet_search_ids' in the settings are to be excluded.
+        continue;
       }
-      $active[$facet['name']] = $search_id;
+      $facet_key = $facet['name'] . '@' . $this->getSearcher();
+      $active[$facet_key] = $search_id;
       $this->fields[$facet['name']] = array(
         'field'             => $facet['field'],
         'limit'             => $options['hard_limit'],
@@ -103,13 +107,35 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
     }
   }
 
+  /**
+   * Adds a search ID to the list of known searches for an index.
+   *
+   * @param string $index_id
+   *   The machine name of the search index.
+   * @param string $search_id
+   *   The identifier of the executed search.
+   */
+  protected function rememberSearchId($index_id, $search_id) {
+    $search_ids = variable_get('search_api_facets_search_ids', array());
+    if (empty($search_ids[$index_id][$search_id])) {
+      $search_ids[$index_id][$search_id] = $search_id;
+      asort($search_ids[$index_id]);
+      variable_set('search_api_facets_search_ids', $search_ids);
+    }
+  }
+
   /**
    * Add the given facet to the query.
    */
   public function addFacet(array $facet, SearchApiQueryInterface $query) {
     if (isset($this->fields[$facet['name']])) {
       $options = &$query->getOptions();
-      $options['search_api_facets'][$facet['name']] = $this->fields[$facet['name']];
+      $facet_info = $this->fields[$facet['name']];
+      if (!empty($facet['query_options'])) {
+        // Let facet-specific query options override the set options.
+        $facet_info = $facet['query_options'] + $facet_info;
+      }
+      $options['search_api_facets'][$facet['name']] = $facet_info;
     }
   }
 
@@ -128,8 +154,10 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
    *   search_api_current_search(). Or NULL, if no match was found.
    */
   public function getCurrentSearch() {
+    // Even if this fails once, there might be a search query later in the page
+    // request. We therefore don't store anything in $this->current_search in
+    // case of failure, but just try again if the method is called again.
     if (!isset($this->current_search)) {
-      $this->current_search = FALSE;
       $index_id = $this->info['instance'];
       // There is currently no way to configure the "current search" block to
       // show on a per-searcher basis as we do with the facets. Therefore we
@@ -137,13 +165,13 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       // I suspect that http://drupal.org/node/593658 would help.
       // For now, just taking the first current search for this index. :-/
       foreach (search_api_current_search() as $search) {
-        list($query, $results) = $search;
+        list($query) = $search;
         if ($query->getIndex()->machine_name == $index_id) {
           $this->current_search = $search;
         }
       }
     }
-    return $this->current_search ? $this->current_search : NULL;
+    return $this->current_search;
   }
 
   /**
@@ -164,6 +192,12 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
    */
   public function getSearchKeys() {
     $search = $this->getCurrentSearch();
+
+    // If the search is empty then there's no reason to continue.
+    if (!$search) {
+      return NULL;
+    }
+
     $keys = $search[0]->getOriginalKeys();
     if (is_array($keys)) {
       // This will happen nearly never when displaying the search keys to the
@@ -172,16 +206,6 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       // properly.
       $keys = '[' . t('complex query') . ']';
     }
-    elseif (!$keys) {
-      // If a base path other than the current one is set, we assume that we
-      // shouldn't report on the current search. Highly hack-y, of course.
-      if ($search[0]->getOption('search_api_base_path', $_GET['q']) !== $_GET['q']) {
-        return NULL;
-      }
-      // Work-around since Facet API won't show the "Current search" block
-      // without keys.
-      $keys = '[' . t('all items') . ']';
-    }
     drupal_alter('search_api_facetapi_keys', $keys, $search[0]);
     return $keys;
   }
@@ -204,7 +228,6 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
    */
   public function settingsForm(&$form, &$form_state) {
     $facet = $form['#facetapi']['facet'];
-    $realm = $form['#facetapi']['realm'];
     $facet_settings = $this->getFacet($facet)->getSettings();
     $options = $facet_settings->settings;
     $search_ids = variable_get('search_api_facets_search_ids', array());
@@ -213,6 +236,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       $form['global']['default_true'] = array(
         '#type' => 'select',
         '#title' => t('Display for searches'),
+        '#prefix' => '<div class="facetapi-global-setting">',
         '#options' => array(
           TRUE => t('For all except the selected'),
           FALSE => t('Only for the selected'),
@@ -222,6 +246,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       $form['global']['facet_search_ids'] = array(
         '#type' => 'select',
         '#title' => t('Search IDs'),
+        '#suffix' => '</div>',
         '#options' => $search_ids,
         '#size' => min(4, count($search_ids)),
         '#multiple' => TRUE,
@@ -238,5 +263,58 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
         '#value' => array(),
       );
     }
+
+    // Add a granularity option to date query types.
+    if (isset($facet['query type']) && $facet['query type'] == 'date') {
+      $granularity_options = array(
+        FACETAPI_DATE_YEAR => t('Years'),
+        FACETAPI_DATE_MONTH => t('Months'),
+        FACETAPI_DATE_DAY => t('Days'),
+        FACETAPI_DATE_HOUR => t('Hours'),
+        FACETAPI_DATE_MINUTE => t('Minutes'),
+        FACETAPI_DATE_SECOND => t('Seconds'),
+      );
+
+      $form['global']['date_granularity'] = array(
+        '#type' => 'select',
+        '#title' => t('Granularity'),
+        '#description' => t('Determine the maximum drill-down level'),
+        '#prefix' => '<div class="facetapi-global-setting">',
+        '#suffix' => '</div>',
+        '#options' => $granularity_options,
+        '#default_value' => isset($options['date_granularity']) ? $options['date_granularity'] : FACETAPI_DATE_MINUTE,
+      );
+
+      // Date facets don't support the "OR" operator (for now).
+      $form['global']['operator']['#access'] = FALSE;
+
+      $default_value = FACETAPI_DATE_YEAR;
+      if (isset($options['date_granularity_min'])) {
+        $default_value = $options['date_granularity_min'];
+      }
+      $form['global']['date_granularity_min'] = array(
+        '#type' => 'select',
+        '#title' => t('Minimum granularity'),
+        '#description' => t('Determine the minimum drill-down level to start at'),
+        '#prefix' => '<div class="facetapi-global-setting">',
+        '#suffix' => '</div>',
+        '#options' => $granularity_options,
+        '#default_value' => $default_value,
+      );
+    }
+
+    // Add an "Exclude" option for terms.
+    if (!empty($facet['query types']) && in_array('term', $facet['query types'])) {
+      $form['global']['operator']['#weight'] = -2;
+      unset($form['global']['operator']['#suffix']);
+      $form['global']['exclude'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Exclude'),
+        '#description' => t('Make the search exclude selected facets, instead of restricting it to them.'),
+        '#suffix' => '</div>',
+        '#weight' => -1,
+        '#default_value' => !empty($options['exclude']),
+      );
+    }
   }
 }

+ 207 - 51
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc

@@ -37,6 +37,17 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
   public function execute($query) {
     // Return terms for this facet.
     $this->adapter->addFacet($this->facet, $query);
+
+    $settings = $this->adapter->getFacet($this->facet)->getSettings()->settings;
+
+    // First check if the facet is enabled for this search.
+    $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+    $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+    if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+      // Facet is not enabled for this search ID.
+      return;
+    }
+
     // Change limit to "unlimited" (-1).
     $options = &$query->getOptions();
     if (!empty($options['search_api_facets'][$this->facet['name']])) {
@@ -46,14 +57,145 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
     if ($active = $this->adapter->getActiveItems($this->facet)) {
       $item = end($active);
       $field = $this->facet['field'];
-      $regex = str_replace(array('^', '$'), '', FACETAPI_REGEX_DATE);
-      $filter = preg_replace_callback($regex, array($this, 'replaceDateString'), $item['value']);
-      $this->addFacetFilter($query, $field, $filter);
+      $filter = $this->createRangeFilter($item['value']);
+      if ($filter) {
+        $this->addFacetFilter($query, $field, $filter);
+      }
     }
   }
 
+  /**
+   * Rewrites the handler-specific date range syntax to the normal facet syntax.
+   *
+   * @param string $value
+   *   The user-facing facet value.
+   *
+   * @return string|null
+   *   A facet to add as a filter, in the format used internally in this module.
+   *   Or NULL if the raw facet in $value is not valid.
+   */
+  protected function createRangeFilter($value) {
+    // Ignore any filters passed directly from the server (range or missing).
+    if (!$value || $value == '!' || (!ctype_digit($value[0]) && preg_match('/^[\[(][^ ]+ TO [^ ]+[\])]$/', $value))) {
+      return $value ? $value : NULL;
+    }
+
+    // Parse into date parts.
+    $parts = $this->parseRangeFilter($value);
+
+    // Return NULL if the date parts are invalid or none were found.
+    if (empty($parts)) {
+      return NULL;
+    }
+
+    $date = new DateTime();
+    switch (count($parts)) {
+      case 1:
+        $date->setDate($parts[0], 1, 1);
+        $date->setTime(0, 0, 0);
+        $lower = $date->format('U');
+        $date->setDate($parts[0] + 1, 1, 1);
+        $date->setTime(0, 0, -1);
+        $upper = $date->format('U');
+        break;
+
+      case 2:
+        // Luckily, $month = 13 is treated as January of next year. (The same
+        // goes for all other parameters.) We use the inverse trick for the
+        // seconds of the upper bound, since that's inclusive and we want to
+        // stop at a second before the next segment starts.
+        $date->setDate($parts[0], $parts[1], 1);
+        $date->setTime(0, 0, 0);
+        $lower = $date->format('U');
+        $date->setDate($parts[0], $parts[1] + 1, 1);
+        $date->setTime(0, 0, -1);
+        $upper = $date->format('U');
+        break;
+
+      case 3:
+        $date->setDate($parts[0], $parts[1], $parts[2]);
+        $date->setTime(0, 0, 0);
+        $lower = $date->format('U');
+        $date->setDate($parts[0], $parts[1], $parts[2] + 1);
+        $date->setTime(0, 0, -1);
+        $upper = $date->format('U');
+        break;
+
+      case 4:
+        $date->setDate($parts[0], $parts[1], $parts[2]);
+        $date->setTime($parts[3], 0, 0);
+        $lower = $date->format('U');
+        $date->setTime($parts[3] + 1, 0, -1);
+        $upper = $date->format('U');
+        break;
+
+      case 5:
+        $date->setDate($parts[0], $parts[1], $parts[2]);
+        $date->setTime($parts[3], $parts[4], 0);
+        $lower = $date->format('U');
+        $date->setTime($parts[3], $parts[4] + 1, -1);
+        $upper = $date->format('U');
+        break;
+
+      case 6:
+        $date->setDate($parts[0], $parts[1], $parts[2]);
+        $date->setTime($parts[3], $parts[4], $parts[5]);
+        return $date->format('U');
+
+      default:
+        return $value;
+    }
+
+    return "[$lower TO $upper]";
+  }
+
+  /**
+   * Parses the date range filter value into parts.
+   *
+   * @param string $value
+   *   The user-facing facet value.
+   *
+   * @return int[]|null
+   *   An array of date parts, or NULL if an invalid value was provided.
+   */
+  protected static function parseRangeFilter($value) {
+    $parts = explode('-', $value);
+
+    foreach ($parts as $i => $part) {
+      // Invalidate if part is not an integer.
+      if ($part === '' || !is_numeric($part) || intval($part) != $part) {
+        return NULL;
+      }
+      $parts[$i] = (int) $part;
+      // Depending on the position, negative numbers or 0 are invalid.
+      switch ($i) {
+        case 0:
+          // Years can contain anything – negative values are unlikely, but
+          // technically possible.
+          break;
+        case 1:
+        case 2:
+          // Days and months have to be positive.
+          if ($part <= 0) {
+            return NULL;
+          }
+          break;
+        default:
+          // All others can be 0, but not negative.
+          if ($part < 0) {
+            return NULL;
+          }
+      }
+    }
+
+    return $parts;
+  }
+
   /**
    * Replacement callback for replacing ISO dates with timestamps.
+   *
+   * Not used anymore, but kept for backwards compatibility with potential
+   * subclasses.
    */
   public function replaceDateString($matches) {
     return strtotime($matches[0]);
@@ -68,25 +210,20 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
   public function build() {
     $facet = $this->adapter->getFacet($this->facet);
     $search_ids = drupal_static('search_api_facetapi_active_facets', array());
-    if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+    $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+    if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
       return array();
     }
-    $search_id = $search_ids[$facet['name']];
+    $search_id = $search_ids[$facet_key];
     $build = array();
     $search = search_api_current_search($search_id);
     $results = $search[1];
-    if (!$results['result count']) {
-      return array();
-    }
     // Gets total number of documents matched in search.
     $total = $results['result count'];
 
-    // Most of the code below is copied from search_facetapi's implementation of
-    // this method.
-
     // Executes query, iterates over results.
-    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) {
-      $values = $results['search_api_facets'][$this->facet['field']];
+    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+      $values = $results['search_api_facets'][$this->facet['name']];
       foreach ($values as $value) {
         if ($value['count']) {
           $filter = $value['filter'];
@@ -102,33 +239,46 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
             }
           }
           else {
-            $filter = substr($value['filter'], 1, -1);
-            $pos = strpos($filter, ' ');
-            if ($pos !== FALSE) {
-              $lower = facetapi_isodate(substr($filter, 0, $pos), FACETAPI_DATE_DAY);
-              $upper = facetapi_isodate(substr($filter, $pos + 1), FACETAPI_DATE_DAY);
-              $filter = '[' . $lower . ' TO ' . $upper . ']';
-            }
             $build[$filter]['#count'] = $value['count'];
           }
         }
       }
     }
 
+    $settings = $facet->getSettings()->settings;
+
+    // Get the finest level of detail we're allowed to drill down to.
+    $max_granularity = FACETAPI_DATE_MINUTE;
+    if (isset($settings['date_granularity'])) {
+      $max_granularity = $settings['date_granularity'];
+    }
+
+    // Get the coarsest level of detail we're allowed to start at.
+    $min_granularity = FACETAPI_DATE_YEAR;
+    if (isset($settings['date_granularity_min'])) {
+      $min_granularity = $settings['date_granularity_min'];
+    }
+
     // Gets active facets, starts building hierarchy.
-    $parent = $gap = NULL;
-    foreach ($this->adapter->getActiveItems($this->facet) as $value => $item) {
+    $parent = $granularity = NULL;
+    $active_items = $this->adapter->getActiveItems($this->facet);
+    foreach ($active_items as $value => $item) {
       // If the item is active, the count is the result set count.
       $build[$value] = array('#count' => $total);
 
-      // Gets next "gap" increment, minute being the lowest we can go.
-      if ($value[0] != '[' || $value[strlen($value) - 1] != ']' || !($pos = strpos($value, ' TO '))) {
+      // Gets next "gap" increment. Ignore any filters passed directly from the
+      // server (range or missing). We always create filters starting with a
+      // year.
+      $value = "$value";
+      if (!$value || !ctype_digit($value[0])) {
+        continue;
+      }
+
+      $granularity = search_api_facetapi_date_get_granularity($value);
+      if (!$granularity) {
         continue;
       }
-      $start = substr($value, 1, $pos);
-      $end = substr($value, $pos + 4, -1);
-      $date_gap = facetapi_get_date_gap($start, $end);
-      $gap = facetapi_get_next_date_gap($date_gap, FACETAPI_DATE_MINUTE);
+      $granularity = facetapi_get_next_date_gap($granularity, $max_granularity);
 
       // If there is a previous item, there is a parent, uses a reference so the
       // arrays are populated when they are updated.
@@ -140,6 +290,7 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
       // Stores the last value iterated over.
       $parent = $value;
     }
+
     if (empty($raw_values)) {
       return $build;
     }
@@ -149,38 +300,42 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
     $timestamps = array_keys($raw_values);
     if (NULL === $parent) {
       if (count($raw_values) > 1) {
-        $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps));
+        $granularity = facetapi_get_timestamp_gap(min($timestamps), max($timestamps), $max_granularity);
+        // Array of numbers used to determine whether the next gap is smaller than
+        // the minimum gap allowed in the drilldown.
+        $gap_numbers = array(
+          FACETAPI_DATE_YEAR => 6,
+          FACETAPI_DATE_MONTH => 5,
+          FACETAPI_DATE_DAY => 4,
+          FACETAPI_DATE_HOUR => 3,
+          FACETAPI_DATE_MINUTE => 2,
+          FACETAPI_DATE_SECOND => 1,
+        );
+        // Gets gap numbers for both the gap, minimum and maximum gap, checks if
+        // the gap is within the limit set by the $granularity parameters.
+        if ($gap_numbers[$granularity] < $gap_numbers[$max_granularity]) {
+          $granularity = $max_granularity;
+        }
+        if ($gap_numbers[$granularity] > $gap_numbers[$min_granularity]) {
+          $granularity = $min_granularity;
+        }
       }
       else {
-        $gap = FACETAPI_DATE_HOUR;
+        $granularity = $max_granularity;
       }
     }
 
-    // Converts all timestamps to dates in ISO 8601 format.
-    $dates = array();
-    foreach ($timestamps as $timestamp) {
-      $dates[$timestamp] = facetapi_isodate($timestamp, $gap);
-    }
-
-    // Treat each date as the range start and next date as the range end.
-    $range_end = array();
-    $previous = NULL;
-    foreach (array_unique($dates) as $date) {
-      if (NULL !== $previous) {
-        $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
-      }
-      $previous = $date;
-    }
-    $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
-
-    // Groups dates by the range they belong to, builds the $build array
-    // with the facet counts and formatted range values.
+    // Groups dates by the range they belong to, builds the $build array with
+    // the facet counts and formatted range values.
+    $format = search_api_facetapi_date_get_granularity_format($granularity);
     foreach ($raw_values as $value => $count) {
-      $new_value = '[' . $dates[$value] . ' TO ' . $range_end[$dates[$value]] . ']';
+      $new_value = date($format, $value);
       if (!isset($build[$new_value])) {
         $build[$new_value] = array('#count' => $count);
       }
-      else {
+      // Active items already have their value set because it's the current
+      // result count.
+      elseif (!isset($active_items[$new_value])) {
         $build[$new_value]['#count'] += $count;
       }
 
@@ -193,4 +348,5 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
 
     return $build;
   }
+
 }

+ 90 - 25
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc

@@ -30,53 +30,98 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
     // Return terms for this facet.
     $this->adapter->addFacet($this->facet, $query);
 
-    $settings = $this->adapter->getFacet($this->facet)->getSettings();
-    // Adds the operator parameter.
-    $operator = $settings->settings['operator'];
+    $settings = $this->getSettings()->settings;
+
+    // First check if the facet is enabled for this search.
+    $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+    $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+    if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+      // Facet is not enabled for this search ID.
+      return;
+    }
 
-    // Add active facet filters.
+    // Retrieve the active facet filters.
     $active = $this->adapter->getActiveItems($this->facet);
     if (empty($active)) {
       return;
     }
 
-    if (FACETAPI_OPERATOR_OR == $operator) {
-      // If we're dealing with an OR facet, we need to use a nested filter.
-      $facet_filter = $query->createFilter('OR');
+    // Create the facet filter, and add a tag to it so that it can be easily
+    // identified down the line by services when they need to exclude facets.
+    $operator = $settings['operator'];
+    if ($operator == FACETAPI_OPERATOR_AND) {
+      $conjunction = 'AND';
+    }
+    elseif ($operator == FACETAPI_OPERATOR_OR) {
+      $conjunction = 'OR';
+      // When the operator is OR, remove parent terms from the active ones if
+      // children are active. If we don't do this, sending a term and its
+      // parent will produce the same results as just sending the parent.
+      if (is_callable($this->facet['hierarchy callback']) && !$settings['flatten']) {
+        // Check the filters in reverse order, to avoid checking parents that
+        // will afterwards be removed anyways.
+        $values = array_keys($active);
+        $parents = call_user_func($this->facet['hierarchy callback'], $values);
+        foreach (array_reverse($values) as $filter) {
+          // Skip this filter if it was already removed, or if it is the
+          // "missing value" filter ("!").
+          if (!isset($active[$filter]) || !is_numeric($filter)) {
+            continue;
+          }
+          // Go through the entire hierarchy of the value and remove all its
+          // ancestors.
+          while (!empty($parents[$filter])) {
+            $ancestor = array_shift($parents[$filter]);
+            if (isset($active[$ancestor])) {
+              unset($active[$ancestor]);
+              if (!empty($parents[$ancestor])) {
+                $parents[$filter] = array_merge($parents[$filter], $parents[$ancestor]);
+              }
+            }
+          }
+        }
+      }
     }
     else {
-      // Otherwise we set the conditions directly on the query.
-      $facet_filter = $query;
+      $vars = array(
+        '%operator' => $operator,
+        '%facet' => !empty($this->facet['label']) ? $this->facet['label'] : $this->facet['name'],
+      );
+      watchdog('search_api_facetapi', 'Unknown facet operator %operator used for facet %facet.', $vars, WATCHDOG_WARNING);
+      return;
     }
+    $tags = array('facet:' . $this->facet['field']);
+    $facet_filter = $query->createFilter($conjunction, $tags);
 
     foreach ($active as $filter => $filter_array) {
       $field = $this->facet['field'];
       $this->addFacetFilter($facet_filter, $field, $filter);
     }
 
-    // For OR facets, we now have to add the filter to the query.
-    if (FACETAPI_OPERATOR_OR == $operator) {
-      $query->filter($facet_filter);
-    }
+    // Now add the filter to the query.
+    $query->filter($facet_filter);
   }
 
   /**
    * Helper method for setting a facet filter on a query or query filter object.
    */
   protected function addFacetFilter($query_filter, $field, $filter) {
-    // Integer (or other nun-string) filters might mess up some of the following
+    // Test if this filter should be negated.
+    $settings = $this->adapter->getFacet($this->facet)->getSettings();
+    $exclude = !empty($settings->settings['exclude']);
+    // Integer (or other non-string) filters might mess up some of the following
     // comparison expressions.
     $filter = (string) $filter;
     if ($filter == '!') {
-      $query_filter->condition($field, NULL);
+      $query_filter->condition($field, NULL, $exclude ? '<>' : '=');
     }
-    elseif ($filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
+    elseif ($filter && $filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
       $lower = trim(substr($filter, 1, $pos));
       $upper = trim(substr($filter, $pos + 4, -1));
       if ($lower == '*' && $upper == '*') {
-        $query_filter->condition($field, NULL, '<>');
+        $query_filter->condition($field, NULL, $exclude ? '=' : '<>');
       }
-      else {
+      elseif (!$exclude) {
         if ($lower != '*') {
           // Iff we have a range with two finite boundaries, we set two
           // conditions (larger than the lower bound and less than the upper
@@ -92,9 +137,22 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
           $query_filter->condition($field, $upper, '<=');
         }
       }
+      else {
+        // Same as above, but with inverted logic.
+        if ($lower != '*') {
+          if ($upper != '*' && ($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
+            $original_query_filter = $query_filter;
+            $query_filter = new SearchApiQueryFilter('OR');
+          }
+          $query_filter->condition($field, $lower, '<');
+        }
+        if ($upper != '*') {
+          $query_filter->condition($field, $upper, '>');
+        }
+      }
     }
     else {
-      $query_filter->condition($field, $filter);
+      $query_filter->condition($field, $filter, $exclude ? '<>' : '=');
     }
     if (isset($original_query_filter)) {
       $original_query_filter->filter($query_filter);
@@ -113,15 +171,22 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
     // initActiveFilters) so that we can retrieve it here and get the correct
     // current search for this facet.
     $search_ids = drupal_static('search_api_facetapi_active_facets', array());
-    if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+    $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+    if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
       return array();
     }
-    $search_id = $search_ids[$facet['name']];
-    $search = search_api_current_search($search_id);
+    $search_id = $search_ids[$facet_key];
+    list(, $results) = search_api_current_search($search_id);
     $build = array();
-    $results = $search[1];
-    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) {
-      $values = $results['search_api_facets'][$this->facet['field']];
+
+    // Always include the active facet items.
+    foreach ($this->adapter->getActiveItems($this->facet) as $filter)  {
+      $build[$filter['value']]['#count'] = 0;
+    }
+
+    // Then, add the facets returned by the server.
+    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+      $values = $results['search_api_facets'][$this->facet['name']];
       foreach ($values as $value) {
         $filter = $value['filter'];
         // As Facet API isn't really suited for our native facet filter

+ 6 - 7
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.info

@@ -1,7 +1,7 @@
-name = Search facets
+name = Search Facets
 description = "Integrate the Search API with the Facet API to provide facetted searches."
-dependencies[] = search_api
-dependencies[] = facetapi
+dependencies[] = search_api:search_api
+dependencies[] = facetapi:facetapi
 core = 7.x
 package = Search
 
@@ -9,9 +9,8 @@ files[] = plugins/facetapi/adapter.inc
 files[] = plugins/facetapi/query_type_term.inc
 files[] = plugins/facetapi/query_type_date.inc
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by Drupal.org packaging script on 2019-03-11
+version = "7.x-1.26"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
-
+datestamp = "1552334832"

+ 33 - 1
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.install

@@ -5,9 +5,41 @@
  * Install, update and uninstall functions for the Search facets module.
  */
 
+/**
+ * Implements hook_install().
+ */
+function search_api_facetapi_install() {
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_YEAR, 'Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MONTH, 'F Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_DAY, 'F j, Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_HOUR, 'H:__');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MINUTE, 'H:i');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_SECOND, 'H:i:S');
+}
+
 /**
  * Implements hook_uninstall().
  */
 function search_api_facetapi_uninstall() {
   variable_del('search_api_facets_search_ids');
-}
+  // We have to use the literal values here, as the Facet API module could have
+  // already been disabled at this point.
+  variable_del('date_format_search_api_facetapi_YEAR');
+  variable_del('date_format_search_api_facetapi_MONTH');
+  variable_del('date_format_search_api_facetapi_DAY');
+  variable_del('date_format_search_api_facetapi_HOUR');
+  variable_del('date_format_search_api_facetapi_MINUTE');
+  variable_del('date_format_search_api_facetapi_SECOND');
+}
+
+/**
+ * Set up date formats.
+ */
+function search_api_facetapi_update_7101() {
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_YEAR, 'Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MONTH, 'F Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_DAY, 'F j, Y');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_HOUR, 'H:__');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MINUTE, 'H:i');
+  variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_SECOND, 'H:i:S');
+}

+ 268 - 18
sites/all/modules/contrib/search/search_api/contrib/search_api_facetapi/search_api_facetapi.module

@@ -53,7 +53,7 @@ function search_api_facetapi_facetapi_searcher_info() {
   $info = array();
   $indexes = search_api_index_load_multiple(FALSE);
   foreach ($indexes as $index) {
-    if ($index->enabled && $index->server()->supportsFeature('search_api_facets')) {
+    if (_search_api_facetapi_index_support_feature($index)) {
       $searcher_name = 'search_api@' . $index->machine_name;
       $info[$searcher_name] = array(
         'label' => t('Search service: @name', array('@name' => $index->name)),
@@ -65,6 +65,9 @@ function search_api_facetapi_facetapi_searcher_info() {
         'supports facet mincount' => TRUE,
         'include default facets' => FALSE,
       );
+      if (($entity_type = $index->getEntityType()) && $entity_type !== $index->item_type) {
+        $info[$searcher_name]['types'][] = $entity_type;
+      }
     }
   }
   return $info;
@@ -80,7 +83,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
     if (!empty($index->options['fields'])) {
       $wrapper = $index->entityWrapper();
       $bundle_key = NULL;
-      if (($entity_info = entity_get_info($index->item_type)) && !empty($entity_info['bundle keys']['bundle'])) {
+      if ($index->getEntityType() && ($entity_info = entity_get_info($index->getEntityType())) && !empty($entity_info['bundle keys']['bundle'])) {
         $bundle_key = $entity_info['bundle keys']['bundle'];
       }
 
@@ -89,12 +92,12 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
       // other modules.
       $type_settings = array(
         'taxonomy_term' => array(
-          'hierarchy callback' => 'facetapi_get_taxonomy_hierarchy',
+          'hierarchy callback' => 'search_api_facetapi_get_taxonomy_hierarchy',
         ),
         'date' => array(
           'query type' => 'date',
           'map options' => array(
-            'map callback' => 'facetapi_map_date',
+            'map callback' => 'search_api_facetapi_map_date',
           ),
         ),
       );
@@ -113,7 +116,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
           'description' => t('Filter by @type.', array('@type' => $field['name'])),
           'allowed operators' => array(
             FACETAPI_OPERATOR_AND => TRUE,
-            FACETAPI_OPERATOR_OR => $index->server()->supportsFeature('search_api_facets_operator_or'),
+            FACETAPI_OPERATOR_OR => _search_api_facetapi_index_support_feature($index, 'search_api_facets_operator_or'),
           ),
           'dependency plugins' => array('role'),
           'facet missing allowed' => TRUE,
@@ -144,7 +147,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
         if ($bundle_key) {
           if ($key === $bundle_key) {
             // Set entity type this field contains bundle information for.
-            $facet_info[$key]['field api bundles'][] = $index->item_type;
+            $facet_info[$key]['field api bundles'][] = $index->getEntityType();
           }
           else {
             // Add "bundle" as possible dependency plugin.
@@ -208,6 +211,58 @@ function search_api_facetapi_search_api_query_alter($query) {
   }
 }
 
+/**
+ * Implements hook_date_formats().
+ */
+function search_api_facetapi_date_formats() {
+  return array(
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_YEAR,
+      'format' => 'Y',
+      'locales' => array(),
+    ),
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_MONTH,
+      'format' => 'F Y',
+      'locales' => array(),
+    ),
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_DAY,
+      'format' => 'F j, Y',
+      'locales' => array(),
+    ),
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_HOUR,
+      'format' => 'H:__',
+      'locales' => array(),
+    ),
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_MINUTE,
+      'format' => 'H:i',
+      'locales' => array(),
+    ),
+    array(
+      'type' => 'search_api_facetapi_' . FACETAPI_DATE_SECOND,
+      'format' => 'H:i:s',
+      'locales' => array(),
+    ),
+  );
+}
+
+/**
+ * Implements hook_date_format_types().
+ */
+function search_api_facetapi_date_format_types() {
+  return array(
+    'search_api_facetapi_' . FACETAPI_DATE_YEAR => t('Search facets - Years'),
+    'search_api_facetapi_' . FACETAPI_DATE_MONTH => t('Search facets - Months'),
+    'search_api_facetapi_' . FACETAPI_DATE_DAY => t('Search facets - Days'),
+    'search_api_facetapi_' . FACETAPI_DATE_HOUR => t('Search facets - Hours'),
+    'search_api_facetapi_' . FACETAPI_DATE_MINUTE => t('Search facets - Minutes'),
+    'search_api_facetapi_' . FACETAPI_DATE_SECOND => t('Search facets - Seconds'),
+  );
+}
+
 /**
  * Menu callback for the facet settings page.
  */
@@ -215,7 +270,7 @@ function search_api_facetapi_settings($realm_name, SearchApiIndex $index) {
   if (!$index->enabled) {
     return array('#markup' => t('Since this index is at the moment disabled, no facets can be activated.'));
   }
-  if (!$index->server()->supportsFeature('search_api_facets')) {
+  if (!_search_api_facetapi_index_support_feature($index)) {
     return array('#markup' => t('This index uses a server that does not support facet functionality.'));
   }
   $searcher_name = 'search_api@' . $index->machine_name;
@@ -223,6 +278,48 @@ function search_api_facetapi_settings($realm_name, SearchApiIndex $index) {
   return drupal_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name);
 }
 
+/**
+ * Checks whether a certain feature is supported for an index.
+ *
+ * @param SearchApiIndex $index
+ *   The search index which should be checked.
+ * @param string $feature
+ *   (optional) The feature to check for. Defaults to "search_api_facets".
+ *
+ * @return bool
+ *   TRUE if the feature is supported by the index's server (and the index is
+ *   currently enabled), FALSE otherwise.
+ */
+function _search_api_facetapi_index_support_feature(SearchApiIndex $index, $feature = 'search_api_facets') {
+  try {
+    $server = $index->server();
+    return $server && $server->supportsFeature($feature);
+  }
+  catch (SearchApiException $e) {
+    return FALSE;
+  }
+}
+
+/**
+ * Gets hierarchy information for taxonomy terms.
+ *
+ * Used as a hierarchy callback in search_api_facetapi_facetapi_facet_info().
+ *
+ * Internally just uses facetapi_get_taxonomy_hierarchy(), but makes sure that
+ * our special "!" value is not passed.
+ *
+ * @param array $values
+ *   An array containing the term IDs.
+ *
+ * @return array
+ *   An associative array mapping term IDs to parent IDs (where parents could be
+ *   found).
+ */
+function search_api_facetapi_get_taxonomy_hierarchy(array $values) {
+  $values = array_filter($values, 'is_numeric');
+  return $values ? facetapi_get_taxonomy_hierarchy($values) : array();
+}
+
 /**
  * Map callback for all search_api facet fields.
  *
@@ -295,13 +392,13 @@ function search_api_facetapi_facet_map_callback(array $values, array $options =
     $lower = isset($map[$range['lower']]) ? $map[$range['lower']] : $range['lower'];
     $upper = isset($map[$range['upper']]) ? $map[$range['upper']] : $range['upper'];
     if ($lower == '*' && $upper == '*') {
-      $map[$value] =  t('any');
+      $map[$value] = t('any');
     }
     elseif ($lower == '*') {
-      $map[$value] = "< $upper";
+      $map[$value] = " $upper";
     }
     elseif ($upper == '*') {
-      $map[$value] = "> $lower";
+      $map[$value] = " $lower";
     }
     else {
       $map[$value] = "$lower – $upper";
@@ -313,25 +410,49 @@ function search_api_facetapi_facet_map_callback(array $values, array $options =
 
 /**
  * Creates a human-readable label for single facet filter values.
+ *
+ * @param array $values
+ *   The values for which labels should be returned.
+ * @param array $options
+ *   An associative array containing the following information about the facet:
+ *   - field: Field information, as stored in the index, but with an additional
+ *     "key" property set to the field's internal name.
+ *   - index id: The machine name of the index for this facet.
+ *   - map callback: (optional) A callback that will be called at the beginning,
+ *     which allows initial mapping of filters. Only values not mapped by that
+ *     callback will be processed by this method.
+ *   - value callback: A callback used to map single values and the limits of
+ *     ranges. The signature is the same as for this function, but all values
+ *     will be single values.
+ *   - missing label: (optional) The label used for the "missing" facet.
+ *
+ * @return array
+ *   An array mapping raw facet values to their labels.
  */
 function _search_api_facetapi_facet_create_label(array $values, array $options) {
   $field = $options['field'];
+  $map = array();
+  $n = count($values);
+
   // For entities, we can simply use the entity labels.
   if (isset($field['entity_type'])) {
     $type = $field['entity_type'];
     $entities = entity_load($type, $values);
-    $map = array();
     foreach ($entities as $id => $entity) {
       $label = entity_label($type, $entity);
-      if ($label) {
+      if ($label !== FALSE) {
         $map[$id] = $label;
       }
     }
-    return $map;
+    if (count($map) == $n) {
+      return $map;
+    }
   }
+
   // Then, we check whether there is an options list for the field.
   $index = search_api_index_load($options['index id']);
   $wrapper = $index->entityWrapper();
+  $values = drupal_map_assoc($values);
   foreach (explode(':', $field['key']) as $part) {
     if (!isset($wrapper->$part)) {
       $wrapper = NULL;
@@ -342,12 +463,18 @@ function _search_api_facetapi_facet_create_label(array $values, array $options)
       $wrapper = $wrapper[0];
     }
   }
-  if ($wrapper && ($options = $wrapper->optionsList('view'))) {
-    return $options;
+  if ($wrapper && ($options_list = $wrapper->optionsList('view'))) {
+    // We have no use for empty strings, as then the facet links would be
+    // invisible.
+    $map += array_intersect_key(array_filter($options_list, 'strlen'), $values);
+    if (count($map) == $n) {
+      return $map;
+    }
   }
-  // As a "last resort" we try to create a label based on the field type.
-  $map = array();
-  foreach ($values as $value) {
+
+  // As a "last resort" we try to create a label based on the field type, for
+  // all values that haven't got a mapping yet.
+  foreach (array_diff_key($values, $map) as $value) {
     switch ($field['type']) {
       case 'boolean':
         $map[$value] = $value ? t('true') : t('false');
@@ -379,3 +506,126 @@ function search_api_facetapi_search_api_admin_index_fields_submit($form, &$form_
   $cid = 'facetapi:facet_info:search_api@' . $form_state['index']->machine_name . ':';
   cache_clear_all($cid, 'cache', TRUE);
 }
+
+/**
+ * Computes the granularity of a date facet filter.
+ *
+ * @param $filter
+ *   The filter value to examine.
+ *
+ * @return string|null
+ *   Either one of the FACETAPI_DATE_* constants corresponding to the
+ *   granularity of the filter, or NULL if it couldn't be computed.
+ */
+function search_api_facetapi_date_get_granularity($filter) {
+  // Granularity corresponds to number of dashes in filter value.
+  $units = array(
+    FACETAPI_DATE_YEAR,
+    FACETAPI_DATE_MONTH,
+    FACETAPI_DATE_DAY,
+    FACETAPI_DATE_HOUR,
+    FACETAPI_DATE_MINUTE,
+    FACETAPI_DATE_SECOND,
+  );
+  $count = substr_count($filter, '-');
+  return isset($units[$count]) ? $units[$count] : NULL;
+}
+
+/**
+ * Returns the date format used for a given granularity.
+ *
+ * @param $granularity
+ *   One of the FACETAPI_DATE_* constants.
+ *
+ * @return string
+ *   The date format used for the given granularity.
+ */
+function search_api_facetapi_date_get_granularity_format($granularity) {
+  $formats = array(
+    FACETAPI_DATE_YEAR => 'Y',
+    FACETAPI_DATE_MONTH => 'Y-m',
+    FACETAPI_DATE_DAY => 'Y-m-d',
+    FACETAPI_DATE_HOUR => 'Y-m-d-H',
+    FACETAPI_DATE_MINUTE => 'Y-m-d-H-i',
+    FACETAPI_DATE_SECOND => 'Y-m-d-H-i-s',
+  );
+  return $formats[$granularity];
+}
+
+/**
+ * Constructs labels for date facet filter values.
+ *
+ * @param array $values
+ *   The date facet filter values, as used in URL parameters.
+ * @param array $options
+ *   (optional) Options for creating the mapping. The following options are
+ *   recognized:
+ *   - format callback: A callback for creating a label for a timestamp. The
+ *     function signature is like search_api_facetapi_format_timestamp(),
+ *     receiving a timestamp and one of the FACETAPI_DATE_* constants as the
+ *     parameters and returning a human-readable label.
+ *
+ * @return array
+ *   An array of labels for the given facet filters.
+ */
+function search_api_facetapi_map_date(array $values, array $options = array()) {
+  $map = array();
+  foreach ($values as $value) {
+    // Ignore any filters passed directly from the server (range or missing). We
+    // always create filters starting with a year.
+    $value = "$value";
+    if (!$value || !ctype_digit($value[0])) {
+      continue;
+    }
+
+    // Get the granularity of the filter.
+    $granularity = search_api_facetapi_date_get_granularity($value);
+    if (!$granularity) {
+      continue;
+    }
+
+    // Otherwise, parse the timestamp from the known format and format it as a
+    // label.
+    $format = search_api_facetapi_date_get_granularity_format($granularity);
+    // Use the "!" modifier to make the date parsing independent of the current
+    // date/time. (See #2678856.)
+    $date = DateTime::createFromFormat('!' . $format, $value);
+    if (!$date) {
+      continue;
+    }
+    $format_callback = 'search_api_facetapi_format_timestamp';
+    if (!empty($options['format callback']) && is_callable($options['format callback'])) {
+      $format_callback = $options['format callback'];
+    }
+    $map[$value] = call_user_func($format_callback, $date->format('U'), $granularity);
+  }
+  return $map;
+}
+
+/**
+ * Format a date according to the default timezone and the given precision.
+ *
+ * @param int $timestamp
+ *   An integer containing the Unix timestamp being formated.
+ * @param string $precision
+ *   A string containing the formatting precision. See the FACETAPI_DATE_*
+ *   constants for valid values.
+ *
+ * @return string
+ *   A human-readable representation of the timestamp.
+ */
+function search_api_facetapi_format_timestamp($timestamp, $precision = FACETAPI_DATE_YEAR) {
+  $formats = array(
+    FACETAPI_DATE_YEAR,
+    FACETAPI_DATE_MONTH,
+    FACETAPI_DATE_DAY,
+    FACETAPI_DATE_HOUR,
+    FACETAPI_DATE_MINUTE,
+    FACETAPI_DATE_SECOND,
+  );
+
+  if (!in_array($precision, $formats)) {
+    $precision = FACETAPI_DATE_YEAR;
+  }
+  return format_date($timestamp, 'search_api_facetapi_' . $precision);
+}

+ 76 - 2
sites/all/modules/contrib/search/search_api/contrib/search_api_views/README.txt

@@ -24,6 +24,37 @@ When these are present, the normal keywords should be ignored and the related
 items be returned as results instead. Sorting, filtering and range restriction
 should all work normally.
 
+"Random sort" feature
+---------------------
+This module defines the "Random sort" feature (feature key:
+"search_api_random_sort") that allows to randomly sort the results returned by a
+search. With a server supporting this, you can use the "Global: Random" sort to
+sort the view's results randomly. Every time the query is run a different
+sorting will be provided.
+
+For developers:
+A service class that wants to support this feature has to check for a
+"search_api_random" field in the search query's sorts and insert a random sort
+in that position. If the query is sorted in this way, then the
+"search_api_random_sort" query option can contain additional options for the
+random sort, as an associative array with any of the following keys:
+- seed: A numeric seed value to use for the random sort.
+
+"BETWEEN operator" feature
+--------------------------
+This module defines the "BETWEEN operator" feature (feature key:
+"search_api_between") that adds the "BETWEEN" and "NOT BETWEEN" filter
+operators to search queries. If your search server supports this feature, you
+can use the "Is between" and "Is not between" operators when adding Views
+filters for numeric, string or date types.
+
+For developers:
+A service class that wants to support this feature has to accept "BETWEEN" and
+"NOT BETWEEN" as additional $operator values in query conditions. The value in
+both cases is an array with the keys 0 and 1, with the value under key 0 being
+the lower and the value under key 1 being the upper bound for the range in which
+the field's value should ("BETWEEN") or should not ("NOT BETWEEN") be.
+
 "Facets block" display
 ----------------------
 Most features should be clear to users of Views. However, the module also
@@ -54,8 +85,7 @@ linked to for the filter to have an effect.
 Since the block will trigger a search on pages where it is set to appear, you
 can also enable additional „normal“ facet blocks for that search, via the
 „Facets“ tab for the index. They will automatically also point to the same
-search that you specified for the display. The Search ID of the „Facets blocks“
-display can easily be recognized by the "-facet_block" suffix.
+search that you specified for the display.
 If you want to use only the normal facets and not display anything at all in
 the Views block, just activate the display's „Hide block“ option.
 
@@ -63,6 +93,50 @@ Note: If you want to display the block not only on a few pages, you should in
 any case take care that it isn't displayed on the search page, since that might
 confuse users.
 
+Access features
+---------------
+Search views created with this module contain two query settings (located in
+the "Advanced" fieldset) which let you control the access checks executed for
+search results displayed in the view.
+
+- Bypass access checks
+This option allows you to deactivate access filters that would otherwise be
+added to the search, if the index supports this. This is, for instance, the case
+for indexes on the "Node" item type, when the "Node access" data alteration is
+activated.
+Use this either to slightly speed up searches where additional checks are
+unnecessary (e.g., because you already filter on "Node: Published") and there is
+no other node access mechanism on your site) or to show certain data that users
+normally wouldn't have access to (e.g., a list of all matching node titles,
+published or not).
+
+- Additional access checks on result entities
+When this option is activated, all result entities will be passed to an
+additional access check, even if search-time access checks are available for
+this index. The advantage is that access rules are guaranteed to be enforced –
+stale data in the index, which might make other access checks incorrect, won't
+influence this access check. You can also use it for item types for which no
+other access mechanisms are available.
+However, note that results filtered out this way will mess up paging, result
+counts and possibly other things too (like facet counts), as the result row is
+only hidden from display after the search has been executed. Where possible,
+you should therefore only use this in combination with appropriate filter
+settings ensuring that only when the index isn't up-to-date items will be
+filtered out this way.
+This option is only available for indexes on entity types.
+
+Other features
+--------------
+- Change parse mode
+You can determine how search keys entered by the user will be parsed by going to
+"Advanced" > "Query settings" within your View's settings. "Direct" can be
+useful, e.g., when you want to give users the full power of Solr. In other
+cases, "Multiple terms" is usually what you want / what users expect.
+Caution: For letting users use fulltext searches, always use the "Search:
+Fulltext search" filter or contextual filter – using a normal filter on a
+fulltext field won't parse the search keys, which means multiple words will only
+be found when they appear as that exact phrase.
+
 FAQ: Why „*Indexed* Node“?
 --------------------------
 The group name used for the search result itself (in fields, filters, etc.) is

+ 48 - 9
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/display_facet_block.inc

@@ -151,11 +151,9 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
     }
   }
 
-  public function execute() {
-    if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
-      form_set_error('', t('The "Facets block" display can only be used with base tables based on Search API indexes.'));
-      return NULL;
-    }
+  public function query() {
+    parent::query();
+
     $facet_field = $this->get_option('facet_field');
     if (!$facet_field) {
       return NULL;
@@ -165,7 +163,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
     if (!$base_path) {
       $base_path = $_GET['q'];
     }
-    $this->view->build();
+
     $limit = empty($this->view->query->pager->options['items_per_page']) ? 10 : $this->view->query->pager->options['items_per_page'];
     $query_options = &$this->view->query->getOptions();
     if (!$this->get_option('hide_block')) {
@@ -177,9 +175,19 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
         'min_count' => 1,
       );
     }
-    $query_options['search id'] = 'search_api_views:' . $this->view->name . '-facets_block';
     $query_options['search_api_base_path'] = $base_path;
     $this->view->query->range(0, 0);
+  }
+
+  public function render() {
+    if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
+      form_set_error('', t('The "Facets block" display can only be used with base tables based on Search API indexes.'));
+      return NULL;
+    }
+    $facet_field = $this->get_option('facet_field');
+    if (!$facet_field) {
+      return NULL;
+    }
 
     $this->view->execute();
 
@@ -230,7 +238,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
       // Initializes variables passed to theme hook.
       $variables = array(
         'text' => $name,
-        'path' => $base_path,
+        'path' => $this->view->query->getOption('search_api_base_path'),
         'count' => $term['count'],
         'options' => array(
           'attributes' => array('class' => 'facetapi-inactive'),
@@ -239,6 +247,31 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
         ),
       );
 
+      // Override the $variables['#path'] if facetapi_pretty_paths is enabled.
+      if (module_exists('facetapi_pretty_paths')) {
+        // Get the appropriate facet adapter.
+        $adapter = facetapi_adapter_load('search_api@' . $index->machine_name);
+
+        // Get the URL processor and check if it uses pretty paths.
+        $urlProcessor = $adapter->getUrlProcessor();
+        if ($urlProcessor instanceof FacetapiUrlProcessorPrettyPaths) {
+          // Retrieve the pretty path alias from the URL processor.
+          $facet = facetapi_facet_load($facet_field, 'search_api@' . $index->machine_name);
+          $values = array(trim($term['filter'], '"'));
+
+          // Get the pretty path for the facet and remove the current search's
+          // base path from it.
+          $base_path_current = $urlProcessor->getBasePath();
+          $pretty_path = $urlProcessor->getFacetPath($facet, $values, FALSE);
+          $pretty_path = str_replace($base_path_current, '', $pretty_path);
+
+          // Set the new, pretty path for the facet and remove the "f" query
+          // parameter.
+          $variables['path'] = $variables['path'] . $pretty_path;
+          unset($variables['options']['query']['f']);
+        }
+      }
+
       // Themes the link, adds row to facets.
       $facets[] = array(
         'class' => array('leaf'),
@@ -250,10 +283,16 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
       return NULL;
     }
 
-    $info['content']['facets'] = array(
+    return array(
+      'facets' => array(
       '#theme'  => 'item_list',
       '#items'  => $facets,
+      )
     );
+  }
+
+  public function execute() {
+    $info['content'] = $this->render();
     $info['content']['more'] = $this->render_more_link();
     $info['subject'] = filter_xss_admin($this->view->get_title());
     return $info;

+ 37 - 18
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerArgument.
+ */
+
 /**
  * Views argument handler class for handling all non-fulltext types.
  */
@@ -12,6 +17,17 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
    */
   public $query;
 
+  /**
+   * The operator to use for multiple arguments.
+   *
+   * Either "and" or "or".
+   *
+   * @var string
+   *
+   * @see views_break_phrase
+   */
+  public $operator;
+
   /**
    * Determine if the argument can generate a breadcrumb
    *
@@ -63,7 +79,8 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
   public function option_definition() {
     $options = parent::option_definition();
 
-    $options['break_phrase'] = array('default' => FALSE);
+    $options['break_phrase'] = array('default' => FALSE, 'bool' => TRUE);
+    $options['not'] = array('default' => FALSE, 'bool' => TRUE);
 
     return $options;
   }
@@ -79,6 +96,14 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
       '#default_value' => $this->options['break_phrase'],
       '#fieldset' => 'more',
     );
+
+    $form['not'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Exclude'),
+      '#description' => t('If selected, the numbers entered for the filter will be excluded rather than limiting the view.'),
+      '#default_value' => !empty($this->options['not']),
+      '#fieldset' => 'more',
+    );
   }
 
   /**
@@ -86,37 +111,31 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
    *
    * The argument sent may be found at $this->argument.
    */
-  // @todo Provide options to select the operator, instead of always using '='?
   public function query($group_by = FALSE) {
-    if (!empty($this->options['break_phrase'])) {
-      views_break_phrase($this->argument, $this);
-    }
-    else {
-      $this->value = array($this->argument);
+    if (empty($this->value)) {
+      if (!empty($this->options['break_phrase'])) {
+        views_break_phrase($this->argument, $this);
+      }
+      else {
+        $this->value = array($this->argument);
+      }
     }
 
+    $operator = empty($this->options['not']) ? '=' : '<>';
+
     if (count($this->value) > 1) {
       $filter = $this->query->createFilter(drupal_strtoupper($this->operator));
       // $filter will be NULL if there were errors in the query.
       if ($filter) {
         foreach ($this->value as $value) {
-          $filter->condition($this->real_field, $value, '=');
+          $filter->condition($this->real_field, $value, $operator);
         }
         $this->query->filter($filter);
       }
     }
     else {
-      $this->query->condition($this->real_field, reset($this->value));
+      $this->query->condition($this->real_field, reset($this->value), $operator);
     }
   }
 
-  /**
-   * Get the title this argument will assign the view, given the argument.
-   *
-   * This usually needs to be overridden to provide a proper title.
-   */
-  public function title() {
-    return t('Search @field for "@arg"', array('@field' => $this->definition['title'], '@arg' => $this->argument));
-  }
-
 }

+ 161 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_date.inc

@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiViewsHandlerArgumentDate class.
+ */
+
+/**
+ * Defines a contextual filter searching for a date or date range.
+ */
+class SearchApiViewsHandlerArgumentDate extends SearchApiViewsHandlerArgument {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query($group_by = FALSE) {
+    if (empty($this->value)) {
+      $this->fillValue();
+      if ($this->value === FALSE) {
+        $this->abort();
+        return;
+      }
+    }
+
+    $outer_conjunction = strtoupper($this->operator);
+
+    if (empty($this->options['not'])) {
+      $operator = '=';
+      $inner_conjunction = 'OR';
+    }
+    else {
+      $operator = '<>';
+      $inner_conjunction = 'AND';
+    }
+
+    if (!empty($this->value)) {
+      if (!empty($this->value)) {
+        $outer_filter = $this->query->createFilter($outer_conjunction);
+        foreach ($this->value as $value) {
+          $value_filter = $this->query->createFilter($inner_conjunction);
+          $values = explode(';', $value);
+          $values = array_map(array($this, 'getTimestamp'), $values);
+          if (in_array(FALSE, $values, TRUE)) {
+            $this->abort();
+            return;
+          }
+          $is_range = (count($values) > 1);
+
+          $inner_filter = ($is_range ? $this->query->createFilter('AND') : $value_filter);
+          $range_op = (empty($this->options['not']) ? '>=' : '<');
+          $inner_filter->condition($this->real_field, $values[0], $is_range ? $range_op : $operator);
+          if ($is_range) {
+            $range_op = (empty($this->options['not']) ? '<=' : '>');
+            $inner_filter->condition($this->real_field, $values[1], $range_op);
+            $value_filter->filter($inner_filter);
+          }
+          $outer_filter->filter($value_filter);
+        }
+
+        $this->query->filter($outer_filter);
+      }
+    }
+  }
+
+  /**
+   * Converts a value to a timestamp, if it isn't one already.
+   *
+   * @param string|int $value
+   *   The value to convert. Either a timestamp, or a date/time string as
+   *   recognized by strtotime().
+   *
+   * @return int|false
+   *   The parsed timestamp, or FALSE if an illegal string was passed.
+   */
+  public function getTimestamp($value) {
+    if (is_numeric($value)) {
+      return $value;
+    }
+
+    return strtotime($value);
+  }
+
+  /**
+   * Fills $this->value with data from the argument.
+   */
+  protected function fillValue() {
+    if (!empty($this->options['break_phrase'])) {
+      // Set up defaults:
+      if (!isset($this->value)) {
+        $this->value = array();
+      }
+
+      if (!isset($this->operator)) {
+        $this->operator = 'OR';
+      }
+
+      if (empty($this->argument)) {
+        return;
+      }
+
+      if (preg_match('/^([-\d;:\s]+\+)*[-\d;:\s]+$/', $this->argument)) {
+        // The '+' character in a query string may be parsed as ' '.
+        $this->value = explode('+', $this->argument);
+      }
+      elseif (preg_match('/^([-\d;:\s]+,)*[-\d;:\s]+$/', $this->argument)) {
+        $this->operator = 'AND';
+        $this->value = explode(',', $this->argument);
+      }
+
+      // Keep an 'error' value if invalid strings were given.
+      if (!empty($this->argument) && (empty($this->value) || !is_array($this->value))) {
+        $this->value = FALSE;
+      }
+    }
+    else {
+      $this->value = array($this->argument);
+    }
+  }
+
+  /**
+   * Aborts the associated query due to an illegal argument.
+   */
+  protected function abort() {
+    $variables['!field'] = $this->definition['group'] . ': ' . $this->definition['title'];
+    $this->query->abort(t('Illegal argument passed to !field contextual filter.', $variables));
+  }
+
+  /**
+   * Computes the title this argument will assign the view, given the argument.
+   *
+   * @return string
+   *   A title fitting for the passed argument.
+   */
+  public function title() {
+    if (!empty($this->argument)) {
+      if (empty($this->value)) {
+        $this->fillValue();
+      }
+      $dates = array();
+      foreach ($this->value as $date) {
+        $date_parts = explode(';', $date);
+
+        $ts = $this->getTimestamp($date_parts[0]);
+        $datestr = format_date($ts, 'short');
+        if (count($date_parts) > 1) {
+          $ts = $this->getTimestamp($date_parts[1]);
+          $datestr .= ' - ' . format_date($ts, 'short');
+        }
+
+        if ($datestr) {
+          $dates[] = $datestr;
+        }
+      }
+
+      return $dates ? implode(', ', $dates) : check_plain($this->argument);
+    }
+
+    return check_plain($this->argument);
+  }
+
+}

+ 30 - 2
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_fulltext.inc

@@ -1,9 +1,14 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerArgumentFulltext.
+ */
+
 /**
  * Views argument handler class for handling fulltext fields.
  */
-class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumentText {
+class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgument {
 
   /**
    * Specify the options this filter uses.
@@ -11,6 +16,7 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
   public function option_definition() {
     $options = parent::option_definition();
     $options['fields'] = array('default' => array());
+    $options['conjunction'] = array('default' => 'AND');
     return $options;
   }
 
@@ -20,6 +26,8 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
   public function options_form(&$form, &$form_state) {
     parent::options_form($form, $form_state);
 
+    $form['help']['#markup'] = t('Note: You can change how search keys are parsed under "Advanced" > "Query settings".');
+
     $fields = $this->getFulltextFields();
     if (!empty($fields)) {
       $form['fields'] = array(
@@ -31,6 +39,17 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
         '#multiple' => TRUE,
         '#default_value' => $this->options['fields'],
       );
+      $form['conjunction'] = array(
+        '#title' => t('Operator'),
+        '#description' => t('Determines how multiple keywords entered for the search will be combined.'),
+        '#type' => 'radios',
+        '#options' => array(
+          'AND' => t('Contains all of these words'),
+          'OR' => t('Contains any of these words'),
+        ),
+        '#default_value' => $this->options['conjunction'],
+      );
+
     }
     else {
       $form['fields'] = array(
@@ -47,7 +66,16 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
    */
   public function query($group_by = FALSE) {
     if ($this->options['fields']) {
-      $this->query->fields($this->options['fields']);
+      try {
+        $this->query->fields($this->options['fields']);
+      }
+      catch (SearchApiException $e) {
+        $this->query->abort($e->getMessage());
+        return;
+      }
+    }
+    if ($this->options['conjunction'] != 'AND') {
+      $this->query->setOption('conjunction', $this->options['conjunction']);
     }
 
     $old = $this->query->getOriginalKeys();

+ 52 - 17
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_more_like_this.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerArgumentMoreLikeThis.
+ */
+
 /**
  * Views argument handler providing a list of related items for search servers
  * supporting the "search_api_mlt" feature.
@@ -11,7 +16,7 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
    */
   public function option_definition() {
     $options = parent::option_definition();
-    unset($options['break_phrase']);
+    $options['entity_type'] = array('default' => FALSE);
     $options['fields'] = array('default' => array());
     return $options;
   }
@@ -22,8 +27,23 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
   public function options_form(&$form, &$form_state) {
     parent::options_form($form, $form_state);
     unset($form['break_phrase']);
+    unset($form['not']);
 
     $index = search_api_index_load(substr($this->table, 17));
+
+    if ($index->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+      $types = array_intersect_key(search_api_entity_type_options_list(), array_flip($index->options['datasource']['types']));
+      $form['entity_type'] = array(
+        '#type' => 'select',
+        '#title' => t('Entity type'),
+        '#description' => t('Select the entity type of the argument.'),
+        '#options' => $types,
+        '#default_value' => $this->options['entity_type'],
+        '#required' => TRUE,
+      );
+    }
+
+
     if (!empty($index->options['fields'])) {
       $fields = array();
       foreach ($index->getFields() as $key => $field) {
@@ -55,23 +75,38 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
    * The argument sent may be found at $this->argument.
    */
   public function query($group_by = FALSE) {
-    $server = $this->query->getIndex()->server();
-    if (!$server->supportsFeature('search_api_mlt')) {
-      $class = search_api_get_service_info($server->class);
-      throw new SearchApiException(t('The search service "@class" does not offer "More like this" functionality.',
-          array('@class' => $class['name'])));
-      return;
-    }
-    $fields = $this->options['fields'] ? $this->options['fields'] : array();
-    if (empty($fields)) {
-      foreach ($this->query->getIndex()->options['fields'] as $key => $field) {
-        $fields[] = $key;
+    try {
+      $server = $this->query->getIndex()->server();
+      if (!$server->supportsFeature('search_api_mlt')) {
+        $class = search_api_get_service_info($server->class);
+        watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.',
+          array('@class' => $class['name']), WATCHDOG_ERROR);
+        $this->query->abort();
+        return;
+      }
+      $index_fields = array_keys($this->query->getIndex()->options['fields']);
+      if (empty($this->options['fields'])) {
+        $fields = $index_fields;
+      }
+      else {
+        $fields = array_intersect($this->options['fields'], $index_fields);
+      }
+      if ($this->query->getIndex()->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+        $id = $this->options['entity_type'] . '/' . $this->argument;
+      }
+      else {
+        $id = $this->argument;
       }
+
+      $mlt = array(
+        'id' => $id,
+        'fields' => $fields,
+      );
+      $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
+    }
+    catch (SearchApiException $e) {
+      $this->query->abort($e->getMessage());
     }
-    $mlt = array(
-      'id' => $this->argument,
-      'fields' => $fields,
-    );
-    $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
   }
+
 }

+ 31 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_string.inc

@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiViewsHandlerArgumentString.
+ */
+
+/**
+ * Views argument handler class for handling string fields.
+ */
+class SearchApiViewsHandlerArgumentString extends SearchApiViewsHandlerArgument {
+
+  /**
+   * Set up the query for this argument.
+   *
+   * The argument sent may be found at $this->argument.
+   */
+  public function query($group_by = FALSE) {
+    if (empty($this->value)) {
+      if (!empty($this->options['break_phrase'])) {
+        views_break_phrase_string($this->argument, $this);
+      }
+      else {
+        $this->value = array($this->argument);
+      }
+    }
+
+    parent::query($group_by);
+  }
+
+}

+ 104 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_taxonomy_term.inc

@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * @file
+ *   Contains the SearchApiViewsHandlerArgumentTaxonomyTerm class.
+ */
+
+/**
+ * Defines a contextual filter searching through all indexed taxonomy fields.
+ */
+class SearchApiViewsHandlerArgumentTaxonomyTerm extends SearchApiViewsHandlerArgument {
+
+  /**
+   * Set up the query for this argument.
+   *
+   * The argument sent may be found at $this->argument.
+   */
+  public function query($group_by = FALSE) {
+    if (empty($this->value)) {
+      $this->fillValue();
+    }
+
+    $outer_conjunction = strtoupper($this->operator);
+
+    if (empty($this->options['not'])) {
+      $operator = '=';
+      $inner_conjunction = 'OR';
+    }
+    else {
+      $operator = '<>';
+      $inner_conjunction = 'AND';
+    }
+
+    if (!empty($this->value)) {
+      $terms = entity_load('taxonomy_term', $this->value);
+
+      if (!empty($terms)) {
+        $filter = $this->query->createFilter($outer_conjunction);
+        $vocabulary_fields = $this->definition['vocabulary_fields'];
+        $vocabulary_fields += array('' => array());
+        foreach ($terms as $term) {
+          $inner_filter = $filter;
+          if ($outer_conjunction != $inner_conjunction) {
+            $inner_filter = $this->query->createFilter($inner_conjunction);
+          }
+          // Set filters for all term reference fields which don't specify a
+          // vocabulary, as well as for all fields specifying the term's
+          // vocabulary.
+          if (!empty($this->definition['vocabulary_fields'][$term->vocabulary_machine_name])) {
+            foreach ($this->definition['vocabulary_fields'][$term->vocabulary_machine_name] as $field) {
+              $inner_filter->condition($field, $term->tid, $operator);
+            }
+          }
+          foreach ($vocabulary_fields[''] as $field) {
+            $inner_filter->condition($field, $term->tid, $operator);
+          }
+          if ($outer_conjunction != $inner_conjunction) {
+            $filter->filter($inner_filter);
+          }
+        }
+
+        $this->query->filter($filter);
+      }
+    }
+  }
+
+  /**
+   * Get the title this argument will assign the view, given the argument.
+   */
+  public function title() {
+    if (!empty($this->argument)) {
+      if (empty($this->value)) {
+        $this->fillValue();
+      }
+      $terms = array();
+      foreach ($this->value as $tid) {
+        $taxonomy_term = taxonomy_term_load($tid);
+        if ($taxonomy_term) {
+          $terms[] = check_plain($taxonomy_term->name);
+        }
+      }
+
+      return $terms ? implode(', ', $terms) : check_plain($this->argument);
+    }
+    else {
+      return check_plain($this->argument);
+    }
+  }
+
+  /**
+   * Fill $this->value with data from the argument.
+   *
+   * Uses views_break_phrase(), if appropriate.
+   */
+  protected function fillValue() {
+    if (!empty($this->options['break_phrase'])) {
+      views_break_phrase($this->argument, $this);
+    }
+    else {
+      $this->value = array($this->argument);
+    }
+  }
+
+}

+ 0 - 17
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_argument_text.inc

@@ -1,17 +0,0 @@
-<?php
-
-/**
- * Views argument handler class for handling fulltext fields.
- */
-class SearchApiViewsHandlerArgumentText extends SearchApiViewsHandlerArgument {
-
-  /**
-   * Get the title this argument will assign the view, given the argument.
-   *
-   * This usually needs to be overridden to provide a proper title.
-   */
-  public function title() {
-    return t('Search for "@arg"', array('@field' => $this->definition['title'], '@arg' => $this->argument));
-  }
-
-}

+ 22 - 8
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilter.
+ */
+
 /**
  * Views filter handler base class for handling all "normal" cases.
  */
@@ -31,8 +36,8 @@ class SearchApiViewsHandlerFilter extends views_handler_filter {
    */
   public function operator_options() {
     return array(
-      '<' => t('Is smaller than'),
-      '<=' => t('Is smaller than or equal to'),
+      '<' => t('Is less than'),
+      '<=' => t('Is less than or equal to'),
       '=' => t('Is equal to'),
       '<>' => t('Is not equal to'),
       '>=' => t('Is greater than or equal to'),
@@ -46,8 +51,8 @@ class SearchApiViewsHandlerFilter extends views_handler_filter {
    * Provide a form for setting the filter value.
    */
   public function value_form(&$form, &$form_state) {
-    while (is_array($this->value)) {
-      $this->value = $this->value ? array_shift($this->value) : NULL;
+    while (is_array($this->value) && count($this->value) < 2) {
+      $this->value = $this->value ? reset($this->value) : NULL;
     }
     $form['value'] = array(
       '#type' => 'textfield',
@@ -58,10 +63,19 @@ class SearchApiViewsHandlerFilter extends views_handler_filter {
 
     // Hide the value box if the operator is 'empty' or 'not empty'.
     // Radios share the same selector so we have to add some dummy selector.
-    $form['value']['#states']['visible'] = array(
-      ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
-      ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
-    );
+    if (empty($form_state['exposed'])) {
+      $form['value']['#states']['visible'] = array(
+        ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+        ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+      );
+    }
+    elseif (!empty($this->options['expose']['use_operator'])) {
+      $name = $this->options['expose']['operator_id'];
+      $form['value']['#states']['visible'] = array(
+        ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+        ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+      );
+    }
   }
 
   /**

+ 5 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_boolean.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterBoolean.
+ */
+
 /**
  * Views filter handler class for handling fulltext fields.
  */

+ 86 - 9
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_date.inc

@@ -1,9 +1,14 @@
 <?php
 
 /**
- * Views filter handler base class for handling all "normal" cases.
+ * @file
+ * Contains SearchApiViewsHandlerFilterDate.
  */
-class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
+
+/**
+ * Views filter handler base class for handling date fields.
+ */
+class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilterNumeric {
 
   /**
    * Add a "widget type" option.
@@ -11,6 +16,8 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
   public function option_definition() {
     return parent::option_definition() + array(
       'widget_type' => array('default' => 'default'),
+      'date_popup_format' => array('default' => 'm/d/Y'),
+      'year_range' => array('default' => '-3:+3'),
     );
   }
 
@@ -29,14 +36,49 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
    */
   public function extra_options_form(&$form, &$form_state) {
     parent::extra_options_form($form, $form_state);
+
     if (module_exists('date_popup')) {
-      $widget_options = array('default' => 'Default', 'date_popup' => 'Date popup');
+      $widget_options = array(
+        'default' => 'Default',
+        'date_popup' => 'Date popup',
+      );
       $form['widget_type'] = array(
         '#type' => 'radios',
         '#title' => t('Date selection form element'),
         '#default_value' => $this->options['widget_type'],
         '#options' => $widget_options,
       );
+      $form['date_popup_format'] = array(
+        '#type' => 'textfield',
+        '#title' => t('Date format'),
+        '#default_value' => $this->options['date_popup_format'],
+        '#description' => t('A date in any format understood by <a href="@doc-link">PHP</a>. For example, "Y-m-d" or "m/d/Y".', array(
+          '@doc-link' => 'http://php.net/manual/en/function.date.php'
+        )),
+        '#states' => array(
+          'visible' => array(
+            ':input[name="options[widget_type]"]' => array('value' => 'date_popup'),
+          ),
+        ),
+      );
+    }
+
+    if (module_exists('date_api')) {
+      $form['year_range'] = array(
+        '#type' => 'date_year_range',
+        '#default_value' => $this->options['year_range'],
+      );
+    }
+  }
+
+  /**
+   * Validate extra options.
+   */
+  public function extra_options_validate($form, &$form_state) {
+    if (isset($form_state['values']['options']['year_range'])) {
+      if (!preg_match('/^(?:\-[0-9]{1,4}|[0-9]{4}):(?:[\+|\-][0-9]{1,4}|[0-9]{4})$/', $form_state['values']['options']['year_range'])) {
+        form_error($form['year_range'], t('Date year range must be in the format -9:+9, 2005:2010, -9:2010, or 2005:+9'));
+      }
     }
   }
 
@@ -46,11 +88,25 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
   public function value_form(&$form, &$form_state) {
     parent::value_form($form, $form_state);
 
+    $is_date_popup = ($this->options['widget_type'] == 'date_popup' && module_exists('date_popup'));
+
+    // If the operator is between
+    if ($this->operator == 'between') {
+      if ($is_date_popup) {
+        $form['value']['min']['#type'] = 'date_popup';
+        $form['value']['min']['#date_format'] =  $this->options['date_popup_format'];
+        $form['value']['min']['#date_year_range'] = $this->options['year_range'];
+        $form['value']['max']['#type'] = 'date_popup';
+        $form['value']['max']['#date_format'] =  $this->options['date_popup_format'];
+        $form['value']['max']['#date_year_range'] = $this->options['year_range'];
+      }
+    }
     // If we are using the date popup widget, overwrite the settings of the form
     // according to what date_popup expects.
-    if ($this->options['widget_type'] == 'date_popup' && module_exists('date_popup')) {
+    elseif ($is_date_popup) {
       $form['value']['#type'] = 'date_popup';
-      $form['value']['#date_format'] = 'm/d/Y';
+      $form['value']['#date_format'] =  $this->options['date_popup_format'];
+      $form['value']['#date_year_range'] = $this->options['year_range'];
       unset($form['value']['#description']);
     }
     elseif (empty($form_state['exposed'])) {
@@ -66,17 +122,38 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
    * Add this filter to the query.
    */
   public function query() {
+    $this->normalizeValue();
+
     if ($this->operator === 'empty') {
       $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
     }
     elseif ($this->operator === 'not empty') {
       $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
     }
-    else {
-      while (is_array($this->value)) {
-        $this->value = $this->value ? reset($this->value) : NULL;
+    elseif (in_array($this->operator, array('between', 'not between'), TRUE)) {
+      $min = $this->value['min'];
+      if ($min !== '') {
+        $min = is_numeric($min) ? $min : strtotime($min, REQUEST_TIME);
+      }
+      $max = $this->value['max'];
+      if ($max !== '') {
+        $max = is_numeric($max) ? $max : strtotime($max, REQUEST_TIME);
       }
-      $v = is_numeric($this->value) ? $this->value : strtotime($this->value, REQUEST_TIME);
+
+      if (is_numeric($min) && is_numeric($max)) {
+        $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+      }
+      elseif (is_numeric($min)) {
+        $operator = $this->operator === 'between' ? '>=' : '<';
+        $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+      }
+      elseif (is_numeric($max)) {
+        $operator = $this->operator === 'between' ? '<=' : '>';
+        $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+      }
+    }
+    else {
+      $v = is_numeric($this->value['value']) ? $this->value['value'] : strtotime($this->value['value'], REQUEST_TIME);
       if ($v !== FALSE) {
         $this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
       }

+ 207 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_entity.inc

@@ -0,0 +1,207 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterEntity.
+ */
+
+/**
+ * Views filter handler class for entities.
+ *
+ * Should be extended for specific entity types, such as
+ * SearchApiViewsHandlerFilterUser and SearchApiViewsHandlerFilterTaxonomyTerm.
+ *
+ * Based on views_handler_filter_term_node_tid.
+ */
+abstract class SearchApiViewsHandlerFilterEntity extends SearchApiViewsHandlerFilter {
+
+  /**
+   * If exposed form input was successfully validated, the entered entity IDs.
+   *
+   * @var array
+   */
+  protected $validated_exposed_input;
+
+  /**
+   * Validates entered entity labels and converts them to entity IDs.
+   *
+   * Since this can come from either the form or the exposed filter, this is
+   * abstracted out a bit so it can handle the multiple input sources.
+   *
+   * @param array $form
+   *   The form or form element for which any errors should be set.
+   * @param array $values
+   *   The entered user names to validate.
+   *
+   * @return array
+   *   The entity IDs corresponding to all entities that could be found.
+   */
+  abstract protected function validate_entity_strings(array &$form, array $values);
+
+  /**
+   * Transforms an array of entity IDs into a comma-separated list of labels.
+   *
+   * @param array $ids
+   *   The entity IDs to transform.
+   *
+   * @return string
+   *   A string containing the labels corresponding to the IDs, separated by
+   *   commas.
+   */
+  abstract protected function ids_to_strings(array $ids);
+
+  /**
+   * {@inheritdoc}
+   */
+  public function operator_options() {
+    $operators = array(
+      '=' => $this->isMultiValued() ? t('Is one of') : t('Is'),
+      'all of' => t('Is all of'),
+      '<>' => $this->isMultiValued() ? t('Is not one of') : t('Is not'),
+      'empty' => t('Is empty'),
+      'not empty' => t('Is not empty'),
+    );
+    if (!$this->isMultiValued()) {
+      unset($operators['all of']);
+    }
+    return $operators;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function value_form(&$form, &$form_state) {
+    parent::value_form($form, $form_state);
+
+    if (!is_array($this->value)) {
+      $this->value = $this->value ? array($this->value) : array();
+    }
+
+    // Set the correct default value in case the admin-set value is used (and a
+    // value is present). The value is used if the form is either not exposed,
+    // or the exposed form wasn't submitted yet. (There doesn't seem to be an
+    // easier way to check for that.)
+    if ($this->value && (empty($form_state['input']) || !empty($form_state['input']['live_preview']))) {
+      $form['value']['#default_value'] = $this->ids_to_strings($this->value);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function value_validate($form, &$form_state) {
+    if (!empty($form['value'])) {
+      $value = &$form_state['values']['options']['value'];
+      if (strlen($value)) {
+        $values = $this->isMultiValued($form_state['values']['options']) ? drupal_explode_tags($value) : array($value);
+        $ids = $this->validate_entity_strings($form['value'], $values);
+
+        if ($ids) {
+          $value = $ids;
+        }
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function accept_exposed_input($input) {
+    $rc = parent::accept_exposed_input($input);
+
+    if ($rc) {
+      // If we have previously validated input, override.
+      if ($this->validated_exposed_input) {
+        $this->value = $this->validated_exposed_input;
+      }
+    }
+
+    return $rc;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exposed_validate(&$form, &$form_state) {
+    if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+      return;
+    }
+
+    $this->validated_exposed_input = FALSE;
+    $identifier = $this->options['expose']['identifier'];
+    $input = $form_state['values'][$identifier];
+
+    if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+      $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+      $input = $this->options['group_info']['group_items'][$input]['value'];
+    }
+
+    if (!strlen($input)) {
+      return;
+    }
+    $values = $this->isMultiValued() ? drupal_explode_tags($input) : array($input);
+
+    if (!$this->options['is_grouped'] || ($this->options['is_grouped'] && ($input != 'All'))) {
+      $this->validated_exposed_input = $this->validate_entity_strings($form[$identifier], $values);
+    }
+  }
+
+  /**
+   * Determines whether multiple user names can be entered into this filter.
+   *
+   * This is either the case if the form isn't exposed, or if the " Allow
+   * multiple selections" option is enabled.
+   *
+   * @param array $options
+   *   (optional) The options array to use. If not supplied, the options set on
+   *   this filter will be used.
+   *
+   * @return bool
+   *   TRUE if multiple values can be entered for this filter, FALSE otherwise.
+   */
+  protected function isMultiValued(array $options = array()) {
+    $options = $options ? $options : $this->options;
+    return empty($options['exposed']) || !empty($options['expose']['multiple']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function admin_summary() {
+    if (!is_array($this->value)) {
+      $this->value = $this->value ? array($this->value) : array();
+    }
+    $value = $this->value;
+    $this->value = empty($value) ? '' : $this->ids_to_strings($value);
+    $ret = parent::admin_summary();
+    $this->value = $value;
+    return $ret;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    if ($this->operator === 'empty') {
+      $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+    }
+    elseif ($this->operator === 'not empty') {
+      $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+    }
+    elseif (is_array($this->value)) {
+      $all_of = $this->operator === 'all of';
+      $operator = $all_of ? '=' : $this->operator;
+      if (count($this->value) == 1) {
+        $this->query->condition($this->real_field, reset($this->value), $operator, $this->options['group']);
+      }
+      else {
+        $filter = $this->query->createFilter($operator === '<>' || $all_of ? 'AND' : 'OR');
+        foreach ($this->value as $value) {
+          $filter->condition($this->real_field, $value, $operator);
+        }
+        $this->query->filter($filter, $this->options['group']);
+      }
+    }
+  }
+
+}

+ 146 - 12
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_fulltext.inc

@@ -1,17 +1,44 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterFulltext.
+ */
+
 /**
  * Views filter handler class for handling fulltext fields.
  */
 class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterText {
 
+  /**
+   * Displays the operator form, adding a description.
+   */
+  public function show_operator_form(&$form, &$form_state) {
+    $this->operator_form($form, $form_state);
+    $form['operator']['#description'] = t('This operator is only useful when using \'Search keys\'.');
+  }
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array(
+      'AND' => t('Contains all of these words'),
+      'OR' => t('Contains any of these words'),
+      'NOT' => t('Contains none of these words'),
+    );
+  }
+
   /**
    * Specify the options this filter uses.
    */
   public function option_definition() {
     $options = parent::option_definition();
 
+    $options['operator']['default'] = 'AND';
+
     $options['mode'] = array('default' => 'keys');
+    $options['min_length'] = array('default' => '');
     $options['fields'] = array('default' => array());
 
     return $options;
@@ -27,7 +54,7 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
       '#title' => t('Use as'),
       '#type' => 'radios',
       '#options' => array(
-        'keys' => t('Search keys – multiple words will be split and the filter will influence relevance.'),
+        'keys' => t('Search keys – multiple words will be split and the filter will influence relevance. You can change how search keys are parsed under "Advanced" > "Query settings".'),
         'filter' => t("Search filter – use as a single phrase that restricts the result set but doesn't influence relevance."),
       ),
       '#default_value' => $this->options['mode'],
@@ -54,6 +81,70 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
     if (isset($form['expose'])) {
       $form['expose']['#weight'] = -5;
     }
+
+    $form['min_length'] = array(
+      '#title' => t('Minimum keyword length'),
+      '#description' => t('Minimum length of each word in the search keys. Leave empty to allow all words.'),
+      '#type' => 'textfield',
+      '#element_validate' => array('element_validate_integer_positive'),
+      '#default_value' => $this->options['min_length'],
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exposed_validate(&$form, &$form_state) {
+    // Only validate exposed input.
+    if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+      return;
+    }
+
+    // We only need to validate if there is a minimum word length set.
+    if ($this->options['min_length'] < 2) {
+      return;
+    }
+
+    $identifier = $this->options['expose']['identifier'];
+    $input = &$form_state['values'][$identifier];
+
+    if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+      $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+      $input = &$this->options['group_info']['group_items'][$input]['value'];
+    }
+
+    // If there is no input, we're fine.
+    if (!trim($input)) {
+      return;
+    }
+
+    $words = preg_split('/\s+/', $input);
+    $quoted = FALSE;
+    foreach ($words as $i => $word) {
+      $word_length = drupal_strlen($word);
+      if (!$word_length) {
+        unset($words[$i]);
+        continue;
+      }
+      // Protect quoted strings.
+      if ($quoted && $word[strlen($word) - 1] === '"') {
+        $quoted = FALSE;
+        continue;
+      }
+      if ($quoted || $word[0] === '"') {
+        $quoted = TRUE;
+        continue;
+      }
+      if ($word_length < $this->options['min_length']) {
+        unset($words[$i]);
+      }
+    }
+    if (!$words) {
+      $vars['@count'] = $this->options['min_length'];
+      $msg = t('You must include at least one positive keyword with @count characters or more.', $vars);
+      form_error($form[$identifier], $msg);
+    }
+    $input = implode(' ', $words);
   }
 
   /**
@@ -68,7 +159,8 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
       return;
     }
     $fields = $this->options['fields'];
-    $fields = $fields ? $fields : array_keys($this->getFulltextFields());
+    $available_fields = array_keys($this->getFulltextFields());
+    $fields = $fields ? array_intersect($fields, $available_fields) : $available_fields;
 
     // If something already specifically set different fields, we silently fall
     // back to mere filtering.
@@ -80,17 +172,31 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
 
     if ($filter) {
       $filter = $this->query->createFilter('OR');
+      $op = $this->operator === 'NOT' ? '<>' : '=';
       foreach ($fields as $field) {
-        $filter->condition($field, $this->value, $this->operator);
+        $filter->condition($field, $this->value, $op);
       }
       $this->query->filter($filter);
       return;
     }
 
-    $this->query->fields($fields);
-    $old = $this->query->getOriginalKeys();
+    // If the operator was set to OR or NOT, set OR as the conjunction. (It is
+    // also set for NOT since otherwise it would be "not all of these words".)
+    if ($this->operator != 'AND') {
+      $this->query->setOption('conjunction', 'OR');
+    }
+
+    try {
+      $this->query->fields($fields);
+    }
+    catch (SearchApiException $e) {
+      $this->query->abort($e->getMessage());
+      return;
+    }
+    $old = $this->query->getKeys();
+    $old_original = $this->query->getOriginalKeys();
     $this->query->keys($this->value);
-    if ($this->operator != '=') {
+    if ($this->operator == 'NOT') {
       $keys = &$this->query->getKeys();
       if (is_array($keys)) {
         $keys['#negation'] = TRUE;
@@ -99,16 +205,44 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
         // We can't know how negation is expressed in the server's syntax.
       }
     }
+
+    // If there were fulltext keys set, we take care to combine them in a
+    // meaningful way (especially with negated keys).
     if ($old) {
       $keys = &$this->query->getKeys();
+      // Array-valued keys are combined.
       if (is_array($keys)) {
-        $keys[] = $old;
+        // If the old keys weren't parsed into an array, we instead have to
+        // combine the original keys.
+        if (is_scalar($old)) {
+          $keys = "($old) ({$this->value})";
+        }
+        else {
+          // If the conjunction or negation settings aren't the same, we have to
+          // nest both old and new keys array.
+          if (!empty($keys['#negation']) != !empty($old['#negation']) || $keys['#conjunction'] != $old['#conjunction']) {
+            $keys = array(
+              '#conjunction' => 'AND',
+              $old,
+              $keys,
+            );
+          }
+          // Otherwise, just add all individual words from the old keys to the
+          // new ones.
+          else {
+            foreach (element_children($old) as $i) {
+              $keys[] = $old[$i];
+            }
+          }
+        }
       }
-      elseif (is_array($old)) {
-        // We don't support such nonsense.
-      }
-      else {
-        $keys = "($old) ($keys)";
+      // If the parse mode was "direct" for both old and new keys, we
+      // concatenate them and set them both via method and reference (to also
+      // update the originalKeys property.
+      elseif (is_scalar($old_original)) {
+        $combined_keys = "($old_original) ($keys)";
+        $this->query->keys($combined_keys);
+        $keys = $combined_keys;
       }
     }
   }

+ 19 - 19
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_language.inc

@@ -14,26 +14,17 @@
 class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOptions {
 
   /**
-   * Provide a form for setting options.
+   * {@inheritdoc}
    */
-  public function value_form(&$form, &$form_state) {
-    parent::value_form($form, $form_state);
-    $form['value']['#options'] = array(
-      'current' => t("Current user's language"),
-      'default' => t('Default site language'),
-    ) + $form['value']['#options'];
-  }
-
-  /**
-   * Provides a summary of this filter's value for the admin UI.
-   */
-  public function admin_summary() {
-    $tmp = $this->definition['options'];
-    $this->definition['options']['current'] = t('current');
-    $this->definition['options']['default'] = t('default');
-    $ret = parent::admin_summary();
-    $this->definition['options'] = $tmp;
-    return $ret;
+  protected function get_value_options() {
+    parent::get_value_options();
+    $options = array();
+    if (module_exists('language_hierarchy')) {
+      $options['fallback'] = t("Current user's language with fallback");
+    }
+    $options['current'] = t("Current user's language");
+    $options['default'] = t('Default site language');
+    $this->value_options = $options + $this->value_options;
   }
 
   /**
@@ -41,6 +32,10 @@ class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOpt
    */
   public function query() {
     global $language_content;
+
+    if (!is_array($this->value)) {
+      $this->value = $this->value ? array($this->value) : array();
+    }
     foreach ($this->value as $i => $v) {
       if ($v == 'current') {
         $this->value[$i] = $language_content->language;
@@ -48,6 +43,11 @@ class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOpt
       elseif ($v == 'default') {
         $this->value[$i] = language_default('language');
       }
+      elseif ($v == 'fallback' && module_exists('language_hierarchy')) {
+        $fallbacks = array($language_content->language => $language_content->language);
+        $fallbacks += array_keys(language_hierarchy_get_ancestors($language_content->language));
+        $this->value[$i] = drupal_map_assoc($fallbacks);
+      }
     }
     parent::query();
   }

+ 248 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_numeric.inc

@@ -0,0 +1,248 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterNumeric.
+ */
+
+/**
+ * Views filter handler class for handling numeric and "string" fields.
+ */
+class SearchApiViewsHandlerFilterNumeric extends SearchApiViewsHandlerFilter {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function init(&$view, &$options) {
+    parent::init($view, $options);
+
+    $this->normalizeValue();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    $options['value'] = array(
+      'contains' => array(
+        'value' => array('default' => ''),
+        'min' => array('default' => ''),
+        'max' => array('default' => ''),
+      ),
+    );
+
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function operator_options() {
+    $operators = parent::operator_options();
+
+    $index = search_api_index_load(substr($this->table, 17));
+    $server = NULL;
+    try {
+      if ($index) {
+        $server = $index->server();
+      }
+    }
+    catch (SearchApiException $e) {
+      // Ignore.
+    }
+    if ($server && $server->supportsFeature('search_api_between')) {
+      $operators += array(
+        'between' => t('Is between'),
+        'not between' => t('Is not between'),
+      );
+    }
+
+    return $operators;
+  }
+
+  /**
+   * Provides a form for setting the filter value.
+   *
+   * Heavily borrowed from views_handler_filter_numeric.
+   *
+   * @see views_handler_filter_numeric::value_form()
+   */
+  public function value_form(&$form, &$form_state) {
+    $form['value']['#tree'] = TRUE;
+
+    $single_field_operators = $this->operator_options();
+    unset(
+      $single_field_operators['empty'],
+      $single_field_operators['not empty'],
+      $single_field_operators['between'],
+      $single_field_operators['not between']
+    );
+    $between_operators = array('between', 'not between');
+
+    // We have to make some choices when creating this as an exposed
+    // filter form. For example, if the operator is locked and thus
+    // not rendered, we can't render dependencies; instead we only
+    // render the form items we need.
+    $which = 'all';
+    $source = NULL;
+    if (!empty($form['operator'])) {
+      $source = ($form['operator']['#type'] == 'radios') ? 'radio:options[operator]' : 'edit-options-operator';
+    }
+
+    $identifier = NULL;
+    if (!empty($form_state['exposed'])) {
+      $identifier = $this->options['expose']['identifier'];
+      if (empty($this->options['expose']['use_operator']) || empty($this->options['expose']['operator_id'])) {
+        // Exposed and locked.
+        $which = in_array($this->operator, $between_operators) ? 'minmax' : 'value';
+      }
+      else {
+        $source = 'edit-' . drupal_html_id($this->options['expose']['operator_id']);
+      }
+    }
+
+    // Hide the value box if the operator is 'empty' or 'not empty'.
+    // Radios share the same selector so we have to add some dummy selector.
+    if ($which == 'all') {
+      $form['value']['value'] = array(
+        '#type' => 'textfield',
+        '#title' => empty($form_state['exposed']) ? t('Value') : '',
+        '#size' => 30,
+        '#default_value' => $this->value['value'],
+        '#dependency' => array($source => array_keys($single_field_operators)),
+      );
+      if ($identifier && !isset($form_state['input'][$identifier]['value'])) {
+        $form_state['input'][$identifier]['value'] = $this->value['value'];
+      }
+    }
+    elseif ($which == 'value') {
+      // When exposed we drop the value-value and just do value if
+      // the operator is locked.
+      $form['value'] = array(
+        '#type' => 'textfield',
+        '#title' => empty($form_state['exposed']) ? t('Value') : '',
+        '#size' => 30,
+        '#default_value' => isset($this->value['value']) ? $this->value['value'] : '',
+      );
+      if ($identifier && !isset($form_state['input'][$identifier])) {
+        $form_state['input'][$identifier] = isset($this->value['value']) ? $this->value['value'] : '';
+      }
+    }
+
+    if ($which == 'all' || $which == 'minmax') {
+      $form['value']['min'] = array(
+        '#type' => 'textfield',
+        '#title' => empty($form_state['exposed']) ? t('Min') : '',
+        '#size' => 30,
+        '#default_value' => $this->value['min'],
+      );
+      $form['value']['max'] = array(
+        '#type' => 'textfield',
+        '#title' => empty($form_state['exposed']) ? t('And max') : t('And'),
+        '#size' => 30,
+        '#default_value' => $this->value['max'],
+      );
+
+      if ($which == 'all') {
+        $form['value']['min']['#dependency'] = array($source => $between_operators);
+        $form['value']['max']['#dependency'] = array($source => $between_operators);
+      }
+
+      if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['min'])) {
+        $form_state['input'][$identifier]['min'] = $this->value['min'];
+      }
+      if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['max'])) {
+        $form_state['input'][$identifier]['max'] = $this->value['max'];
+      }
+
+      if (!isset($form['value']['value'])) {
+        // Ensure there is something in the 'value'.
+        $form['value']['value'] = array(
+          '#type' => 'value',
+          '#value' => NULL,
+        );
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function admin_summary() {
+    if (!empty($this->options['exposed'])) {
+      return t('exposed');
+    }
+
+    if ($this->operator === 'empty') {
+      return t('is empty');
+    }
+    if ($this->operator === 'not empty') {
+      return t('is not empty');
+    }
+
+    if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+      // This is of course wrong for translation purposes, but copied from
+      // views_handler_filter_numeric::admin_summary() so probably still better
+      // to re-use this than to do it correctly.
+      $operator = $this->operator === 'between' ? t('between') : t('not between');
+      $vars = array(
+        '@min' => (string) $this->value['min'],
+        '@max' => (string) $this->value['max'],
+      );
+      return $operator . ' ' . t('@min and @max', $vars);
+    }
+
+    return check_plain((string) $this->operator) . ' ' . check_plain((string) $this->value['value']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    $this->normalizeValue();
+
+    if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+      $min = $this->value['min'];
+      $max = $this->value['max'];
+      if ($min !== '' && $max !== '') {
+        $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+      }
+      elseif ($min !== '') {
+        $operator = $this->operator === 'between' ? '>=' : '<';
+        $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+      }
+      elseif ($max !== '') {
+        $operator = $this->operator === 'between' ? '<=' : '>';
+        $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+      }
+    }
+    else {
+      // The parent handler doesn't expect our value structure, just pass the
+      // scalar value instead.
+      $this->value = $this->value['value'];
+      parent::query();
+    }
+  }
+
+  /**
+   * Sets $this->value to an array of options as defined by the filter.
+   *
+   * @see SearchApiViewsHandlerFilterNumeric::option_definition()
+   */
+  protected function normalizeValue() {
+    $value = $this->value;
+    if (is_array($value) && isset($value[0])) {
+      $value = $value[0];
+    }
+    if (!is_array($value)) {
+      $value = array('value' => $value);
+    }
+    $this->value = array(
+      'value' => isset($value['value']) ? $value['value'] : '',
+      'min' => isset($value['min']) ? $value['min'] : '',
+      'max' => isset($value['max']) ? $value['max'] : '',
+    );
+  }
+
+}

+ 187 - 50
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_options.inc

@@ -1,26 +1,98 @@
 <?php
 
 /**
- * Views filter handler class for handling fields with a limited set of possible
- * values.
- *
- * Definition items:
- * - options: An array of possible values for this field.
+ * @file
+ * Contains the SearchApiViewsHandlerFilterOptions class.
+ */
+
+/**
+ * Views filter handler for fields with a limited set of possible values.
  */
 class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
 
+  /**
+   * Stores the values which are available on the form.
+   *
+   * @var array
+   */
+  protected $value_options = NULL;
+
+  /**
+   * The type of form element used to display the options.
+   *
+   * @var string
+   */
   protected $value_form_type = 'checkboxes';
 
+  /**
+   * Retrieves a wrapper for this filter's field.
+   *
+   * @return EntityMetadataWrapper|null
+   *   A wrapper for the field which this filter uses.
+   */
+  protected function get_wrapper() {
+    if ($this->query) {
+      $index = $this->query->getIndex();
+    }
+    elseif (substr($this->view->base_table, 0, 17) == 'search_api_index_') {
+      $index = search_api_index_load(substr($this->view->base_table, 17));
+    }
+    else {
+      return NULL;
+    }
+    $wrapper = $index->entityWrapper(NULL, TRUE);
+    $parts = explode(':', $this->real_field);
+    foreach ($parts as $i => $part) {
+      if (!isset($wrapper->$part)) {
+        return NULL;
+      }
+      $wrapper = $wrapper->$part;
+      $info = $wrapper->info();
+      if ($i < count($parts) - 1) {
+        // Unwrap lists.
+        $level = search_api_list_nesting_level($info['type']);
+        for ($j = 0; $j < $level; ++$j) {
+          $wrapper = $wrapper[0];
+        }
+      }
+    }
+
+    return $wrapper;
+  }
+
+  /**
+   * Fills the value_options property with all possible options.
+   */
+  protected function get_value_options() {
+    if (isset($this->value_options)) {
+      return;
+    }
+
+    $wrapper = $this->get_wrapper();
+    if ($wrapper) {
+      $this->value_options = $wrapper->optionsList('view');
+    }
+    else {
+      $this->value_options = array();
+    }
+  }
+
   /**
    * Provide a list of options for the operator form.
    */
   public function operator_options() {
-    return array(
+    $options = array(
       '=' => t('Is one of'),
-      '<>' => t('Is not one of'),
+      'all of' => t('Is all of'),
+      '<>' => t('Is none of'),
       'empty' => t('Is empty'),
       'not empty' => t('Is not empty'),
     );
+    // "Is all of" doesn't make sense for single-valued fields.
+    if (empty($this->definition['multi-valued'])) {
+      unset($options['all of']);
+    }
+    return $options;
   }
 
   /**
@@ -49,6 +121,7 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
    */
   public function option_definition() {
     $options = parent::option_definition();
+    $options['value'] = array('default' => '');
     $options['expose']['contains']['reduce'] = array('default' => FALSE);
     return $options;
   }
@@ -57,13 +130,12 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
    * Reduce the options according to the selection.
    */
   protected function reduce_value_options() {
-    $options = array();
-    foreach ($this->definition['options'] as $id => $option) {
-      if (isset($this->options['value'][$id])) {
-        $options[$id] = $option;
+    foreach ($this->value_options as $id => $option) {
+      if (!isset($this->options['value'][$id])) {
+        unset($this->value_options[$id]);
       }
     }
-    return $options;
+    return $this->value_options;
   }
 
   /**
@@ -86,29 +158,38 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
    * Provide a form for setting options.
    */
   public function value_form(&$form, &$form_state) {
-    $options = array();
+    $this->get_value_options();
     if (!empty($this->options['expose']['reduce']) && !empty($form_state['exposed'])) {
-      $options += $this->reduce_value_options($form_state);
+      $options = $this->reduce_value_options();
     }
     else {
-      $options += $this->definition['options'];
+      $options = $this->value_options;
     }
+
     $form['value'] = array(
       '#type' => $this->value_form_type,
       '#title' => empty($form_state['exposed']) ? t('Value') : '',
       '#options' => $options,
       '#multiple' => TRUE,
-      '#size' => min(4, count($this->definition['options'])),
-      '#default_value' => isset($this->value) ? $this->value : array(),
+      '#size' => min(4, count($options)),
+      '#default_value' => is_array($this->value) ? $this->value : array(),
     );
 
-    // Hide the value box if operator is 'empty' or 'not empty'.
+    // Hide the value box if the operator is 'empty' or 'not empty'.
     // Radios share the same selector so we have to add some dummy selector.
-    // #states replace #dependency (http://drupal.org/node/1595022).
-    $form['value']['#states']['visible'] = array(
-      ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
-      ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
-    );
+    if (empty($form_state['exposed'])) {
+      $form['value']['#states']['visible'] = array(
+        ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+        ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+      );
+    }
+    elseif (!empty($this->options['expose']['use_operator'])) {
+      $name = $this->options['expose']['operator_id'];
+      $form['value']['#states']['visible'] = array(
+        ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+        ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+      );
+    }
   }
 
   /**
@@ -135,19 +216,30 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
     $values = '';
 
     // Remove every element which is not known.
+    $this->get_value_options();
     foreach ($this->value as $i => $value) {
-      if (!isset($this->definition['options'][$value])) {
+      if (!isset($this->value_options[$value])) {
         unset($this->value[$i]);
       }
     }
     // Choose different kind of ouput for 0, a single and multiple values.
     if (count($this->value) == 0) {
-      return $this->operator == '=' ? t('none') : t('any');
+      return $this->operator != '<>' ? t('none') : t('any');
     }
     elseif (count($this->value) == 1) {
+      switch ($this->operator) {
+        case '=':
+        case 'all of':
+          $operator = '=';
+          break;
+
+        case '<>':
+          $operator = '<>';
+          break;
+      }
       // If there is only a single value, use just the plain operator, = or <>.
-      $operator = check_plain($this->operator);
-      $values = check_plain($this->definition['options'][reset($this->value)]);
+      $operator = check_plain($operator);
+      $values = check_plain($this->value_options[reset($this->value)]);
     }
     else {
       foreach ($this->value as $value) {
@@ -158,46 +250,91 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
           $values .= '…';
           break;
         }
-        $values .= check_plain($this->definition['options'][$value]);
+        $values .= check_plain($this->value_options[$value]);
       }
     }
 
     return $operator . (($values !== '') ? ' ' . $values : '');
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  function accept_exposed_input($input) {
+    $accepted = parent::accept_exposed_input($input);
+
+    // Grouped filters will have the raw form values structure from the
+    // checkboxes as the value here. Convert that into the correct array of
+    // values instead.
+    if ($accepted && is_array($this->value) && $this->is_a_group()) {
+      // For some reason, Views thinks it's a good idea to nest the form values
+      // into a second array in some cases. That one will be numerically indexed
+      // with just a single entry, though, so it should be relatively easy to
+      // spot.
+      if (count($this->value) && isset($this->value[0])) {
+        $this->value = reset($this->value);
+      }
+      $this->value = array_keys(array_filter($this->value));
+      if (!$this->value) {
+        return FALSE;
+      }
+    }
+
+    return $accepted;
+  }
+
   /**
    * Add this filter to the query.
    */
   public function query() {
     if ($this->operator === 'empty') {
       $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+      return;
     }
-    elseif ($this->operator === 'not empty') {
+    if ($this->operator === 'not empty') {
       $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+      return;
     }
-    else {
-      while (is_array($this->value) && count($this->value) == 1) {
-        $this->value = reset($this->value);
-      }
-      if (is_scalar($this->value) && $this->value !== '') {
-        $this->query->condition($this->real_field, $this->value, $this->operator, $this->options['group']);
+
+    // Extract the value.
+    while (is_array($this->value) && count($this->value) == 1) {
+      $this->value = reset($this->value);
+    }
+
+    // Determine operator and conjunction. The defaults are already right for
+    // "all of".
+    $operator = '=';
+    $conjunction = 'AND';
+    switch ($this->operator) {
+      case '=':
+        $conjunction = 'OR';
+        break;
+
+      case '<>':
+        $operator = '<>';
+        break;
+    }
+
+    // If the value is an empty array, we either want no filter at all (for
+    // "is none of"), or want to find only items with no value for the field.
+    if ($this->value === array()) {
+      if ($operator != '<>') {
+        $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
       }
-      elseif ($this->value) {
-        if ($this->operator == '=') {
-          $filter = $this->query->createFilter('OR');
-          // $filter will be NULL if there were errors in the query.
-          if ($filter) {
-            foreach ($this->value as $v) {
-              $filter->condition($this->real_field, $v, '=');
-            }
-            $this->query->filter($filter, $this->options['group']);
-          }
-        }
-        else {
-          foreach ($this->value as $v) {
-            $this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
-          }
+      return;
+    }
+
+    if (is_scalar($this->value) && $this->value !== '') {
+      $this->query->condition($this->real_field, $this->value, $operator, $this->options['group']);
+    }
+    elseif ($this->value) {
+      $filter = $this->query->createFilter($conjunction);
+      // $filter will be NULL if there were errors in the query.
+      if ($filter) {
+        foreach ($this->value as $v) {
+          $filter->condition($this->real_field, $v, $operator);
         }
+        $this->query->filter($filter, $this->options['group']);
       }
     }
   }

+ 335 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc

@@ -0,0 +1,335 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterTaxonomyTerm.
+ */
+
+/**
+ * Views filter handler class for taxonomy term entities.
+ *
+ * Based on views_handler_filter_term_node_tid.
+ */
+class SearchApiViewsHandlerFilterTaxonomyTerm extends SearchApiViewsHandlerFilterEntity {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function has_extra_options() {
+    return !empty($this->definition['vocabulary']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+
+    $options['type'] = array('default' => !empty($this->definition['vocabulary']) ? 'textfield' : 'select');
+    $options['hierarchy'] = array('default' => 0);
+    $options['expose']['contains']['reduce'] = array('default' => FALSE);
+    $options['error_message'] = array('default' => TRUE, 'bool' => TRUE);
+
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function extra_options_form(&$form, &$form_state) {
+    $form['type'] = array(
+      '#type' => 'radios',
+      '#title' => t('Selection type'),
+      '#options' => array('select' => t('Dropdown'), 'textfield' => t('Autocomplete')),
+      '#default_value' => $this->options['type'],
+    );
+
+    $form['hierarchy'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Show hierarchy in dropdown'),
+      '#default_value' => !empty($this->options['hierarchy']),
+    );
+    $form['hierarchy']['#states']['visible'][':input[name="options[type]"]']['value'] = 'select';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function value_form(&$form, &$form_state) {
+    parent::value_form($form, $form_state);
+
+    if (!empty($this->definition['vocabulary'])) {
+      $vocabulary = taxonomy_vocabulary_machine_name_load($this->definition['vocabulary']);
+      $title = t('Select terms from vocabulary @voc', array('@voc' => $vocabulary->name));
+    }
+    else {
+      $vocabulary = FALSE;
+      $title = t('Select terms');
+    }
+    $form['value']['#title'] = $title;
+
+    if ($vocabulary && $this->options['type'] == 'textfield') {
+      $form['value']['#autocomplete_path'] = 'admin/views/ajax/autocomplete/taxonomy/' . $vocabulary->vid;
+    }
+    else {
+      if ($vocabulary && !empty($this->options['hierarchy'])) {
+        $tree = taxonomy_get_tree($vocabulary->vid, 0, NULL, TRUE);
+        $options = array();
+
+        if ($tree) {
+          foreach ($tree as $term) {
+            $choice = new stdClass();
+            $choice->option = array($term->tid => str_repeat('-', $term->depth) . check_plain(entity_label('taxonomy_term', $term)));
+            $options[] = $choice;
+          }
+        }
+      }
+      else {
+        $options = array();
+        $query = db_select('taxonomy_term_data', 'td');
+        $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid');
+        $query->fields('td');
+        $query->orderby('tv.weight');
+        $query->orderby('tv.name');
+        $query->orderby('td.weight');
+        $query->orderby('td.name');
+        $query->addTag('taxonomy_term_access');
+        if ($vocabulary) {
+          $query->condition('tv.machine_name', $vocabulary->machine_name);
+        }
+        $result = $query->execute();
+        $tids = array();
+
+        foreach ($result as $term) {
+          $tids[] = $term->tid;
+        }
+        $terms = taxonomy_term_load_multiple($tids);
+
+        foreach ($terms as $term) {
+          $options[$term->tid] = check_plain(entity_label('taxonomy_term', $term));
+        }
+      }
+
+      $default_value = (array) $this->value;
+
+      if (!empty($form_state['exposed'])) {
+        $identifier = $this->options['expose']['identifier'];
+
+        if (!empty($this->options['expose']['reduce'])) {
+          $options = $this->reduce_value_options($options);
+
+          if (!empty($this->options['expose']['multiple']) && empty($this->options['expose']['required'])) {
+            $default_value = array();
+          }
+        }
+
+        if (empty($this->options['expose']['multiple'])) {
+          if (empty($this->options['expose']['required']) && (empty($default_value) || !empty($this->options['expose']['reduce']))) {
+            $default_value = 'All';
+          }
+          elseif (empty($default_value)) {
+            $keys = array_keys($options);
+            $default_value = array_shift($keys);
+          }
+          // Due to #1464174 there is a chance that array('') was saved in the
+          // admin ui. Let's choose a safe default value.
+          elseif ($default_value == array('')) {
+            $default_value = 'All';
+          }
+          else {
+            $copy = $default_value;
+            $default_value = array_shift($copy);
+          }
+        }
+      }
+      $form['value']['#type'] = 'select';
+      $form['value']['#multiple'] = TRUE;
+      $form['value']['#options'] = $options;
+      $form['value']['#size'] = min(9, count($options));
+      $form['value']['#default_value'] = $default_value;
+
+      if (!empty($form_state['exposed']) && isset($identifier) && !isset($form_state['input'][$identifier])) {
+        $form_state['input'][$identifier] = $default_value;
+      }
+    }
+  }
+
+  /**
+   * Reduces the available exposed options according to the selection.
+   */
+  protected function reduce_value_options(array $options) {
+    foreach ($options as $id => $option) {
+      if (empty($this->options['value'][$id])) {
+        unset($options[$id]);
+      }
+    }
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function value_validate($form, &$form_state) {
+    // We only validate if they've chosen the text field style.
+    if ($this->options['type'] != 'textfield') {
+      return;
+    }
+
+    parent::value_validate($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function accept_exposed_input($input) {
+    if (empty($this->options['exposed'])) {
+      return TRUE;
+    }
+
+    // We need to know the operator, which is normally set in
+    // views_handler_filter::accept_exposed_input(), before we actually call
+    // the parent version of ourselves.
+    if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+      $this->operator = $input[$this->options['expose']['operator_id']];
+    }
+
+    // If view is an attachment and is inheriting exposed filters, then assume
+    // exposed input has already been validated.
+    if (!empty($this->view->is_attachment) && $this->view->display_handler->uses_exposed()) {
+      $this->validated_exposed_input = (array) $this->view->exposed_raw_input[$this->options['expose']['identifier']];
+    }
+
+    // If we're checking for EMPTY or NOT, we don't need any input, and we can
+    // say that our input conditions are met by just having the right operator.
+    if ($this->operator == 'empty' || $this->operator == 'not empty') {
+      return TRUE;
+    }
+
+    // If it's non-required and there's no value don't bother filtering.
+    if (!$this->options['expose']['required'] && empty($this->validated_exposed_input)) {
+      return FALSE;
+    }
+
+    return parent::accept_exposed_input($input);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exposed_validate(&$form, &$form_state) {
+    if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+      return;
+    }
+
+    // We only validate if they've chosen the text field style.
+    if ($this->options['type'] != 'textfield') {
+      $input = $form_state['values'][$this->options['expose']['identifier']];
+      if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+        $input = $this->options['group_info']['group_items'][$input]['value'];
+      }
+
+      if ($input != 'All')  {
+        $this->validated_exposed_input = (array) $input;
+      }
+      return;
+    }
+
+    parent::exposed_validate($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function expose_options() {
+    parent::expose_options();
+    $this->options['expose']['reduce'] = FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validate_entity_strings(array &$form, array $values) {
+    if (empty($values)) {
+      return array();
+    }
+
+    $tids = array();
+    $names = array();
+    $missing = array();
+    foreach ($values as $value) {
+      $missing[strtolower($value)] = TRUE;
+      $names[] = $value;
+    }
+
+    if (!$names) {
+      return FALSE;
+    }
+
+    $query = db_select('taxonomy_term_data', 'td');
+    $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid');
+    $query->fields('td');
+    $query->condition('td.name', $names);
+    if (!empty($this->definition['vocabulary'])) {
+      $query->condition('tv.machine_name', $this->definition['vocabulary']);
+    }
+    $query->addTag('taxonomy_term_access');
+    $result = $query->execute();
+    foreach ($result as $term) {
+      unset($missing[strtolower($term->name)]);
+      $tids[] = $term->tid;
+    }
+
+    if ($missing) {
+      if (!empty($this->options['error_message'])) {
+        form_error($form, format_plural(count($missing), 'Unable to find term: @terms', 'Unable to find terms: @terms', array('@terms' => implode(', ', array_keys($missing)))));
+      }
+      else {
+        // Add a bogus TID which will show an empty result for a positive filter
+        // and be ignored for an excluding one.
+        $tids[] = 0;
+      }
+    }
+
+    return $tids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function expose_form(&$form, &$form_state) {
+    parent::expose_form($form, $form_state);
+
+    if ($this->options['type'] == 'select') {
+      $form['expose']['reduce'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Limit list to selected items'),
+        '#description' => t('If checked, the only items presented to the user will be the ones selected here.'),
+        '#default_value' => $this->options['expose']['reduce'],
+      );
+    }
+    else {
+      $form['error_message'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Display error message'),
+        '#description' => t('Display an error message if one of the entered terms could not be found.'),
+        '#default_value' => $this->options['error_message'],
+      );
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function ids_to_strings(array $ids) {
+    $ids = array_filter($ids);
+    if (!$ids) {
+      return '';
+    }
+    return implode(', ', db_select('taxonomy_term_data', 'td')
+      ->fields('td', array('name'))
+      ->condition('td.tid', $ids)
+      ->execute()
+      ->fetchCol());
+  }
+
+}

+ 53 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_text.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterText.
+ */
+
 /**
  * Views filter handler class for handling fulltext fields.
  */
@@ -12,4 +17,52 @@ class SearchApiViewsHandlerFilterText extends SearchApiViewsHandlerFilter {
     return array('=' => t('contains'), '<>' => t("doesn't contain"));
   }
 
+  /**
+   * Determines whether input from the exposed filters affects this filter.
+   *
+   * Overridden to not treat "All" differently.
+   *
+   * @param array $input
+   *   The user input from the exposed filters.
+   *
+   * @return bool
+   *   TRUE if the input should change the behavior of this filter.
+   */
+  public function accept_exposed_input($input) {
+    if (empty($this->options['exposed'])) {
+      return TRUE;
+    }
+
+    if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+      $this->operator = $input[$this->options['expose']['operator_id']];
+    }
+
+    if (!empty($this->options['expose']['identifier'])) {
+      $value = $input[$this->options['expose']['identifier']];
+
+      // Various ways to check for the absence of non-required input.
+      if (empty($this->options['expose']['required'])) {
+        if (($this->operator == 'empty' || $this->operator == 'not empty') && $value === '') {
+          $value = ' ';
+        }
+
+        if (!empty($this->always_multiple) && $value === '') {
+          return FALSE;
+        }
+      }
+
+      if (isset($value)) {
+        $this->value = $value;
+        if (empty($this->always_multiple) && empty($this->options['expose']['multiple'])) {
+          $this->value = array($value);
+        }
+      }
+      else {
+        return FALSE;
+      }
+    }
+
+    return TRUE;
+  }
+
 }

+ 79 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_filter_user.inc

@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiViewsHandlerFilterUser.
+ */
+
+/**
+ * Views filter handler class for handling user entities.
+ *
+ * Based on views_handler_filter_user_name.
+ */
+class SearchApiViewsHandlerFilterUser extends SearchApiViewsHandlerFilterEntity {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function value_form(&$form, &$form_state) {
+    parent::value_form($form, $form_state);
+
+    // Set autocompletion.
+    $path = $this->isMultiValued() ? 'admin/views/ajax/autocomplete/user' : 'user/autocomplete';
+    $form['value']['#autocomplete_path'] = $path;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function ids_to_strings(array $ids) {
+    $names = array();
+    $args[':uids'] = array_filter($ids);
+    if ($args[':uids']) {
+      $result = db_query('SELECT uid, name FROM {users} u WHERE uid IN (:uids)', $args);
+      $result = $result->fetchAllKeyed();
+    }
+    foreach ($ids as $uid) {
+      if (!$uid) {
+        $names[] = variable_get('anonymous', t('Anonymous'));
+      }
+      elseif (isset($result[$uid])) {
+        $names[] = $result[$uid];
+      }
+    }
+    return implode(', ', $names);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validate_entity_strings(array &$form, array $values) {
+    $uids = array();
+    $missing = array();
+    foreach ($values as $value) {
+      if (drupal_strtolower($value) === drupal_strtolower(variable_get('anonymous', t('Anonymous')))) {
+        $uids[] = 0;
+      }
+      else {
+        $missing[strtolower($value)] = $value;
+      }
+    }
+
+    if (!$missing) {
+      return $uids;
+    }
+
+    $result = db_query("SELECT * FROM {users} WHERE name IN (:names)", array(':names' => array_values($missing)));
+    foreach ($result as $account) {
+      unset($missing[strtolower($account->name)]);
+      $uids[] = $account->uid;
+    }
+
+    if ($missing) {
+      form_error($form, format_plural(count($missing), 'Unable to find user: @users', 'Unable to find users: @users', array('@users' => implode(', ', $missing))));
+    }
+
+    return $uids;
+  }
+
+}

+ 21 - 1
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/handler_sort.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsHandlerSort.
+ */
+
 /**
  * Class for sorting results according to a specified field.
  */
@@ -23,8 +28,23 @@ class SearchApiViewsHandlerSort extends views_handler_sort {
       unset($this->query->orderby);
       $sort = &$this->query->getSort();
       $sort = array();
+      unset($sort);
+    }
+
+    // If two of the same fields are used for sort, ignore the latter in order
+    // for the prior to take precedence. (Temporary workaround until
+    // https://www.drupal.org/node/2145547 is fixed in Views.)
+    $alreadySorted = $this->query->getSort();
+    if (is_array($alreadySorted) && isset($alreadySorted[$this->real_field])) {
+      return;
+    }
+
+    try {
+      $this->query->sort($this->real_field, $this->options['order']);
+    }
+    catch (SearchApiException $e) {
+      $this->query->abort($e->getMessage());
     }
-    $this->query->sort($this->real_field, $this->options['order']);
   }
 
 }

+ 146 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/plugin_cache.inc

@@ -0,0 +1,146 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiViewsCache class.
+ */
+
+/**
+ * Plugin class for caching Search API views.
+ */
+class SearchApiViewsCache extends views_plugin_cache_time {
+
+  /**
+   * Static cache for get_results_key().
+   *
+   * @var string
+   */
+  protected $_results_key = NULL;
+
+  /**
+   * Static cache for getSearchApiQuery().
+   *
+   * @var SearchApiQueryInterface
+   */
+  protected $search_api_query = NULL;
+
+  /**
+   * Overrides views_plugin_cache::cache_set().
+   *
+   * Also stores Search API's internal search results.
+   */
+  public function cache_set($type) {
+    if ($type != 'results') {
+      return parent::cache_set($type);
+    }
+
+    $cid = $this->get_results_key();
+    $results = NULL;
+    $query_plugin = $this->view->query;
+    if ($query_plugin instanceof SearchApiViewsQuery) {
+      $results = $query_plugin->getSearchApiResults();
+    }
+    $data = array(
+      'result' => $this->view->result,
+      'total_rows' => isset($this->view->total_rows) ? $this->view->total_rows : 0,
+      'current_page' => $this->view->get_current_page(),
+      'search_api results' => $results,
+    );
+    cache_set($cid, $data, $this->table, $this->cache_set_expire($type));
+  }
+
+  /**
+   * Overrides views_plugin_cache::cache_get().
+   *
+   * Additionally stores successfully retrieved results with
+   * search_api_current_search().
+   */
+  public function cache_get($type) {
+    if ($type != 'results') {
+      return parent::cache_get($type);
+    }
+
+    // Values to set: $view->result, $view->total_rows, $view->execute_time,
+    // $view->current_page.
+    if ($cache = cache_get($this->get_results_key(), $this->table)) {
+      $cutoff = $this->cache_expire($type);
+      if (!$cutoff || $cache->created > $cutoff) {
+        $this->view->result = $cache->data['result'];
+        $this->view->total_rows = $cache->data['total_rows'];
+        $this->view->set_current_page($cache->data['current_page']);
+        $this->view->execute_time = 0;
+
+        // Trick Search API into believing a search happened, to make facetting
+        // et al. work.
+        $query = $this->getSearchApiQuery();
+        search_api_current_search($query->getOption('search id'), $query, $cache->data['search_api results']);
+
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Overrides views_plugin_cache::get_cache_key().
+   *
+   * Use the Search API query as the main source for the key. Note that in
+   * Views < 3.8, this method does not exist.
+   */
+  public function get_cache_key($key_data = array()) {
+    global $user;
+
+    if (!isset($this->_results_key)) {
+      $query = $this->getSearchApiQuery();
+      $query->preExecute();
+      $key_data += array(
+        'query' => $query,
+        'roles' => array_keys($user->roles),
+        'super-user' => $user->uid == 1, // special caching for super user.
+        'language' => $GLOBALS['language']->language,
+        'base_url' => $GLOBALS['base_url'],
+        'offset' => $this->view->get_current_page() . '*' . $this->view->get_items_per_page() . '+' . $this->view->get_offset(),
+      );
+      // Not sure what gets passed in exposed_info, so better include it. All
+      // other parameters used in the parent method are already reflected in the
+      // Search API query object we use.
+      if (isset($_GET['exposed_info'])) {
+        $key_data['exposed_info'] = $_GET['exposed_info'];
+      }
+    }
+    $key = drupal_hash_base64(serialize($key_data));
+    return $key;
+  }
+
+  /**
+   * Overrides views_plugin_cache::get_results_key().
+   *
+   * This is unnecessary for Views >= 3.8.
+   */
+  public function get_results_key() {
+    if (!isset($this->_results_key)) {
+      $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . $this->get_cache_key();
+    }
+
+    return $this->_results_key;
+  }
+
+  /**
+   * Retrieves the Search API query object associated with the current view.
+   *
+   * @return SearchApiQueryInterface|null
+   *   The Search API query object associated with the current view; or NULL if
+   *   there is none.
+   */
+  protected function getSearchApiQuery() {
+    if (!isset($this->search_api_query)) {
+      $this->search_api_query = FALSE;
+      if (isset($this->view->query) && $this->view->query instanceof SearchApiViewsQuery) {
+        $this->search_api_query = $this->view->query->getSearchApiQuery();
+      }
+    }
+
+    return $this->search_api_query ? $this->search_api_query : NULL;
+  }
+
+}

+ 146 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/plugin_content_cache.inc

@@ -0,0 +1,146 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiViewsContentCache class.
+ */
+
+/**
+ * Plugin class for caching Search API views, with additional invalidation.
+ */
+class SearchApiViewsContentCache extends views_content_cache_plugin_cache {
+
+  /**
+   * Static cache for get_results_key().
+   *
+   * @var string
+   */
+  protected $_results_key = NULL;
+
+  /**
+   * Static cache for getSearchApiQuery().
+   *
+   * @var SearchApiQueryInterface
+   */
+  protected $search_api_query = NULL;
+
+  /**
+   * Overrides views_plugin_cache::cache_set().
+   *
+   * Also stores Search API's internal search results.
+   */
+  public function cache_set($type) {
+    if ($type != 'results') {
+      return parent::cache_set($type);
+    }
+
+    $cid = $this->get_results_key();
+    $results = NULL;
+    $query_plugin = $this->view->query;
+    if ($query_plugin instanceof SearchApiViewsQuery) {
+      $results = $query_plugin->getSearchApiResults();
+    }
+    $data = array(
+      'result' => $this->view->result,
+      'total_rows' => isset($this->view->total_rows) ? $this->view->total_rows : 0,
+      'current_page' => $this->view->get_current_page(),
+      'search_api results' => $results,
+    );
+    cache_set($cid, $data, $this->table, $this->cache_set_expire($type));
+  }
+
+  /**
+   * Overrides views_plugin_cache::cache_get().
+   *
+   * Additionally stores successfully retrieved results with
+   * search_api_current_search().
+   */
+  public function cache_get($type) {
+    if ($type != 'results') {
+      return parent::cache_get($type);
+    }
+
+    // Values to set: $view->result, $view->total_rows, $view->execute_time,
+    // $view->current_page.
+    if ($cache = cache_get($this->get_results_key(), $this->table)) {
+      $cutoff = $this->cache_expire($type);
+      if (!$cutoff || $cache->created > $cutoff) {
+        $this->view->result = $cache->data['result'];
+        $this->view->total_rows = $cache->data['total_rows'];
+        $this->view->set_current_page($cache->data['current_page']);
+        $this->view->execute_time = 0;
+
+        // Trick Search API into believing a search happened, to make facetting
+        // et al. work.
+        $query = $this->getSearchApiQuery();
+        search_api_current_search($query->getOption('search id'), $query, $cache->data['search_api results']);
+
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Overrides views_plugin_cache::get_cache_key().
+   *
+   * Use the Search API query as the main source for the key. Note that in
+   * Views < 3.8, this method does not exist.
+   */
+  public function get_cache_key($key_data = array()) {
+    global $user;
+
+    if (!isset($this->_results_key)) {
+      $query = $this->getSearchApiQuery();
+      $query->preExecute();
+      $key_data += array(
+        'query' => $query,
+        'roles' => array_keys($user->roles),
+        'super-user' => $user->uid == 1, // special caching for super user.
+        'language' => $GLOBALS['language']->language,
+        'base_url' => $GLOBALS['base_url'],
+        'offset' => $this->view->get_current_page() . '*' . $this->view->get_items_per_page() . '+' . $this->view->get_offset(),
+      );
+      // Not sure what gets passed in exposed_info, so better include it. All
+      // other parameters used in the parent method are already reflected in the
+      // Search API query object we use.
+      if (isset($_GET['exposed_info'])) {
+        $key_data['exposed_info'] = $_GET['exposed_info'];
+      }
+    }
+    $key = drupal_hash_base64(serialize($key_data));
+    return $key;
+  }
+
+  /**
+   * Overrides views_plugin_cache::get_results_key().
+   *
+   * This is unnecessary for Views >= 3.8.
+   */
+  public function get_results_key() {
+    if (!isset($this->_results_key)) {
+      $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . $this->get_cache_key();
+    }
+
+    return $this->_results_key;
+  }
+
+  /**
+   * Retrieves the Search API query object associated with the current view.
+   *
+   * @return SearchApiQueryInterface|null
+   *   The Search API query object associated with the current view; or NULL if
+   *   there is none.
+   */
+  protected function getSearchApiQuery() {
+    if (!isset($this->search_api_query)) {
+      $this->search_api_query = FALSE;
+      if (isset($this->view->query) && $this->view->query instanceof SearchApiViewsQuery) {
+        $this->search_api_query = $this->view->query->getSearchApiQuery();
+      }
+    }
+
+    return $this->search_api_query ? $this->search_api_query : NULL;
+  }
+
+}

+ 225 - 47
sites/all/modules/contrib/search/search_api/contrib/search_api_views/includes/query.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiViewsQuery.
+ */
+
 /**
  * Views query class using a Search API index as the data source.
  */
@@ -50,6 +55,13 @@ class SearchApiViewsQuery extends views_plugin_query {
    */
   protected $errors;
 
+  /**
+   * Whether to abort the search instead of executing it.
+   *
+   * @var bool
+   */
+  protected $abort = FALSE;
+
   /**
    * The names of all fields whose value is required by a handler.
    *
@@ -85,7 +97,7 @@ class SearchApiViewsQuery extends views_plugin_query {
         $id = substr($base_table, 17);
         $this->index = search_api_index_load($id);
         $this->query = $this->index->query(array(
-          'parse mode' => 'terms',
+          'parse mode' => $this->options['parse_mode'],
         ));
       }
     }
@@ -110,29 +122,81 @@ class SearchApiViewsQuery extends views_plugin_query {
   }
 
   /**
-   * Add a sort to the query.
+   * Adds a sort to the query.
    *
-   * @param $selector
+   * @param string $selector
    *   The field to sort on. All indexed fields of the index are valid values.
-   *   In addition, the special fields 'search_api_relevance' (sort by
-   *   relevance) and 'search_api_id' (sort by item id) may be used.
-   * @param $order
+   *   In addition, these special fields may be used:
+   *   - search_api_relevance: sort by relevance;
+   *   - search_api_id: sort by item id;
+   *   - search_api_random: random sort (available only if the server supports
+   *     the "search_api_random_sort" feature).
+   * @param string $order
    *   The order to sort items in - either 'ASC' or 'DESC'. Defaults to 'ASC'.
    */
   public function add_selector_orderby($selector, $order = 'ASC') {
-    $this->query->sort($selector, $order);
+    if (!$this->errors) {
+      $this->query->sort($selector, $order);
+    }
+  }
+
+  /**
+   * Provides a sorting method as present in the Views default query plugin.
+   *
+   * This is provided so that the "Global: Random" sort included in Views will
+   * work properly with Search API Views. Random sorting is only supported if
+   * the active search server supports the "search_api_random_sort" feature,
+   * though, otherwise the call will be ignored.
+   *
+   * This method can only be used to sort randomly, as would be done with the
+   * default query plugin. All other calls are ignored.
+   *
+   * @param string|null $table
+   *   Only "rand" is recognized here, all other calls are ignored.
+   * @param string|null $field
+   *   Is ignored and only present for compatibility reasons.
+   * @param string $order
+   *   Either "ASC" or "DESC".
+   * @param string|null $alias
+   *   Is ignored and only present for compatibility reasons.
+   * @param array $params
+   *   The following optional parameters are recognized:
+   *   - seed: a predefined seed for the random generator.
+   *
+   * @see views_plugin_query_default::add_orderby()
+   */
+  public function add_orderby($table, $field = NULL, $order = 'ASC', $alias = '', $params = array()) {
+    $server = $this->getIndex()->server();
+    if ($table == 'rand') {
+      if ($server->supportsFeature('search_api_random_sort')) {
+        $this->add_selector_orderby('search_api_random', $order);
+        if ($params) {
+          $this->setOption('search_api_random_sort', $params);
+        }
+      }
+      else {
+        $variables['%server'] = $server->label();
+        watchdog('search_api_views', 'Tried to sort results randomly on server %server which does not support random sorting.', $variables, WATCHDOG_WARNING);
+      }
+    }
   }
 
   /**
    * Defines the options used by this query plugin.
    *
-   * Adds an option to bypass access checks.
+   * Adds some access options.
    */
   public function option_definition() {
     return parent::option_definition() + array(
       'search_api_bypass_access' => array(
         'default' => FALSE,
       ),
+      'entity_access' => array(
+        'default' => FALSE,
+      ),
+      'parse_mode' => array(
+        'default' => 'terms',
+      ),
     );
   }
 
@@ -150,12 +214,45 @@ class SearchApiViewsQuery extends views_plugin_query {
       '#description' => t('If the underlying search index has access checks enabled, this option allows to disable them for this view.'),
       '#default_value' => $this->options['search_api_bypass_access'],
     );
+
+    if ($this->index && $this->index->getEntityType()) {
+      $form['entity_access'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Additional access checks on result entities'),
+        '#description' => t("Execute an access check for all result entities. This prevents users from seeing inappropriate content when the index contains stale data, or doesn't provide access checks. However, result counts, paging and other things won't work correctly if results are eliminated in this way, so only use this as a last ressort (and in addition to other checks, if possible)."),
+        '#default_value' => $this->options['entity_access'],
+      );
+    }
+
+    $form['parse_mode'] = array(
+      '#type' => 'select',
+      '#title' => t('Parse mode'),
+      '#description' => t('Choose how the search keys will be parsed.'),
+      '#options' => array(),
+      '#default_value' => $this->options['parse_mode'],
+    );
+    foreach ($this->query->parseModes() as $key => $mode) {
+      $form['parse_mode']['#options'][$key] = $mode['name'];
+      if (!empty($mode['description'])) {
+        $states['visible'][':input[name="query[options][parse_mode]"]']['value'] = $key;
+        $form["parse_mode_{$key}_description"] = array(
+          '#type' => 'item',
+          '#title' => $mode['name'],
+          '#description' => $mode['description'],
+          '#states' => $states,
+        );
+      }
+    }
   }
 
   /**
    * Builds the necessary info to execute the query.
    */
   public function build(&$view) {
+    if (!empty($this->errors)) {
+      return;
+    }
+
     $this->view = $view;
 
     // Setup the nested filter structure for this query.
@@ -173,6 +270,7 @@ class SearchApiViewsQuery extends views_plugin_query {
       // Add a nested filter for each filter group, with its set conjunction.
       foreach ($this->where as $group_id => $group) {
         if (!empty($group['conditions']) || !empty($group['filters'])) {
+          $group += array('type' => 'AND');
           // For filters without a group, we want to always add them directly to
           // the query.
           $filter = ($group_id === '') ? $this->query : $this->query->createFilter($group['type']);
@@ -199,10 +297,32 @@ class SearchApiViewsQuery extends views_plugin_query {
     $view->init_pager();
     $this->pager->query();
 
+    // Set the search ID, if it was not already set.
+    if ($this->query->getOption('search id') == get_class($this->query)) {
+      $this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
+    }
+
     // Add the "search_api_bypass_access" option to the query, if desired.
     if (!empty($this->options['search_api_bypass_access'])) {
       $this->query->setOption('search_api_bypass_access', TRUE);
     }
+
+    // If the View and the Panel conspire to provide an overridden path then
+    // pass that through as the base path.
+    if (!empty($this->view->override_path) && strpos(current_path(), $this->view->override_path) !== 0) {
+      $this->query->setOption('search_api_base_path', $this->view->override_path);
+    }
+
+    // Save query information for Views UI.
+    $view->build_info['query'] = (string) $this->query;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(&$view) {
+    parent::alter($view);
+    drupal_alter('search_api_views_query', $view, $this);
   }
 
   /**
@@ -213,7 +333,7 @@ class SearchApiViewsQuery extends views_plugin_query {
    * $view->pager['current_page'].
    */
   public function execute(&$view) {
-    if ($this->errors) {
+    if ($this->errors || $this->abort) {
       if (error_displayable()) {
         foreach ($this->errors as $msg) {
           drupal_set_message(check_plain($msg), 'error');
@@ -225,24 +345,44 @@ class SearchApiViewsQuery extends views_plugin_query {
       return;
     }
 
+    // Calculate the "skip result count" option, if it wasn't already set to
+    // FALSE.
+    $skip_result_count = $this->query->getOption('skip result count', TRUE);
+    if ($skip_result_count) {
+      $skip_result_count = !$this->pager || (!$this->pager->use_count_query() && empty($view->get_total_rows));
+      $this->query->setOption('skip result count', $skip_result_count);
+    }
+
     try {
-      $start = microtime(TRUE);
-      // Add range and search ID (if it wasn't already set).
-      $this->query->range($this->offset, $this->limit);
-      if ($this->query->getOption('search id') == get_class($this->query)) {
-        $this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
+      // Trigger pager pre_execute().
+      if ($this->pager) {
+        $this->pager->pre_execute($this->query);
       }
 
+      // Views passes sometimes NULL and sometimes the integer 0 for "All" in a
+      // pager. If set to 0 items, a string "0" is passed. Therefore, we unset
+      // the limit if an empty value OTHER than a string "0" was passed.
+      if (!$this->limit && $this->limit !== '0') {
+        $this->limit = NULL;
+      }
+      // Set the range. (We always set this, as there might even be an offset if
+      // all items are shown.)
+      $this->query->range($this->offset, $this->limit);
+
+      $start = microtime(TRUE);
+
       // Execute the search.
       $results = $this->query->execute();
       $this->search_api_results = $results;
 
       // Store the results.
-      $this->pager->total_items = $view->total_rows = $results['result count'];
-      if (!empty($this->pager->options['offset'])) {
-        $this->pager->total_items -= $this->pager->options['offset'];
+      if (!$skip_result_count) {
+        $this->pager->total_items = $view->total_rows = $results['result count'];
+        if (!empty($this->pager->options['offset'])) {
+          $this->pager->total_items -= $this->pager->options['offset'];
+        }
+        $this->pager->update_page_info();
       }
-      $this->pager->update_page_info();
       $view->result = array();
       if (!empty($results['results'])) {
         $this->addResults($results['results'], $view);
@@ -250,12 +390,33 @@ class SearchApiViewsQuery extends views_plugin_query {
       // We shouldn't use $results['performance']['complete'] here, since
       // extracting the results probably takes considerable time as well.
       $view->execute_time = microtime(TRUE) - $start;
+
+      // Trigger pager post_execute().
+      if ($this->pager) {
+        $this->pager->post_execute($view->result);
+      }
     }
     catch (Exception $e) {
       $this->errors[] = $e->getMessage();
       // Recursion to get the same error behaviour as above.
-      return $this->execute($view);
+      $this->execute($view);
+    }
+  }
+
+  /**
+   * Aborts this search query.
+   *
+   * Used by handlers to flag a fatal error which shouldn't be displayed but
+   * still lead to the view returning empty and the search not being executed.
+   *
+   * @param string|null $msg
+   *   Optionally, a translated, unescaped error message to display.
+   */
+  public function abort($msg = NULL) {
+    if ($msg) {
+      $this->errors[] = $msg;
     }
+    $this->abort = TRUE;
   }
 
   /**
@@ -270,6 +431,12 @@ class SearchApiViewsQuery extends views_plugin_query {
     // First off, we try to gather as much field values as possible without
     // loading any items.
     foreach ($results as $id => $result) {
+      if (!empty($this->options['entity_access']) && ($entity_type = $this->index->getEntityType())) {
+        $entity = $this->index->loadItems(array($id));
+        if (!$entity || !entity_access('view', $entity_type, reset($entity))) {
+          continue;
+        }
+      }
       $row = array();
 
       // Include the loaded item for this result row, if present, or the item
@@ -286,11 +453,11 @@ class SearchApiViewsQuery extends views_plugin_query {
 
       // Gather any fields from the search results.
       if (!empty($result['fields'])) {
-        $row['_entity_properties'] += $result['fields'];
+        $row['_entity_properties'] += search_api_get_sanitized_field_values($result['fields']);
       }
 
       // Check whether we need to extract any properties from the result item.
-      $missing_fields = array_diff_key($this->fields, $row);
+      $missing_fields = array_diff_key($this->fields, $row['_entity_properties']);
       if ($missing_fields) {
         $missing[$id] = $missing_fields;
         if (is_object($row['entity'])) {
@@ -308,14 +475,14 @@ class SearchApiViewsQuery extends views_plugin_query {
     // Load items of those rows which haven't got all field values, yet.
     if (!empty($ids)) {
       $items += $this->index->loadItems($ids);
-      // $items now includes loaded items, and those already passed in the
-      // search results.
-      foreach ($items as $id => $item) {
-        // Extract item properties.
-        $wrapper = $this->index->entityWrapper($item, FALSE);
-        $rows[$id]->_entity_properties += $this->extractFields($wrapper, $missing[$id]);
-        $rows[$id]->entity = $item;
-      }
+    }
+    // $items now includes all loaded items from which fields still need to be
+    // extracted.
+    foreach ($items as $id => $item) {
+      // Extract item properties.
+      $wrapper = $this->index->entityWrapper($item, FALSE);
+      $rows[$id]->_entity_properties += $this->extractFields($wrapper, $missing[$id]);
+      $rows[$id]->entity = $item;
     }
 
     // Finally, add all rows to the Views result set.
@@ -353,12 +520,21 @@ class SearchApiViewsQuery extends views_plugin_query {
   public function get_result_entities($results, $relationship = NULL, $field = NULL) {
     list($type, $wrappers) = $this->get_result_wrappers($results, $relationship, $field);
     $return = array();
-    foreach ($wrappers as $id => $wrapper) {
+    foreach ($wrappers as $i => $wrapper) {
       try {
-        $return[$id] = $wrapper->value();
+        // Get the entity ID beforehand for possible watchdog messages.
+        $id = $wrapper->value(array('identifier' => TRUE));
+
+        // Only add results that exist.
+        if ($entity = $wrapper->value()) {
+          $return[$i] = $entity;
+        }
+        else {
+          watchdog('search_api_views', 'The search index returned a reference to an entity with ID @id, which does not exist in the database. Your index may be out of sync and should be rebuilt.', array('@id' => $id), WATCHDOG_ERROR);
+        }
       }
       catch (EntityMetadataWrapperException $e) {
-        // Ignore.
+        watchdog_exception('search_api_views', $e, "%type while trying to load search result entity with ID @id: !message in %function (line %line of %file).", array('@id' => $id), WATCHDOG_ERROR);
       }
     }
     return array($type, $return);
@@ -371,31 +547,31 @@ class SearchApiViewsQuery extends views_plugin_query {
    * query backend.
    */
   public function get_result_wrappers($results, $relationship = NULL, $field = NULL) {
-    $is_entity = (boolean) entity_get_info($this->index->item_type);
+    $type = $this->index->getEntityType() ? $this->index->getEntityType() : $this->index->item_type;
     $wrappers = array();
-    $load_entities = array();
+    $load_items = array();
     foreach ($results as $row_index => $row) {
-      if ($is_entity && isset($row->entity)) {
+      if (isset($row->entity)) {
         // If this entity isn't load, register it for pre-loading.
         if (!is_object($row->entity)) {
-          $load_entities[$row->entity] = $row_index;
+          $load_items[$row->entity] = $row_index;
+        }
+        else {
+          $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
         }
-
-        $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
       }
     }
 
     // If the results are entities, we pre-load them to make use of a multiple
     // load. (Otherwise, each result would be loaded individually.)
-    if (!empty($load_entities)) {
-      $entities = entity_load($this->index->item_type, array_keys($load_entities));
-      foreach ($entities as $entity_id => $entity) {
-        $wrappers[$load_entities[$entity_id]] = $this->index->entityWrapper($entity);
+    if (!empty($load_items)) {
+      $items = $this->index->loadItems(array_keys($load_items));
+      foreach ($items as $id => $item) {
+        $wrappers[$load_items[$id]] = $this->index->entityWrapper($item);
       }
     }
 
     // Apply the relationship, if necessary.
-    $type = $this->index->item_type;
     $selector_suffix = '';
     if ($field && ($pos = strrpos($field, ':'))) {
       $selector_suffix = substr($field, 0, $pos);
@@ -441,9 +617,9 @@ class SearchApiViewsQuery extends views_plugin_query {
   // Query interface methods (proxy to $this->query)
   //
 
-  public function createFilter($conjunction = 'AND') {
+  public function createFilter($conjunction = 'AND', $tags = array()) {
     if (!$this->errors) {
-      return $this->query->createFilter($conjunction);
+      return $this->query->createFilter($conjunction, $tags);
     }
   }
 
@@ -541,16 +717,18 @@ class SearchApiViewsQuery extends views_plugin_query {
     return $ret;
   }
 
-  public function getOption($name) {
+  public function getOption($name, $default = NULL) {
     if (!$this->errors) {
-      return $this->query->getOption($name);
+      return $this->query->getOption($name, $default);
     }
+    return $default;
   }
 
   public function setOption($name, $value) {
     if (!$this->errors) {
       return $this->query->setOption($name, $value);
     }
+    return NULL;
   }
 
   public function &getOptions() {

+ 34 - 0
sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.api.php

@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Search Views module.
+ */
+
+/**
+ * Alter the query before executing the query.
+ *
+ * @param view $view
+ *   The view object about to be processed.
+ * @param SearchApiViewsQuery $query
+ *   The Search API Views query to be altered.
+ *
+ * @see hook_views_query_alter()
+ */
+function hook_search_api_views_query_alter(view &$view, SearchApiViewsQuery &$query) {
+  // (Example assuming a view with an exposed filter on node title.)
+  // If the input for the title filter is a positive integer, filter against
+  // node ID instead of node title.
+  if ($view->name == 'my_view' && is_numeric($view->exposed_raw_input['title']) && $view->exposed_raw_input['title'] > 0) {
+    // Traverse through the 'where' part of the query.
+    foreach ($query->where as &$condition_group) {
+      foreach ($condition_group['conditions'] as &$condition) {
+        // If this is the part of the query filtering on title, chang the
+        // condition to filter on node ID.
+        if (reset($condition) == 'node.title') {
+          $condition = array('node.nid', $view->exposed_raw_input['title'],'=');
+        }
+      }
+    }
+  }
+}

+ 16 - 10
sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.info

@@ -1,30 +1,36 @@
-
-name = Search views
+name = Search Views
 description = Integrates the Search API with Views, enabling users to create views with searches as filters or arguments.
-dependencies[] = search_api
-dependencies[] = views
+dependencies[] = search_api:search_api
+dependencies[] = views:views
 core = 7.x
 package = Search
 
-; Views handlers
+; Views handlers/plugins
 files[] = includes/display_facet_block.inc
 files[] = includes/handler_argument.inc
 files[] = includes/handler_argument_fulltext.inc
 files[] = includes/handler_argument_more_like_this.inc
-files[] = includes/handler_argument_text.inc
+files[] = includes/handler_argument_string.inc
+files[] = includes/handler_argument_date.inc
+files[] = includes/handler_argument_taxonomy_term.inc
 files[] = includes/handler_filter.inc
 files[] = includes/handler_filter_boolean.inc
 files[] = includes/handler_filter_date.inc
+files[] = includes/handler_filter_entity.inc
 files[] = includes/handler_filter_fulltext.inc
 files[] = includes/handler_filter_language.inc
+files[] = includes/handler_filter_numeric.inc
 files[] = includes/handler_filter_options.inc
+files[] = includes/handler_filter_taxonomy_term.inc
 files[] = includes/handler_filter_text.inc
+files[] = includes/handler_filter_user.inc
 files[] = includes/handler_sort.inc
+files[] = includes/plugin_cache.inc
+files[] = includes/plugin_content_cache.inc
 files[] = includes/query.inc
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by Drupal.org packaging script on 2019-03-11
+version = "7.x-1.26"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
-
+datestamp = "1552334832"

+ 12 - 4
sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.install

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * @file
  * Install, update and uninstall functions for the search_api_views module.
@@ -24,7 +25,7 @@ function search_api_views_update_7101() {
   if (!$table_fields) {
     return;
   }
-  foreach (views_get_all_views() as $name => $view) {
+  foreach (views_get_all_views() as $view) {
     if (empty($view->base_table) || empty($table_fields[$view->base_table])) {
       continue;
     }
@@ -32,7 +33,7 @@ function search_api_views_update_7101() {
     $fields = $table_fields[$view->base_table];
     $change |= _search_api_views_update_7101_helper($view->base_field, $fields);
     if (!empty($view->display)) {
-      foreach ($view->display as $key => &$display) {
+      foreach ($view->display as &$display) {
         $options = &$display->display_options;
         if (isset($options['style_options']['grouping'])) {
           $change |= _search_api_views_update_7101_helper($options['style_options']['grouping'], $fields);
@@ -66,8 +67,15 @@ function search_api_views_update_7101() {
 /**
  * Helper function for replacing field identifiers.
  *
- * @return
- *   TRUE iff the identifier was changed.
+ * @param $field
+ *   Some data to be searched for field names that should be altered. Passed by
+ *   reference.
+ * @param array $fields
+ *   An array mapping Search API field identifiers (as previously used by Views)
+ *   to the new, sanitized Views field identifiers.
+ *
+ * @return bool
+ *   TRUE if any data was changed, FALSE otherwise.
  */
 function _search_api_views_update_7101_helper(&$field, array $fields) {
   if (is_array($field)) {

+ 26 - 3
sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.module

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Integrates the Search API with Views.
+ */
+
 /**
  * Implements hook_views_api().
  */
@@ -12,7 +17,7 @@ function search_api_views_views_api() {
 /**
  * Implements hook_search_api_index_insert().
  */
-function search_api_views_search_api_index_insert(SearchApiIndex $index) {
+function search_api_views_search_api_index_insert() {
   // Make the new index available for views.
   views_invalidate_cache();
 }
@@ -21,8 +26,23 @@ function search_api_views_search_api_index_insert(SearchApiIndex $index) {
  * Implements hook_search_api_index_update().
  */
 function search_api_views_search_api_index_update(SearchApiIndex $index) {
-  if (!$index->enabled && $index->original->enabled) {
+  // Check whether index was disabled.
+  $is_enabled = $index->enabled;
+  $was_enabled = $index->original->enabled;
+  if (!$is_enabled && $was_enabled) {
     _search_api_views_index_unavailable($index);
+    return;
+  }
+
+  // Check whether the indexed fields changed.
+  $old_fields = $index->original->options + array('fields' => array());
+  $old_fields = $old_fields['fields'];
+  $new_fields = $index->options + array('fields' => array());
+  $new_fields = $new_fields['fields'];
+
+  // If the index was enabled or its fields changed, invalidate the Views cache.
+  if ($is_enabled != $was_enabled || $old_fields != $new_fields) {
+    views_invalidate_cache();
   }
 }
 
@@ -30,7 +50,10 @@ function search_api_views_search_api_index_update(SearchApiIndex $index) {
  * Implements hook_search_api_index_delete().
  */
 function search_api_views_search_api_index_delete(SearchApiIndex $index) {
-  _search_api_views_index_unavailable($index);
+  // Only do this if this is a "real" deletion, no revert.
+  if (!$index->hasStatus(ENTITY_IN_CODE)) {
+    _search_api_views_index_unavailable($index);
+  }
 }
 
 /**

+ 151 - 16
sites/all/modules/contrib/search/search_api/contrib/search_api_views/search_api_views.views.inc

@@ -1,12 +1,16 @@
 <?php
 
+/**
+ * @file
+ * Views hook implementations for the Search API Views module.
+ */
+
 /**
  * Implements hook_views_data().
  */
 function search_api_views_views_data() {
   try {
     $data = array();
-    $entity_types = entity_get_info();
     foreach (search_api_index_load_multiple(FALSE) as $index) {
       // Fill in base data.
       $key = 'search_api_index_' . $index->machine_name;
@@ -20,14 +24,16 @@ function search_api_views_views_data() {
         'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->name)),
         'query class' => 'search_api_views_query',
       );
-      if (isset($entity_types[$index->item_type])) {
-        $table['table'] += array(
-          'entity type' => $index->item_type,
-          'skip entity load' => TRUE,
-        );
-      }
+      $table['table']['entity type'] = $index->getEntityType();
+      $table['table']['skip entity load'] = TRUE;
 
-      $wrapper = $index->entityWrapper(NULL, TRUE);
+      try {
+        $wrapper = $index->entityWrapper(NULL, FALSE);
+      }
+      catch (EntityMetadataWrapperException $e) {
+        watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+        continue;
+      }
 
       // Add field handlers and relationships provided by the Entity API.
       foreach ($wrapper as $key => $property) {
@@ -37,6 +43,14 @@ function search_api_views_views_data() {
         }
       }
 
+      try {
+        $wrapper = $index->entityWrapper(NULL);
+      }
+      catch (EntityMetadataWrapperException $e) {
+        watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+        continue;
+      }
+
       // Add handlers for all indexed fields.
       foreach ($index->getFields() as $key => $field) {
         $tmp = $wrapper;
@@ -63,7 +77,7 @@ function search_api_views_views_data() {
         if ($group) {
           // @todo Entity type label instead of $group?
           $table[$id]['group'] = $group;
-          $name = t('@field (indexed)', array('@field' => $name));
+          $name = t('!field (indexed)', array('!field' => $name));
         }
         $table[$id]['title'] = $name;
         $table[$id]['help'] = empty($info['description']) ? t('(No information available)') : $info['description'];
@@ -85,6 +99,7 @@ function search_api_views_views_data() {
       $table['search_api_relevance']['title'] = t('Relevance');
       $table['search_api_relevance']['help'] = t('The relevance of this search result with respect to the query.');
       $table['search_api_relevance']['field']['type'] = 'decimal';
+      $table['search_api_relevance']['field']['float'] = TRUE;
       $table['search_api_relevance']['field']['handler'] = 'entity_views_handler_field_numeric';
       $table['search_api_relevance']['field']['click sortable'] = TRUE;
       $table['search_api_relevance']['sort']['handler'] = 'SearchApiViewsHandlerSort';
@@ -105,6 +120,31 @@ function search_api_views_views_data() {
       $table['search_api_views_more_like_this']['title'] = t('More like this');
       $table['search_api_views_more_like_this']['help'] = t('Find similar content.');
       $table['search_api_views_more_like_this']['argument']['handler'] = 'SearchApiViewsHandlerArgumentMoreLikeThis';
+
+      // If there are taxonomy term references indexed in the index, include the
+      // "Indexed taxonomy term fields" contextual filter. We also save for all
+      // fields whether they contain only terms of a certain vocabulary, keying
+      // that information by vocabulary for later ease of use.
+      $vocabulary_fields = array();
+      foreach ($index->getFields() as $key => $field) {
+        if (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+          $field_id = ($pos = strrpos($key, ':')) ? substr($key, $pos + 1) : $key;
+          $field_info = field_info_field($field_id);
+          if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+            $vocabulary_fields[$vocabulary][] = $key;
+          }
+          else {
+            $vocabulary_fields[''][] = $key;
+          }
+        }
+      }
+      if ($vocabulary_fields) {
+        $table['search_api_views_taxonomy_term']['group'] = t('Search');
+        $table['search_api_views_taxonomy_term']['title'] = t('Indexed taxonomy term fields');
+        $table['search_api_views_taxonomy_term']['help'] = t('Search in all indexed taxonomy term fields.');
+        $table['search_api_views_taxonomy_term']['argument']['handler'] = 'SearchApiViewsHandlerArgumentTaxonomyTerm';
+        $table['search_api_views_taxonomy_term']['argument']['vocabulary_fields'] = $vocabulary_fields;
+      }
     }
     return $data;
   }
@@ -114,8 +154,18 @@ function search_api_views_views_data() {
 }
 
 /**
- * Helper function that returns an array of handler definitions to add to a
- * views field definition.
+ * Adds handler definitions for a field to a Views data table definition.
+ *
+ * Helper method for search_api_views_views_data().
+ *
+ * @param $id
+ *   The internal identifier of the field.
+ * @param array $field
+ *   Information about the field.
+ * @param EntityMetadataWrapper $wrapper
+ *   A wrapper providing further metadata about the field.
+ * @param array $table
+ *   The existing Views data table definition, as a reference.
  */
 function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper $wrapper, array &$table) {
   $type = $field['type'];
@@ -130,7 +180,7 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
   if ($inner_type == 'text') {
     $table[$id] += array(
       'argument' => array(
-        'handler' => 'SearchApiViewsHandlerArgumentText',
+        'handler' => 'SearchApiViewsHandlerArgumentString',
       ),
       'filter' => array(
         'handler' => 'SearchApiViewsHandlerFilterText',
@@ -139,9 +189,10 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
     return;
   }
 
-  if ($options = $wrapper->optionsList('view')) {
+  $info = $wrapper->info();
+  if (isset($info['options list']) && is_callable($info['options list'])) {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
-    $table[$id]['filter']['options'] = $options;
+    $table[$id]['filter']['multi-valued'] = search_api_is_list_type($type);
   }
   elseif ($inner_type == 'boolean') {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterBoolean';
@@ -149,11 +200,42 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
   elseif ($inner_type == 'date') {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterDate';
   }
+  elseif (isset($field['entity_type']) && $field['entity_type'] === 'user') {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterUser';
+  }
+  elseif (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterTaxonomyTerm';
+    $field_info = field_info_field($info['name']);
+    // For the "Parent terms" and "All parent terms" properties, we can
+    // extrapolate the vocabulary from the parent in the selector. (E.g.,
+    // for "field_tags:parent" we can use the information of "field_tags".)
+    // Otherwise, we can't include any vocabulary information.
+    if (!$field_info && ($info['name'] == 'parent' || $info['name'] == 'parents_all')) {
+      if (!empty($table[$id]['real field'])) {
+        $parts = explode(':', $table[$id]['real field']);
+        $field_info = field_info_field($parts[count($parts) - 2]);
+      }
+    }
+    if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+      $table[$id]['filter']['vocabulary'] = $vocabulary;
+    }
+  }
+  elseif (in_array($inner_type, array('integer', 'decimal', 'duration', 'string'))) {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterNumeric';
+  }
   else {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter';
   }
 
-  $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+  if ($inner_type == 'string' || $inner_type == 'uri') {
+    $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentString';
+  }
+  elseif ($inner_type == 'date') {
+    $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentDate';
+  }
+  else {
+    $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+  }
 
   // We can only sort according to single-valued fields.
   if ($type == $inner_type) {
@@ -168,12 +250,27 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
  * Implements hook_views_plugins().
  */
 function search_api_views_views_plugins() {
+  // Collect all base tables provided by this module.
+  $bases = array();
+  foreach (search_api_index_load_multiple(FALSE) as $index) {
+    $bases[] = 'search_api_index_' . $index->machine_name;
+  }
+
   $ret = array(
     'query' => array(
       'search_api_views_query' => array(
         'title' => t('Search API Query'),
         'help' => t('Query will be generated and run using the Search API.'),
-        'handler' => 'SearchApiViewsQuery'
+        'handler' => 'SearchApiViewsQuery',
+      ),
+    ),
+    'cache' => array(
+      'search_api_views_cache' => array(
+        'title' => t('Search-specific'),
+        'help' => t("Cache Search API views. (Other methods probably won't work with search views.)"),
+        'base' => $bases,
+        'handler' => 'SearchApiViewsCache',
+        'uses options' => TRUE,
       ),
     ),
   );
@@ -192,5 +289,43 @@ function search_api_views_views_plugins() {
     );
   }
 
+  if (module_exists('views_content_cache')) {
+    $ret['cache']['search_api_views_content_cache'] = array(
+      'title' => t('Search-specific content-based'),
+      'help' => t("Cache Search API views based on content updates. (Requires Views Content Cache)"),
+      'base' => $bases,
+      'handler' => 'SearchApiViewsContentCache',
+      'uses options' => TRUE,
+    );
+  }
+
   return $ret;
 }
+
+/**
+ * Returns the vocabulary machine name of a term field.
+ *
+ * @param array|null $field_info
+ *   The field's field info array, or NULL if the field is not provided by the
+ *   Field API. See the return value of field_info_field().
+ *
+ * @return string|null
+ *   If the field contains taxonomy terms of a single vocabulary (which could be
+ *   determined), that vocabulary's machine name; NULL otherwise.
+ */
+function _search_api_views_get_field_vocabulary($field_info) {
+  // Test for "Term reference" fields.
+  if (isset($field_info['settings']['allowed_values'][0]['vocabulary'])) {
+    return $field_info['settings']['allowed_values'][0]['vocabulary'];
+  }
+  // Test for "Entity reference" fields.
+  elseif (isset($field_info['settings']['handler']) && $field_info['settings']['handler'] === 'base') {
+    if (!empty($field_info['settings']['handler_settings']['target_bundles'])) {
+      $bundles = $field_info['settings']['handler_settings']['target_bundles'];
+      if (count($bundles) == 1) {
+        return key($bundles);
+      }
+    }
+  }
+  return NULL;
+}

+ 43 - 63
sites/all/modules/contrib/search/search_api/includes/callback.inc

@@ -1,5 +1,13 @@
 <?php
 
+/**
+ * @file
+ * Contains base definitions for data alterations.
+ *
+ * Contains the SearchApiAlterCallbackInterface interface and the
+ * SearchApiAbstractAlterCallback class.
+ */
+
 /**
  * Interface representing a Search API data-alter callback.
  */
@@ -18,7 +26,7 @@ interface SearchApiAlterCallbackInterface {
   /**
    * Check whether this data-alter callback is applicable for a certain index.
    *
-   * This can be used for hiding the callback on the index's "Workflow" tab. To
+   * This can be used for hiding the callback on the index's "Filters" tab. To
    * avoid confusion, you should only use criteria that are immutable, such as
    * the index's entity type. Also, since this is only used for UI purposes, you
    * should not completely rely on this to ensure certain index configurations
@@ -85,10 +93,15 @@ interface SearchApiAlterCallbackInterface {
   public function alterItems(array &$items);
 
   /**
-   * Declare the properties that are (or can be) added to items with this
-   * callback. If a property with this name already exists for an entity it
-   * will be overridden, so keep a clear namespace by prefixing the properties
-   * with the module name if this is not desired.
+   * Declare the properties that are added to items by this callback.
+   *
+   * If one of the specified properties already exists for an entity it will be
+   * overridden, so keep a clear namespace by prefixing the properties with the
+   * module name if this is not desired.
+   *
+   * CAUTION: Since this method is used when calling
+   * SearchApiIndex::getFields(), calling that method from inside propertyInfo()
+   * will lead to a recursion and should therefore be avoided.
    *
    * @see hook_entity_property_info()
    *
@@ -101,8 +114,10 @@ interface SearchApiAlterCallbackInterface {
 }
 
 /**
- * Abstract base class for data-alter callbacks, implementing most methods with
- * sensible defaults.
+ * Abstract base class for data-alter callbacks.
+ *
+ * This class implements most methods with sensible defaults.
+ *
  * Extending classes will at least have to implement the alterItems() method to
  * make this work. If that method adds additional fields to the items,
  * propertyInfo() has to be overridden, too.
@@ -124,12 +139,7 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   protected $options;
 
   /**
-   * Construct a data-alter callback.
-   *
-   * @param SearchApiIndex $index
-   *   The index whose items will be altered.
-   * @param array $options
-   *   The callback options set for this index.
+   * Implements SearchApiAlterCallbackInterface::__construct().
    */
   public function __construct(SearchApiIndex $index, array $options = array()) {
     $this->index = $index;
@@ -137,64 +147,28 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   }
 
   /**
-   * Check whether this data-alter callback is applicable for a certain index.
-   *
-   * This can be used for hiding the callback on the index's "Workflow" tab. To
-   * avoid confusion, you should only use criteria that are immutable, such as
-   * the index's entity type. Also, since this is only used for UI purposes, you
-   * should not completely rely on this to ensure certain index configurations
-   * and at least throw an exception with a descriptive error message if this is
-   * violated on runtime.
+   * Implements SearchApiAlterCallbackInterface::supportsIndex().
    *
    * The default implementation always returns TRUE.
-   *
-   * @param SearchApiIndex $index
-   *   The index to check for.
-   *
-   * @return boolean
-   *   TRUE if the callback can run on the given index; FALSE otherwise.
    */
   public function supportsIndex(SearchApiIndex $index) {
     return TRUE;
   }
 
   /**
-   * Display a form for configuring this callback.
-   *
-   * @return array
-   *   A form array for configuring this callback, or FALSE if no configuration
-   *   is possible.
+   * Implements SearchApiAlterCallbackInterface::configurationForm().
    */
   public function configurationForm() {
     return array();
   }
 
   /**
-   * Validation callback for the form returned by configurationForm().
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
+   * Implements SearchApiAlterCallbackInterface::configurationFormValidate().
    */
   public function configurationFormValidate(array $form, array &$values, array &$form_state) { }
 
   /**
-   * Submit callback for the form returned by configurationForm().
-   *
-   * This method should both return the new options and set them internally.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
-   *
-   * @return array
-   *   The new options array for this callback.
+   * Implements SearchApiAlterCallbackInterface::configurationFormSubmit().
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     $this->options = $values;
@@ -202,19 +176,25 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   }
 
   /**
-   * Declare the properties that are (or can be) added to items with this
-   * callback. If a property with this name already exists for an entity it
-   * will be overridden, so keep a clear namespace by prefixing the properties
-   * with the module name if this is not desired.
-   *
-   * @see hook_entity_property_info()
-   *
-   * @return array
-   *   Information about all additional properties, as specified by
-   *   hook_entity_property_info() (only the inner "properties" array).
+   * Implements SearchApiAlterCallbackInterface::propertyInfo().
    */
   public function propertyInfo() {
     return array();
   }
 
+  /**
+   * Determines whether the given index contains multiple types of entities.
+   *
+   * @param SearchApiIndex|null $index
+   *   (optional) The index to examine. Defaults to the index set for this
+   *   plugin.
+   *
+   * @return bool
+   *   TRUE if the index is a multi-entity index, FALSE otherwise.
+   */
+  protected function isMultiEntityIndex(SearchApiIndex $index = NULL) {
+    $index = $index ? $index : $this->index;
+    return $index->datasource() instanceof SearchApiCombinedEntityDataSourceController;
+  }
+
 }

+ 87 - 10
sites/all/modules/contrib/search/search_api/includes/callback_add_aggregation.inc

@@ -1,17 +1,48 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiAlterAddAggregation.
+ */
+
 /**
  * Search API data alteration callback that adds an URL field for all items.
  */
 class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
 
+  /**
+   * The type of aggregation currently performed.
+   *
+   * Used to temporarily store the current aggregation type for use of
+   * SearchApiAlterAddAggregation::reduce() with array_reduce().
+   *
+   * @var string
+   */
+  protected $reductionType;
+
+  /**
+   * A separator to use when the aggregation type is 'fulltext'.
+   *
+   * Used to temporarily store a string separator when the aggregation type is
+   * "fulltext", for use in SearchApiAlterAddAggregation::reduce() with
+   * array_reduce().
+   *
+   * @var string
+   */
+  protected $fulltextReductionSeparator;
+
   public function configurationForm() {
     $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
 
     $fields = $this->index->getFields(FALSE);
     $field_options = array();
+    $field_properties = array();
     foreach ($fields as $name => $field) {
-      $field_options[$name] = $field['name'];
+      $field_options[$name] = check_plain($field['name']);
+      $field_properties[$name] = array(
+        '#attributes' => array('title' => $name),
+        '#description' => check_plain($field['description']),
+      );
     }
     $additional = empty($this->options['fields']) ? array() : $this->options['fields'];
 
@@ -60,17 +91,31 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
         '#required' => TRUE,
       );
       $form['fields'][$name]['type_descriptions'] = $type_descriptions;
+      $type_selector = ':input[name="callbacks[search_api_alter_add_aggregation][settings][fields][' . $name . '][type]"]';
       foreach (array_keys($types) as $type) {
-        $form['fields'][$name]['type_descriptions'][$type]['#states']['visible'][':input[name="callbacks[search_api_alter_add_aggregation][settings][fields][' . $name . '][type]"]']['value'] = $type;
+        $form['fields'][$name]['type_descriptions'][$type]['#states']['visible'][$type_selector]['value'] = $type;
       }
-      $form['fields'][$name]['fields'] = array(
+      $form['fields'][$name]['separator'] = array(
+        '#type' => 'textfield',
+        '#title' => t('Fulltext separator'),
+        '#description' => t('For aggregation type "Fulltext", set the text that should be used to separate the aggregated field values. Use "\t" for tabs and "\n" for newline characters.'),
+        '#default_value' => addcslashes(isset($field['separator']) ? $field['separator'] : "\n\n", "\0..\37\\"),
+        '#states' => array(
+          'visible' => array(
+            $type_selector => array(
+              'value' => 'fulltext',
+            ),
+          ),
+        ),
+      );
+      $form['fields'][$name]['fields'] = array_merge($field_properties, array(
         '#type' => 'checkboxes',
         '#title' => t('Contained fields'),
         '#options' => $field_options,
         '#default_value' => drupal_map_assoc($field['fields']),
         '#attributes' => array('class' => array('search-api-alter-add-aggregation-fields')),
         '#required' => TRUE,
-      );
+      ));
       $form['fields'][$name]['actions'] = array(
         '#type' => 'actions',
         'remove' => array(
@@ -106,11 +151,12 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
       return;
     }
     foreach ($values['fields'] as $name => $field) {
-      $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
       unset($values['fields'][$name]['actions']);
+      $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
       if ($field['name'] && !$fields) {
         form_error($form['fields'][$name]['fields'], t('You have to select at least one field to aggregate. If you want to remove an aggregated field, please delete its name.'));
       }
+      $values['fields'][$name]['separator'] = stripcslashes($field['separator']);
     }
   }
 
@@ -157,6 +203,7 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
             $values = $this->flattenArray($values);
 
             $this->reductionType = $field['type'];
+            $this->fulltextReductionSeparator = isset($field['separator']) ? $field['separator'] : "\n\n";
             $item->$name = array_reduce($values, array($this, 'reduce'), NULL);
             if ($field['type'] == 'count' && !$item->$name) {
               $item->$name = 0;
@@ -173,7 +220,7 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
   public function reduce($a, $b) {
     switch ($this->reductionType) {
       case 'fulltext':
-        return isset($a) ? $a . "\n\n" . $b : $b;
+        return isset($a) ? $a . $this->fulltextReductionSeparator . $b : $b;
       case 'sum':
         return $a + $b;
       case 'count':
@@ -184,7 +231,22 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
         return isset($a) ? min($a, $b) : $b;
       case 'first':
         return isset($a) ? $a : $b;
+      case 'first_char':
+        $b = "$b";
+        if (isset($a) || $b === '') {
+          return $a;
+        }
+        return drupal_substr($b, 0, 1);
+      case 'last':
+        return isset($b) ? $b : $a;
+      case 'list':
+        if (!isset($a)) {
+          $a = array();
+        }
+        $a[] = $b;
+        return $a;
     }
+    return NULL;
   }
 
   /**
@@ -237,10 +299,13 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
   /**
    * Helper method for getting all available aggregation types.
    *
-   * @param $info (optional)
-   *   One of "name", "type" or "description", to indicate what values should be
-   *   returned for the types. Defaults to "name".
+   * @param string $info
+   *   (optional) One of "name", "type" or "description", to indicate what
+   *   information should be returned for the types.
    *
+   * @return string[]
+   *   An associative array of aggregation type identifiers mapped to their
+   *   names, data types or descriptions, as requested.
    */
   protected function getTypes($info = 'name') {
     switch ($info) {
@@ -252,6 +317,9 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
           'max' => t('Maximum'),
           'min' => t('Minimum'),
           'first' => t('First'),
+          'first_char' => t('First letter'),
+          'last' => t('Last'),
+          'list' => t('List'),
         );
       case 'type':
         return array(
@@ -260,7 +328,10 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
           'count' => 'integer',
           'max' => 'integer',
           'min' => 'integer',
-          'first' => 'string',
+          'first' => 'token',
+          'first_char' => 'token',
+          'last' => 'token',
+          'list' => 'list<token>',
         );
       case 'description':
         return array(
@@ -270,8 +341,12 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
           'max' => t('The Maximum aggregation computes the numerically largest contained field value.'),
           'min' => t('The Minimum aggregation computes the numerically smallest contained field value.'),
           'first' => t('The First aggregation will simply keep the first encountered field value. This is helpful foremost when you know that a list field will only have a single value.'),
+          'first_char' => t('The "First letter" aggregation uses just the first letter of the first encountered field value as the aggregated value. This can, for example, be used to build a Glossary view.'),
+          'last' => t('The Last aggregation will simply keep the last encountered field value.'),
+          'list' => t('The List aggregation collects all field values into a multi-valued field containing all values.'),
         );
     }
+    return array();
   }
 
   /**
@@ -280,6 +355,8 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
   public function formButtonSubmit(array $form, array &$form_state) {
     $button_name = $form_state['triggering_element']['#name'];
     if ($button_name == 'op') {
+      // Increment $i until the corresponding field is not set, then create the
+      // field with that number as suffix.
       for ($i = 1; isset($this->options['fields']['search_api_aggregation_' . $i]); ++$i) {
       }
       $this->options['fields']['search_api_aggregation_' . $i] = array(

+ 28 - 54
sites/all/modules/contrib/search/search_api/includes/callback_add_hierarchy.inc

@@ -1,7 +1,12 @@
 <?php
 
 /**
- * Search API data alteration callback that adds an URL field for all items.
+ * @file
+ * Contains SearchApiAlterAddHierarchy.
+ */
+
+/**
+ * Adds all ancestors for hierarchical fields.
  */
 class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
 
@@ -15,24 +20,16 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
   protected $field_options;
 
   /**
-   * Enable this data alteration only if any hierarchical fields are available.
+   * Overrides SearchApiAbstractAlterCallback::supportsIndex().
    *
-   * @param SearchApiIndex $index
-   *   The index to check for.
-   *
-   * @return boolean
-   *   TRUE if the callback can run on the given index; FALSE otherwise.
+   * Returns TRUE only if any hierarchical fields are available.
    */
   public function supportsIndex(SearchApiIndex $index) {
     return (bool) $this->getHierarchicalFields();
   }
 
   /**
-   * Display a form for configuring this callback.
-   *
-   * @return array
-   *   A form array for configuring this callback, or FALSE if no configuration
-   *   is possible.
+   * {@inheritdoc}
    */
   public function configurationForm() {
     $options = $this->getHierarchicalFields();
@@ -54,19 +51,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Submit callback for the form returned by configurationForm().
-   *
-   * This method should both return the new options and set them internally.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
-   *
-   * @return array
-   *   The new options array for this callback.
+   * {@inheritdoc}
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     // Change the saved type of fields in the index, if necessary.
@@ -74,7 +59,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
       $fields = &$this->index->options['fields'];
       $previous = drupal_map_assoc($this->options['fields']);
       foreach ($values['fields'] as $field) {
-        list($key, $prop) = explode(':', $field);
+        list($key) = explode(':', $field);
         if (empty($previous[$field]) && isset($fields[$key]['type'])) {
           $fields[$key]['type'] = 'list<' . search_api_extract_inner_type($fields[$key]['type']) . '>';
           $change = TRUE;
@@ -82,7 +67,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
       }
       $new = drupal_map_assoc($values['fields']);
       foreach ($previous as $field) {
-        list($key, $prop) = explode(':', $field);
+        list($key) = explode(':', $field);
         if (empty($new[$field]) && isset($fields[$key]['type'])) {
           $w = $this->index->entityWrapper(NULL, FALSE);
           if (isset($w->$key)) {
@@ -102,19 +87,11 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Alter items before indexing.
-   *
-   * Items which are removed from the array won't be indexed, but will be marked
-   * as clean for future indexing. This could for instance be used to implement
-   * some sort of access filter for security purposes (e.g., don't index
-   * unpublished nodes or comments).
-   *
-   * @param array $items
-   *   An array of items to be altered, keyed by item IDs.
+   * {@inheritdoc}
    */
   public function alterItems(array &$items) {
     if (empty($this->options['fields'])) {
-      return array();
+      return;
     }
     foreach ($items as $item) {
       $wrapper = $this->index->entityWrapper($item, FALSE);
@@ -131,22 +108,13 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
         $this->extractHierarchy($child, $prop, $values[$key]);
       }
       foreach ($values as $key => $value) {
-        $item->$key = $value;
+        $item->$key = array_values($value);
       }
     }
   }
 
   /**
-   * Declare the properties that are (or can be) added to items with this
-   * callback. If a property with this name already exists for an entity it
-   * will be overridden, so keep a clear namespace by prefixing the properties
-   * with the module name if this is not desired.
-   *
-   * @see hook_entity_property_info()
-   *
-   * @return array
-   *   Information about all additional properties, as specified by
-   *   hook_entity_property_info() (only the inner "properties" array).
+   * {@inheritdoc}
    */
   public function propertyInfo() {
     if (empty($this->options['fields'])) {
@@ -188,7 +156,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Helper method for finding all hierarchical fields of an index's type.
+   * Finds all hierarchical fields for the current index.
    *
    * @return array
    *   An array containing all hierarchical fields of the index, structured as
@@ -231,13 +199,19 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
       }
       return;
     }
-    $v = $wrapper->value(array('identifier' => TRUE));
-    if ($v && !isset($values[$v])) {
-      $values[$v] = $v;
-      if (isset($wrapper->$property) && $wrapper->$property->value()) {
-        $this->extractHierarchy($wrapper->$property, $property, $values);
+    try {
+      $v = $wrapper->value(array('identifier' => TRUE));
+      if ($v && !isset($values[$v])) {
+        $values[$v] = $v;
+        if (isset($wrapper->$property) && $wrapper->value() && $wrapper->$property->value()) {
+          $this->extractHierarchy($wrapper->$property, $property, $values);
+        }
       }
     }
+    catch (EntityMetadataWrapperException $e) {
+      // Some properties like entity_metadata_book_get_properties() throw
+      // exceptions, so we catch them here and ignore the property.
+    }
   }
 
 }

+ 6 - 1
sites/all/modules/contrib/search/search_api/includes/callback_add_url.inc

@@ -1,12 +1,17 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiAlterAddUrl.
+ */
+
 /**
  * Search API data alteration callback that adds an URL field for all items.
  */
 class SearchApiAlterAddUrl extends SearchApiAbstractAlterCallback {
 
   public function alterItems(array &$items) {
-    foreach ($items as $id => &$item) {
+    foreach ($items as &$item) {
       $url = $this->index->datasource()->getItemUrl($item);
       if (!$url) {
         $item->search_api_url = NULL;

+ 14 - 7
sites/all/modules/contrib/search/search_api/includes/callback_add_viewed_entity.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiAlterAddViewedEntity.
+ */
+
 /**
  * Search API data alteration callback that adds an URL field for all items.
  */
@@ -11,14 +16,16 @@ class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
    * @see SearchApiAlterCallbackInterface::supportsIndex()
    */
   public function supportsIndex(SearchApiIndex $index) {
-    return (bool) entity_get_info($index->item_type);
+    return (bool) $index->getEntityType();
   }
 
   public function configurationForm() {
-    $info = entity_get_info($this->index->item_type);
     $view_modes = array();
-    foreach ($info['view modes'] as $key => $mode) {
-      $view_modes[$key] = $mode['label'];
+    if ($entity_type = $this->index->getEntityType()) {
+      $info = entity_get_info($entity_type);
+      foreach ($info['view modes'] as $key => $mode) {
+        $view_modes[$key] = $mode['label'];
+      }
     }
     $this->options += array('mode' => reset($view_modes));
     if (count($view_modes) > 1) {
@@ -60,14 +67,14 @@ class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
     $original_user = $GLOBALS['user'];
     $GLOBALS['user'] = drupal_anonymous_user();
 
-    $type = $this->index->item_type;
+    $type = $this->index->getEntityType();
     $mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
-    foreach ($items as $id => &$item) {
+    foreach ($items as &$item) {
       // Since we can't really know what happens in entity_view() and render(),
       // we use try/catch. This will at least prevent some errors, even though
       // it's no protection against fatal errors and the like.
       try {
-        $render = entity_view($type, array(entity_id($type, $item) => $item), $mode);
+        $render = entity_view($type, array(entity_id($type, $item) => $item), $mode, $item->search_api_language);
         $text = render($render);
         if (!$text) {
           $item->search_api_viewed = NULL;

File diff suppressed because it is too large
+ 71 - 32
sites/all/modules/contrib/search/search_api/includes/callback_bundle_filter.inc


+ 46 - 0
sites/all/modules/contrib/search/search_api/includes/callback_comment_access.inc

@@ -0,0 +1,46 @@
+<?php
+/**
+ * @file
+ * Contains the SearchApiAlterCommentAccess class.
+ */
+
+/**
+ * Adds node access information to comment indexes.
+ */
+class SearchApiAlterCommentAccess extends SearchApiAlterNodeAccess {
+
+  /**
+   * Overrides SearchApiAlterNodeAccess::supportsIndex().
+   *
+   * Returns TRUE only for indexes on comments.
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return $index->getEntityType() === 'comment';
+  }
+
+  /**
+   * Overrides SearchApiAlterNodeAccess::getNode().
+   *
+   * Returns the comment's node, instead of the item (i.e., the comment) itself.
+   */
+  protected function getNode($item) {
+    return node_load($item->nid);
+  }
+
+  /**
+   * Overrides SearchApiAlterNodeAccess::configurationFormSubmit().
+   *
+   * Doesn't index the comment's "Author".
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $old_status = !empty($form_state['index']->options['data_alter_callbacks']['search_api_alter_comment_access']['status']);
+    $new_status = !empty($form_state['values']['callbacks']['search_api_alter_comment_access']['status']);
+
+    if (!$old_status && $new_status) {
+      $form_state['index']->options['fields']['status']['type'] = 'boolean';
+    }
+
+    return parent::configurationFormSubmit($form, $values, $form_state);
+  }
+
+}

+ 11 - 40
sites/all/modules/contrib/search/search_api/includes/callback_language_control.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiAlterLanguageControl.
+ */
+
 /**
  * Search API data alteration callback that filters out items based on their
  * bundle.
@@ -7,12 +12,7 @@
 class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
 
   /**
-   * Construct a data-alter callback.
-   *
-   * @param SearchApiIndex $index
-   *   The index whose items will be altered.
-   * @param array $options
-   *   The callback options set for this index.
+   * {@inheritdoc}
    */
   public function __construct(SearchApiIndex $index, array $options = array()) {
     $options += array(
@@ -23,16 +23,10 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Check whether this data-alter callback is applicable for a certain index.
+   * Overrides SearchApiAbstractAlterCallback::supportsIndex().
    *
    * Only returns TRUE if the system is multilingual.
    *
-   * @param SearchApiIndex $index
-   *   The index to check for.
-   *
-   * @return boolean
-   *   TRUE if the callback can run on the given index; FALSE otherwise.
-   *
    * @see drupal_multilingual()
    */
   public function supportsIndex(SearchApiIndex $index) {
@@ -40,10 +34,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Display a form for configuring this data alteration.
-   *
-   * @return array
-   *   A form array for configuring this data alteration.
+   * {@inheritdoc}
    */
   public function configurationForm() {
     $form = array();
@@ -79,7 +70,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
       foreach ($list as $lang) {
         $name = t($lang->name);
         $native = $lang->native;
-        $languages[$lang->language] = ($name == $native) ? $name : "$name ($native)";
+        $languages[$lang->language] = check_plain(($name == $native) ? $name : "$name ($native)");
         if (!$lang->enabled) {
           $languages[$lang->language] .= ' [' . t('disabled') . ']';
         }
@@ -98,19 +89,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Submit callback for the form returned by configurationForm().
-   *
-   * This method should both return the new options and set them internally.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
-   *
-   * @return array
-   *   The new options array for this callback.
+   * {@inheritdoc}
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     $values['languages'] = array_filter($values['languages']);
@@ -118,15 +97,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Alter items before indexing.
-   *
-   * Items which are removed from the array won't be indexed, but will be marked
-   * as clean for future indexing. This could for instance be used to implement
-   * some sort of access filter for security purposes (e.g., don't index
-   * unpublished nodes or comments).
-   *
-   * @param array $items
-   *   An array of items to be altered, keyed by item IDs.
+   * {@inheritdoc}
    */
   public function alterItems(array &$items) {
     foreach ($items as $i => &$item) {

+ 23 - 34
sites/all/modules/contrib/search/search_api/includes/callback_node_access.inc

@@ -10,31 +10,19 @@
 class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
 
   /**
-   * Check whether this data-alter callback is applicable for a certain index.
+   * Overrides SearchApiAbstractAlterCallback::supportsIndex().
    *
    * Returns TRUE only for indexes on nodes.
-   *
-   * @param SearchApiIndex $index
-   *   The index to check for.
-   *
-   * @return boolean
-   *   TRUE if the callback can run on the given index; FALSE otherwise.
    */
   public function supportsIndex(SearchApiIndex $index) {
     // Currently only node access is supported.
-    return $index->item_type === 'node';
+    return $index->getEntityType() === 'node';
   }
 
   /**
-   * Declare the properties that are (or can be) added to items with this callback.
+   * Overrides SearchApiAbstractAlterCallback::propertyInfo().
    *
    * Adds the "search_api_access_node" property.
-   *
-   * @see hook_entity_property_info()
-   *
-   * @return array
-   *   Information about all additional properties, as specified by
-   *   hook_entity_property_info() (only the inner "properties" array).
    */
   public function propertyInfo() {
     return array(
@@ -47,15 +35,7 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
   }
 
   /**
-   * Alter items before indexing.
-   *
-   * Items which are removed from the array won't be indexed, but will be marked
-   * as clean for future indexing. This could for instance be used to implement
-   * some sort of access filter for security purposes (e.g., don't index
-   * unpublished nodes or comments).
-   *
-   * @param array $items
-   *   An array of items to be altered, keyed by item IDs.
+   * {@inheritdoc}
    */
   public function alterItems(array &$items) {
     static $account;
@@ -65,30 +45,39 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
       $account = drupal_anonymous_user();
     }
 
-    foreach ($items as $nid => &$item) {
+    foreach ($items as $id => $item) {
+      $node = $this->getNode($item);
       // Check whether all users have access to the node.
-      if (!node_access('view', $item, $account)) {
+      if (!node_access('view', $node, $account)) {
         // Get node access grants.
-        $result = db_query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $item->nid));
+        $result = db_query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $node->nid));
 
-        // Store all grants together with it's realms in the item.
+        // Store all grants together with their realms in the item.
         foreach ($result as $grant) {
-          if (!isset($items[$nid]->search_api_access_node)) {
-            $items[$nid]->search_api_access_node = array();
-          }
-          $items[$nid]->search_api_access_node[] = "node_access_$grant->realm:$grant->gid";
+          $items[$id]->search_api_access_node[] = "node_access_{$grant->realm}:{$grant->gid}";
         }
       }
       else {
         // Add the generic view grant if we are not using node access or the
         // node is viewable by anonymous users.
-        $items[$nid]->search_api_access_node = array('node_access__all');
+        $items[$id]->search_api_access_node = array('node_access__all');
       }
     }
   }
 
   /**
-   * Submit callback for the configuration form.
+   * Retrieves the node related to a search item.
+   *
+   * In the default implementation for nodes, the item is already the node.
+   * Subclasses may override this to easily provide node access checks for
+   * items related to nodes.
+   */
+  protected function getNode($item) {
+    return $item;
+  }
+
+  /**
+   * Overrides SearchApiAbstractAlterCallback::configurationFormSubmit().
    *
    * If the data alteration is being enabled, set "Published" and "Author" to
    * "indexed", because both are needed for the node access filter.

+ 1 - 1
sites/all/modules/contrib/search/search_api/includes/callback_node_status.inc

@@ -22,7 +22,7 @@ class SearchApiAlterNodeStatus extends SearchApiAbstractAlterCallback {
    *   TRUE if the callback can run on the given index; FALSE otherwise.
    */
   public function supportsIndex(SearchApiIndex $index) {
-    return $index->item_type === 'node';
+    return $index->getEntityType() === 'node';
   }
 
   /**

+ 78 - 0
sites/all/modules/contrib/search/search_api/includes/callback_role_filter.inc

@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiAlterRoleFilter class.
+ */
+
+/**
+ * Data alteration that filters out users based on their role.
+ */
+class SearchApiAlterRoleFilter extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Overrides SearchApiAbstractAlterCallback::supportsIndex().
+   *
+   * This plugin only supports indexes containing users.
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    if ($this->isMultiEntityIndex($index)) {
+      return in_array('user', $index->options['datasource']['types']);
+    }
+    return $index->getEntityType() == 'user';
+  }
+
+  /**
+   * Implements SearchApiAlterCallbackInterface::alterItems().
+   */
+  public function alterItems(array &$items) {
+    $selected_roles = $this->options['roles'];
+    $default = (bool) $this->options['default'];
+    $multi_types = $this->isMultiEntityIndex($this->index);
+    foreach ($items as $id => $item) {
+      if ($multi_types) {
+        if ($item->item_type !== 'user') {
+          continue;
+        }
+        $item_roles = $item->user->roles;
+      }
+      else {
+        $item_roles = $item->roles;
+      }
+      $role_match = (count(array_diff_key($item_roles, $selected_roles)) !== count($item_roles));
+      if ($role_match === $default) {
+        unset($items[$id]);
+      }
+    }
+  }
+
+  /**
+   * Overrides SearchApiAbstractAlterCallback::configurationForm().
+   *
+   * Add option for the roles to include/exclude.
+   */
+  public function configurationForm() {
+    $options = array_map('check_plain', user_roles());
+    $form = array(
+      'default' => array(
+        '#type' => 'radios',
+        '#title' => t('Which users should be indexed?'),
+        '#default_value' => isset($this->options['default']) ? $this->options['default'] : 1,
+        '#options' => array(
+          1 => t('All but those from one of the selected roles'),
+          0 => t('Only those from the selected roles'),
+        ),
+      ),
+      'roles' => array(
+        '#type' => 'select',
+        '#title' => t('Roles'),
+        '#default_value' => isset($this->options['roles']) ? $this->options['roles'] : array(),
+        '#options' => $options,
+        '#size' => min(4, count($options)),
+        '#multiple' => TRUE,
+      ),
+    );
+    return $form;
+  }
+
+}

+ 57 - 0
sites/all/modules/contrib/search/search_api/includes/callback_user_content.inc

@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiAlterAddUserContent.
+ */
+
+/**
+ * Adds the nodes created by the indexed user for indexing.
+ */
+class SearchApiAlterAddUserContent extends SearchApiAbstractAlterCallback {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return $index->getEntityType() === 'user';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function propertyInfo() {
+    return array(
+      'search_api_user_content' => array(
+        'label' => t('User content'),
+        'description' => t('The nodes created by this user'),
+        'type' => 'list<node>',
+      ),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alterItems(array &$items) {
+    $uids = array();
+    foreach ($items as $item) {
+      $uids[] = $item->uid;
+    }
+
+    $sql = 'SELECT nid, uid FROM {node} WHERE uid IN (:uids)';
+    $nids = db_query($sql, array(':uids' => $uids));
+    $user_nodes = array();
+    foreach ($nids as $row) {
+      $user_nodes[$row->uid][] = $row->nid;
+    }
+
+    foreach ($items as $item) {
+      $item->search_api_user_content = array();
+      if (!empty($user_nodes[$item->uid])) {
+        $item->search_api_user_content = $user_nodes[$item->uid];
+      }
+    }
+  }
+
+}

+ 31 - 0
sites/all/modules/contrib/search/search_api/includes/callback_user_status.inc

@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiAlterUserStatus class.
+ */
+
+/**
+ * Filters out blocked user accounts.
+ */
+class SearchApiAlterUserStatus extends SearchApiAbstractAlterCallback {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return $index->getEntityType() == 'user';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alterItems(array &$items) {
+    foreach ($items as $id => $account) {
+      if (empty($account->status)) {
+        unset($items[$id]);
+      }
+    }
+  }
+
+}

+ 337 - 222
sites/all/modules/contrib/search/search_api/includes/datasource.inc

@@ -12,45 +12,55 @@
  * They are used for loading items, extracting item data, keeping track of the
  * item status, etc.
  *
- * All methods of the data source may throw exceptions of type
- * SearchApiDataSourceException if any exception or error state is encountered.
+ * Modules providing implementations of this interface that use a different way
+ * (either different table or different method altogether) of keeping track of
+ * indexed/dirty items than SearchApiAbstractDataSourceController should be
+ * aware that indexes' numerical IDs can change due to feature reverts. It is
+ * therefore recommended to use search_api_index_update_datasource(), or similar
+ * code, in a hook_search_api_index_update() implementation.
  */
 interface SearchApiDataSourceControllerInterface {
 
   /**
-   * Constructor for a data source controller.
+   * Constructs an SearchApiDataSourceControllerInterface object.
    *
-   * @param $type
+   * @param string $type
    *   The item type for which this controller is created.
    */
   public function __construct($type);
 
   /**
-   * Return information on the ID field for this controller's type.
+   * Returns information on the ID field for this controller's type.
    *
    * @return array
    *   An associative array containing the following keys:
    *   - key: The property key for the ID field, as used in the item wrapper.
    *   - type: The type of the ID field. Has to be one of the types from
    *     search_api_field_types(). List types ("list<*>") are not allowed.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function getIdFieldInfo();
 
   /**
-   * Load items of the type of this data source controller.
+   * Loads items of the type of this data source controller.
    *
    * @param array $ids
-   *   The IDs of the items to laod.
+   *   The IDs of the items to load.
    *
    * @return array
    *   The loaded items, keyed by ID.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function loadItems(array $ids);
 
   /**
-   * Get a metadata wrapper for the item type of this data source controller.
+   * Creates a metadata wrapper for this datasource controller's type.
    *
-   * @param $item
+   * @param mixed $item
    *   Unless NULL, an item of the item type for this controller to be wrapped.
    * @param array $info
    *   Optionally, additional information that should be used for creating the
@@ -60,151 +70,182 @@ interface SearchApiDataSourceControllerInterface {
    *   A wrapper for the item type of this data source controller, according to
    *   the info array, and optionally loaded with the given data.
    *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
+   *
    * @see entity_metadata_wrapper()
    */
   public function getMetadataWrapper($item = NULL, array $info = array());
 
   /**
-   * Get the unique ID of an item.
+   * Retrieves the unique ID of an item.
    *
-   * @param $item
+   * @param mixed $item
    *   An item of this controller's type.
    *
-   * @return
+   * @return mixed
    *   Either the unique ID of the item, or NULL if none is available.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function getItemId($item);
 
   /**
-   * Get a human-readable label for an item.
+   * Retrieves a human-readable label for an item.
    *
-   * @param $item
+   * @param mixed $item
    *   An item of this controller's type.
    *
-   * @return
+   * @return string|null
    *   Either a human-readable label for the item, or NULL if none is available.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function getItemLabel($item);
 
   /**
-   * Get a URL at which the item can be viewed on the web.
+   * Retrieves a URL at which the item can be viewed on the web.
    *
-   * @param $item
+   * @param mixed $item
    *   An item of this controller's type.
    *
-   * @return
+   * @return array|null
    *   Either an array containing the 'path' and 'options' keys used to build
    *   the URL of the item, and matching the signature of url(), or NULL if the
    *   item has no URL of its own.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function getItemUrl($item);
 
   /**
-   * Initialize tracking of the index status of items for the given indexes.
+   * Initializes tracking of the index status of items for the given indexes.
    *
    * All currently known items of this data source's type should be inserted
    * into the tracking table for the given indexes, with status "changed". If
    * items were already present, these should also be set to "changed" and not
    * be inserted again.
    *
-   * @param array $indexes
+   * @param SearchApiIndex[] $indexes
    *   The SearchApiIndex objects for which item tracking should be initialized.
    *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function startTracking(array $indexes);
 
   /**
-   * Stop tracking of the index status of items for the given indexes.
+   * Stops tracking of the index status of items for the given indexes.
    *
    * The tracking tables of the given indexes should be completely cleared.
    *
-   * @param array $indexes
+   * @param SearchApiIndex[] $indexes
    *   The SearchApiIndex objects for which item tracking should be stopped.
    *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function stopTracking(array $indexes);
 
   /**
-   * Start tracking the index status for the given items on the given indexes.
+   * Starts tracking the index status for the given items on the given indexes.
    *
    * @param array $item_ids
    *   The IDs of new items to track.
-   * @param array $indexes
+   * @param SearchApiIndex[] $indexes
    *   The indexes for which items should be tracked.
    *
+   * @return SearchApiIndex[]|null
+   *   All indexes for which any items were added; or NULL if items were added
+   *   for all of them.
+   *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function trackItemInsert(array $item_ids, array $indexes);
 
   /**
-   * Set the tracking status of the given items to "changed"/"dirty".
+   * Sets the tracking status of the given items to "changed"/"dirty".
    *
    * Unless $dequeue is set to TRUE, this operation is ignored for items whose
    * status is not "indexed".
    *
-   * @param $item_ids
+   * @param array|false $item_ids
    *   Either an array with the IDs of the changed items. Or FALSE to mark all
    *   items as changed for the given indexes.
-   * @param array $indexes
+   * @param SearchApiIndex[] $indexes
    *   The indexes for which the change should be tracked.
-   * @param $dequeue
-   *   If set to TRUE, also change the status of queued items.
+   * @param bool $dequeue
+   *   (deprecated) If set to TRUE, also change the status of queued items.
+   *   The concept of queued items will be removed in the Drupal 8 version of
+   *   this module.
+   *
+   * @return SearchApiIndex[]|null
+   *   All indexes for which any items were updated; or NULL if items were
+   *   updated for all of them.
    *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE);
 
   /**
-   * Set the tracking status of the given items to "queued".
+   * Sets the tracking status of the given items to "queued".
    *
    * Queued items are not marked as "dirty" even when they are changed, and they
    * are not returned by the getChangedItems() method.
    *
-   * @param $item_ids
+   * @param array|false $item_ids
    *   Either an array with the IDs of the queued items. Or FALSE to mark all
    *   items as queued for the given indexes.
    * @param SearchApiIndex $index
    *   The index for which the items were queued.
    *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
+   *
+   * @deprecated
+   *   As of Search API 1.10, the cron queue is not used for indexing anymore,
+   *   therefore this method has become useless. It will be removed in the
+   *   Drupal 8 version of this module.
    */
   public function trackItemQueued($item_ids, SearchApiIndex $index);
 
   /**
-   * Set the tracking status of the given items to "indexed".
+   * Sets the tracking status of the given items to "indexed".
    *
    * @param array $item_ids
    *   The IDs of the indexed items.
-   * @param SearchApiIndex $indexes
+   * @param SearchApiIndex $index
    *   The index on which the items were indexed.
    *
    * @throws SearchApiDataSourceException
-   *   If the index doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function trackItemIndexed(array $item_ids, SearchApiIndex $index);
 
   /**
-   * Stop tracking the index status for the given items on the given indexes.
+   * Stops tracking the index status for the given items on the given indexes.
    *
    * @param array $item_ids
    *   The IDs of the removed items.
-   * @param array $indexes
+   * @param SearchApiIndex[] $indexes
    *   The indexes for which the deletions should be tracked.
    *
+   * @return SearchApiIndex[]|null
+   *   All indexes for which any items were deleted; or NULL if items were
+   *   deleted for all of them.
+   *
    * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function trackItemDelete(array $item_ids, array $indexes);
 
   /**
-   * Get a list of items that need to be indexed.
+   * Retrieves a list of items that need to be indexed.
    *
    * If possible, completely unindexed items should be returned before items
    * that were indexed but later changed. Also, items that were changed longer
@@ -212,16 +253,19 @@ interface SearchApiDataSourceControllerInterface {
    *
    * @param SearchApiIndex $index
    *   The index for which changed items should be returned.
-   * @param $limit
+   * @param int $limit
    *   The maximum number of items to return. Negative values mean "unlimited".
    *
    * @return array
    *   The IDs of items that need to be indexed for the given index.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   public function getChangedItems(SearchApiIndex $index, $limit = -1);
 
   /**
-   * Get information on how many items have been indexed for a certain index.
+   * Retrieves information on how many items have been indexed for a certain index.
    *
    * @param SearchApiIndex $index
    *   The index whose index status should be returned.
@@ -233,14 +277,89 @@ interface SearchApiDataSourceControllerInterface {
    *     index.
    *
    * @throws SearchApiDataSourceException
-   *   If the index doesn't use the same item type as this controller.
+   *   If any error state was encountered.
    */
   public function getIndexStatus(SearchApiIndex $index);
 
+  /**
+   * Retrieves the entity type of items from this datasource.
+   *
+   * @return string|null
+   *   An entity type string if the items provided by this datasource are
+   *   entities; NULL otherwise.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
+   */
+  public function getEntityType();
+
+  /**
+   * Form constructor for configuring the datasource for a given index.
+   *
+   * @param array $form
+   *   The form returned by configurationForm().
+   * @param array $form_state
+   *   The form state. $form_state['index'] will contain the edited index. If
+   *   this key is empty, then a new index is being created. In case of an edit,
+   *   $form_state['index']->options['datasource'] contains the previous
+   *   settings for the datasource.
+   *
+   * @return array|false
+   *   A form array for configuring this callback, or FALSE if no configuration
+   *   is possible.
+   */
+  public function configurationForm(array $form, array &$form_state);
+
+  /**
+   * Validation callback for the form returned by configurationForm().
+   *
+   * This method will only be called if that form was non-empty.
+   *
+   * @param array $form
+   *   The form returned by configurationForm().
+   * @param array $values
+   *   The part of the $form_state['values'] array corresponding to this form.
+   * @param array $form_state
+   *   The complete form state.
+   */
+  public function configurationFormValidate(array $form, array &$values, array &$form_state);
+
+  /**
+   * Submit callback for the form returned by configurationForm().
+   *
+   * This method will only be called if that form was non-empty.
+   *
+   * Any necessary changes to the submitted values should be made, afterwards
+   * they will automatically be stored as the index's "datasource" options. The
+   * method can also be used by the datasource controller to react to the
+   * possible change in its settings.
+   *
+   * @param array $form
+   *   The form returned by configurationForm().
+   * @param array $values
+   *   The part of the $form_state['values'] array corresponding to this form.
+   * @param array $form_state
+   *   The complete form state.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state);
+
+  /**
+   * Returns a summary of an index's current datasource configuration.
+   *
+   * @param SearchApiIndex $index
+   *   The index whose datasource configuration should be summarized.
+   *
+   * @return string|null
+   *   A translated string describing the index's current datasource
+   *   configuration. Or NULL, if there is no configuration (or no description
+   *   is available).
+   */
+  public function getConfigurationSummary(SearchApiIndex $index);
+
 }
 
 /**
- * Default base class for the SearchApiDataSourceControllerInterface.
+ * Provides a default base class for datasource controllers.
  *
  * Contains default implementations for a number of methods which will be
  * similar for most data sources. Concrete data sources can decide to extend
@@ -264,6 +383,15 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
    */
   protected $type;
 
+  /**
+   * The entity type for this controller instance.
+   *
+   * @var string|null
+   *
+   * @see getEntityType()
+   */
+  protected $entityType = NULL;
+
   /**
    * The info array for the item type, as specified via
    * hook_search_api_item_type_info().
@@ -306,57 +434,86 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   protected $changedColumn = 'changed';
 
   /**
-   * Constructor for a data source controller.
-   *
-   * @param $type
-   *   The item type for which this controller is created.
+   * {@inheritdoc}
    */
   public function __construct($type) {
     $this->type = $type;
     $this->info = search_api_get_item_type_info($type);
+
+    if (!empty($this->info['entity_type'])) {
+      $this->entityType = $this->info['entity_type'];
+    }
   }
 
   /**
-   * Get a metadata wrapper for the item type of this data source controller.
-   *
-   * @param $item
-   *   Unless NULL, an item of the item type for this controller to be wrapped.
-   * @param array $info
-   *   Optionally, additional information that should be used for creating the
-   *   wrapper. Uses the same format as entity_metadata_wrapper().
-   *
-   * @return EntityMetadataWrapper
-   *   A wrapper for the item type of this data source controller, according to
-   *   the info array, and optionally loaded with the given data.
-   *
-   * @see entity_metadata_wrapper()
+   * {@inheritdoc}
+   */
+  public function getEntityType() {
+    return $this->entityType;
+  }
+
+  /**
+   * {@inheritdoc}
    */
   public function getMetadataWrapper($item = NULL, array $info = array()) {
     $info += $this->getPropertyInfo();
-    return entity_metadata_wrapper($this->type, $item, $info);
+    return entity_metadata_wrapper($this->entityType ? $this->entityType : $this->type, $item, $info);
   }
 
   /**
-   * Helper method that can be used by subclasses to specify the property
-   * information to use when creating a metadata wrapper.
+   * Retrieves the property info for this item type.
+   *
+   * This is a helper method for getMetadataWrapper() that can be used by
+   * subclasses to specify the property information to use when creating a
+   * metadata wrapper.
+   *
+   * The data structure uses largely the format specified in
+   * hook_entity_property_info(). However, the first level of keys (containing
+   * the entity types) is omitted, and the "properties" key is called
+   * "property info" instead. So, an example return value would look like this:
+   *
+   * @code
+   * return array(
+   *   'property info' => array(
+   *     'foo' => array(
+   *       'label' => t('Foo'),
+   *       'type' => 'text',
+   *     ),
+   *     'bar' => array(
+   *       'label' => t('Bar'),
+   *       'type' => 'list<integer>',
+   *     ),
+   *   ),
+   * );
+   * @endcode
+   *
+   * SearchApiExternalDataSourceController::getPropertyInfo() contains a working
+   * example of this method.
+   *
+   * If the item type is an entity type, no additional property information is
+   * required, the method will thus just return an empty array. You can still
+   * use this to append additional properties to the entities, or the like,
+   * though.
    *
    * @return array
-   *   Property information as specified by hook_entity_property_info().
+   *   Property information as specified by entity_metadata_wrapper().
    *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
+   *
+   * @see getMetadataWrapper()
    * @see hook_entity_property_info()
    */
   protected function getPropertyInfo() {
+    // If this is an entity type, no additional property info is needed.
+    if ($this->entityType) {
+      return array();
+    }
     throw new SearchApiDataSourceException(t('No known property information for type @type.', array('@type' => $this->type)));
   }
 
   /**
-   * Get the unique ID of an item.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either the unique ID of the item, or NULL if none is available.
+   * {@inheritdoc}
    */
   public function getItemId($item) {
     $id_info = $this->getIdFieldInfo();
@@ -370,13 +527,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Get a human-readable label for an item.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either a human-readable label for the item, or NULL if none is available.
+   * {@inheritdoc}
    */
   public function getItemLabel($item) {
     $label = $this->getMetadataWrapper($item)->label();
@@ -384,33 +535,14 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Get a URL at which the item can be viewed on the web.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either an array containing the 'path' and 'options' keys used to build
-   *   the URL of the item, and matching the signature of url(), or NULL if the
-   *   item has no URL of its own.
+   * {@inheritdoc}
    */
   public function getItemUrl($item) {
     return NULL;
   }
 
   /**
-   * Initialize tracking of the index status of items for the given indexes.
-   *
-   * All currently known items of this data source's type should be inserted
-   * into the tracking table for the given indexes, with status "changed". If
-   * items were already present, these should also be set to "changed" and not
-   * be inserted again.
-   *
-   * @param array $indexes
-   *   The SearchApiIndex objects for which item tracking should be initialized.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function startTracking(array $indexes) {
     if (!$this->table) {
@@ -424,27 +556,23 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Helper method that can be used by subclasses instead of implementing startTracking().
-   *
    * Returns the IDs of all items that are known for this controller's type.
    *
+   * Helper method that can be used by subclasses instead of implementing
+   * startTracking().
+   *
    * @return array
    *   An array containing all item IDs for this type.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
    */
   protected function getAllItemIds() {
     throw new SearchApiDataSourceException(t('Items not known for type @type.', array('@type' => $this->type)));
   }
 
   /**
-   * Stop tracking of the index status of items for the given indexes.
-   *
-   * The tracking tables of the given indexes should be completely cleared.
-   *
-   * @param array $indexes
-   *   The SearchApiIndex objects for which item tracking should be stopped.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function stopTracking(array $indexes) {
     if (!$this->table) {
@@ -454,28 +582,24 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
     // will mostly be called with only one index.
     foreach ($indexes as $index) {
       $this->checkIndex($index);
-      $query = db_delete($this->table)
+      db_delete($this->table)
         ->condition($this->indexIdColumn, $index->id)
         ->execute();
     }
   }
 
   /**
-   * Start tracking the index status for the given items on the given indexes.
-   *
-   * @param array $item_ids
-   *   The IDs of new items to track.
-   * @param array $indexes
-   *   The indexes for which items should be tracked.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function trackItemInsert(array $item_ids, array $indexes) {
-    if (!$this->table) {
+    if (!$this->table || $item_ids === array()) {
       return;
     }
 
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+    }
+
     // Since large amounts of items can overstrain the database, only add items
     // in chunks.
     foreach (array_chunk($item_ids, 1000) as $chunk) {
@@ -483,7 +607,6 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
         ->fields(array($this->itemIdColumn, $this->indexIdColumn, $this->changedColumn));
       foreach ($chunk as $item_id) {
         foreach ($indexes as $index) {
-          $this->checkIndex($index);
           $insert->values(array(
             $this->itemIdColumn => $item_id,
             $this->indexIdColumn => $index->id,
@@ -496,60 +619,53 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Set the tracking status of the given items to "changed"/"dirty".
-   *
-   * Unless $dequeue is set to TRUE, this operation is ignored for items whose
-   * status is not "indexed".
-   *
-   * @param $item_ids
-   *   Either an array with the IDs of the changed items. Or FALSE to mark all
-   *   items as changed for the given indexes.
-   * @param array $indexes
-   *   The indexes for which the change should be tracked.
-   * @param $dequeue
-   *   If set to TRUE, also change the status of queued items.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) {
-    if (!$this->table) {
-      return;
+    if (!$this->table || $item_ids === array()) {
+      return NULL;
     }
-    $index_ids = array();
+
+    $indexes_by_id = array();
     foreach ($indexes as $index) {
       $this->checkIndex($index);
-      $index_ids[] = $index->id;
+      $update = db_update($this->table)
+        ->fields(array(
+          $this->changedColumn => REQUEST_TIME,
+        ))
+        ->condition($this->indexIdColumn, $index->id)
+        ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+      if ($item_ids !== FALSE) {
+        $update->condition($this->itemIdColumn, $item_ids, 'IN');
+      }
+      $update->execute();
+      $indexes_by_id[$index->id] = $index;
     }
-    $update = db_update($this->table)
-      ->fields(array(
-        $this->changedColumn => REQUEST_TIME,
-      ))
-      ->condition($this->indexIdColumn, $index_ids, 'IN')
-      ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+
+    // Determine and return the indexes with any changed items. If $item_ids is
+    // FALSE, all items are marked as changed and, thus, all indexes will be
+    // affected (unless they don't have any items, but no real point in treating
+    // that special case).
     if ($item_ids !== FALSE) {
-      $update->condition($this->itemIdColumn, $item_ids, 'IN');
+      $indexes_with_items = db_select($this->table, 't')
+        ->fields('t', array($this->indexIdColumn))
+        ->distinct()
+        ->condition($this->indexIdColumn, array_keys($indexes_by_id), 'IN')
+        ->condition($this->itemIdColumn, $item_ids, 'IN')
+        ->execute()
+        ->fetchCol();
+      return array_intersect_key($indexes_by_id, array_flip($indexes_with_items));
     }
-    $update->execute();
+
+    return NULL;
   }
 
   /**
-   * Set the tracking status of the given items to "queued".
-   *
-   * Queued items are not marked as "dirty" even when they are changed, and they
-   * are not returned by the getChangedItems() method.
-   *
-   * @param $item_ids
-   *   Either an array with the IDs of the queued items. Or FALSE to mark all
-   *   items as queued for the given indexes.
-   * @param SearchApiIndex $index
-   *   The index for which the items were queued.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function trackItemQueued($item_ids, SearchApiIndex $index) {
-    if (!$this->table) {
+    $this->checkIndex($index);
+    if (!$this->table || $item_ids === array()) {
       return;
     }
     $update = db_update($this->table)
@@ -564,18 +680,10 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Set the tracking status of the given items to "indexed".
-   *
-   * @param array $item_ids
-   *   The IDs of the indexed items.
-   * @param SearchApiIndex $indexes
-   *   The index on which the items were indexed.
-   *
-   * @throws SearchApiDataSourceException
-   *   If the index doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
-    if (!$this->table) {
+    if (!$this->table || $item_ids === array()) {
       return;
     }
     $this->checkIndex($index);
@@ -589,45 +697,30 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Stop tracking the index status for the given items on the given indexes.
-   *
-   * @param array $item_ids
-   *   The IDs of the removed items.
-   * @param array $indexes
-   *   The indexes for which the deletions should be tracked.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function trackItemDelete(array $item_ids, array $indexes) {
-    if (!$this->table) {
-      return;
+    if (!$this->table || $item_ids === array()) {
+      return NULL;
     }
-    $index_ids = array();
+
+    $ret = array();
+
     foreach ($indexes as $index) {
       $this->checkIndex($index);
-      $index_ids[] = $index->id;
+      $delete = db_delete($this->table)
+        ->condition($this->indexIdColumn, $index->id)
+        ->condition($this->itemIdColumn, $item_ids, 'IN');
+      if ($delete->execute()) {
+        $ret[] = $index;
+      }
     }
-    db_delete($this->table)
-      ->condition($this->itemIdColumn, $item_ids, 'IN')
-      ->condition($this->indexIdColumn, $index_ids, 'IN')
-      ->execute();
+
+    return $ret;
   }
 
   /**
-   * Get a list of items that need to be indexed.
-   *
-   * If possible, completely unindexed items should be returned before items
-   * that were indexed but later changed. Also, items that were changed longer
-   * ago should be favored.
-   *
-   * @param SearchApiIndex $index
-   *   The index for which changed items should be returned.
-   * @param $limit
-   *   The maximum number of items to return. Negative values mean "unlimited".
-   *
-   * @return array
-   *   The IDs of items that need to be indexed for the given index.
+   * {@inheritdoc}
    */
   public function getChangedItems(SearchApiIndex $index, $limit = -1) {
     if ($limit == 0) {
@@ -635,7 +728,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
     }
     $this->checkIndex($index);
     $select = db_select($this->table, 't');
-    $select->addField('t', 'item_id');
+    $select->addField('t', $this->itemIdColumn);
     $select->condition($this->indexIdColumn, $index->id);
     $select->condition($this->changedColumn, 0, '>');
     $select->orderBy($this->changedColumn, 'ASC');
@@ -646,16 +739,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Get information on how many items have been indexed for a certain index.
-   *
-   * @param SearchApiIndex $index
-   *   The index whose index status should be returned.
-   *
-   * @return array
-   *   An associative array containing two keys (in this order):
-   *   - indexed: The number of items already indexed in their latest version.
-   *   - total: The total number of items that have to be indexed for this
-   *     index.
+   * {@inheritdoc}
    */
   public function getIndexStatus(SearchApiIndex $index) {
     if (!$this->table) {
@@ -677,20 +761,51 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   }
 
   /**
-   * Helper method for ensuring that an index uses the same item type as this controller.
+   * {@inheritdoc}
+   */
+  public function configurationForm(array $form, array &$form_state) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfigurationSummary(SearchApiIndex $index) {
+    return NULL;
+  }
+
+  /**
+   * Checks whether the given index is valid for this datasource controller.
+   *
+   * Helper method used by various methods in this class. By default only checks
+   * whether the types match.
    *
    * @param SearchApiIndex $index
    *   The index to check.
    *
    * @throws SearchApiDataSourceException
-   *   If the index doesn't use the same type as this controller.
+   *   If the index doesn't fit to this datasource controller.
    */
   protected function checkIndex(SearchApiIndex $index) {
     if ($index->item_type != $this->type) {
       $index_type = search_api_get_item_type_info($index->item_type);
       $index_type = empty($index_type['name']) ? $index->item_type : $index_type['name'];
-      $msg = t('Invalid index @index of type @index_type passed to data source controller for type @this_type.',
-          array('@index' => $index->name, '@index_type' => $index_type, '@this_type' => $this->info['name']));
+      $msg = t(
+        'Invalid index @index of type @index_type passed to data source controller for type @this_type.',
+        array('@index' => $index->name, '@index_type' => $index_type, '@this_type' => $this->info['name'])
+      );
       throw new SearchApiDataSourceException($msg);
     }
   }

+ 257 - 93
sites/all/modules/contrib/search/search_api/includes/datasource_entity.inc

@@ -6,53 +6,82 @@
  */
 
 /**
- * Data source for all entities known to the Entity API.
+ * Represents a datasource for all entities known to the Entity API.
  */
 class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceController {
 
   /**
-   * Return information on the ID field for this controller's type.
+   * Entity type info for this type.
    *
-   * @return array
-   *   An associative array containing the following keys:
-   *   - key: The property key for the ID field, as used in the item wrapper.
-   *   - type: The type of the ID field. Has to be one of the types from
-   *     search_api_field_types(). List types ("list<*>") are not allowed.
+   * @var array
+   */
+  protected $entityInfo;
+
+  /**
+   * The ID key of this entity type, if any.
+   *
+   * @var string|null
+   */
+  protected $idKey;
+
+  /**
+   * The bundle key of this entity type, if any.
+   *
+   * @var string|null
+   */
+  protected $bundleKey;
+
+  /**
+   * Cached return values for getBundles(), keyed by index machine name.
+   *
+   * @var array
+   */
+  protected $bundles = array();
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct($type) {
+    parent::__construct($type);
+
+    $this->entityInfo = entity_get_info($this->entityType);
+    if (!empty($this->entityInfo['entity keys']['id'])) {
+      $this->idKey = $this->entityInfo['entity keys']['id'];
+    }
+    if (!empty($this->entityInfo['entity keys']['bundle'])) {
+      $this->bundleKey = $this->entityInfo['entity keys']['bundle'];
+    }
+  }
+
+  /**
+   * {@inheritdoc}
    */
   public function getIdFieldInfo() {
-    $info = entity_get_info($this->type);
-    $properties = entity_get_property_info($this->type);
-    if (empty($info['entity keys']['id'])) {
-      throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $info['label'])));
+    $properties = entity_get_property_info($this->entityType);
+    if (!$this->idKey) {
+      throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $this->entityInfo['label'])));
     }
-    $field = $info['entity keys']['id'];
-    if (empty($properties['properties'][$field]['type'])) {
-      throw new SearchApiDataSourceException(t("Entity type @type doesn't specify a type for the @prop property.", array('@type' => $info['label'], '@prop' => $field)));
+    if (empty($properties['properties'][$this->idKey]['type'])) {
+      throw new SearchApiDataSourceException(t("Entity type @type doesn't specify a type for the @prop property.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
     }
-    $type = $properties['properties'][$field]['type'];
+    $type = $properties['properties'][$this->idKey]['type'];
     if (search_api_is_list_type($type)) {
-      throw new SearchApiDataSourceException(t("Entity type @type uses list field @prop as its ID.", array('@type' => $info['label'], '@prop' => $field)));
+      throw new SearchApiDataSourceException(t("Entity type @type uses list field @prop as its ID.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
     }
     if ($type == 'token') {
       $type = 'string';
     }
     return array(
-      'key' => $field,
+      'key' => $this->idKey,
       'type' => $type,
     );
   }
 
   /**
-   * Load items of the type of this data source controller.
-   *
-   * @param array $ids
-   *   The IDs of the items to laod.
-   *
-   * @return array
-   *   The loaded items, keyed by ID.
+   * {@inheritdoc}
    */
   public function loadItems(array $ids) {
-    $items = entity_load($this->type, $ids);
+    $items = entity_load($this->entityType, $ids);
     // If some items couldn't be loaded, remove them from tracking.
     if (count($items) != count($ids)) {
       $ids = array_flip($ids);
@@ -65,65 +94,33 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
   }
 
   /**
-   * Get a metadata wrapper for the item type of this data source controller.
-   *
-   * @param $item
-   *   Unless NULL, an item of the item type for this controller to be wrapped.
-   * @param array $info
-   *   Optionally, additional information that should be used for creating the
-   *   wrapper. Uses the same format as entity_metadata_wrapper().
-   *
-   * @return EntityMetadataWrapper
-   *   A wrapper for the item type of this data source controller, according to
-   *   the info array, and optionally loaded with the given data.
-   *
-   * @see entity_metadata_wrapper()
+   * {@inheritdoc}
    */
   public function getMetadataWrapper($item = NULL, array $info = array()) {
-    return entity_metadata_wrapper($this->type, $item, $info);
+    return entity_metadata_wrapper($this->entityType, $item, $info);
   }
 
   /**
-   * Get the unique ID of an item.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either the unique ID of the item, or NULL if none is available.
+   * {@inheritdoc}
    */
   public function getItemId($item) {
-    $id = entity_id($this->type, $item);
+    $id = entity_id($this->entityType, $item);
     return $id ? $id : NULL;
   }
 
   /**
-   * Get a human-readable label for an item.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either a human-readable label for the item, or NULL if none is available.
+   * {@inheritdoc}
    */
   public function getItemLabel($item) {
-    $label = entity_label($this->type, $item);
+    $label = entity_label($this->entityType, $item);
     return $label ? $label : NULL;
   }
 
   /**
-   * Get a URL at which the item can be viewed on the web.
-   *
-   * @param $item
-   *   An item of this controller's type.
-   *
-   * @return
-   *   Either an array containing the 'path' and 'options' keys used to build
-   *   the URL of the item, and matching the signature of url(), or NULL if the
-   *   item has no URL of its own.
+   * {@inheritdoc}
    */
   public function getItemUrl($item) {
-    if ($this->type == 'file') {
+    if ($this->entityType == 'file') {
       return array(
         'path' => file_create_url($item->uri),
         'options' => array(
@@ -132,23 +129,12 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
         ),
       );
     }
-    $url = entity_uri($this->type, $item);
+    $url = entity_uri($this->entityType, $item);
     return $url ? $url : NULL;
   }
 
   /**
-   * Initialize tracking of the index status of items for the given indexes.
-   *
-   * All currently known items of this data source's type should be inserted
-   * into the tracking table for the given indexes, with status "changed". If
-   * items were already present, these should also be set to "changed" and not
-   * be inserted again.
-   *
-   * @param array $indexes
-   *   The SearchApiIndex objects for which item tracking should be initialized.
-   *
-   * @throws SearchApiDataSourceException
-   *   If any of the indexes doesn't use the same item type as this controller.
+   * {@inheritdoc}
    */
   public function startTracking(array $indexes) {
     if (!$this->table) {
@@ -158,24 +144,60 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
     // all items again without any key conflicts.
     $this->stopTracking($indexes);
 
-    $entity_info = entity_get_info($this->type);
-
-    if (!empty($entity_info['base table'])) {
+    if (!empty($this->entityInfo['base table']) && $this->idKey) {
       // Use a subselect, which will probably be much faster than entity_load().
 
       // Assumes that all entities use the "base table" property and the
       // "entity keys[id]" in the same way as the default controller.
-      $id_field = $entity_info['entity keys']['id'];
-      $table = $entity_info['base table'];
+      $table = $this->entityInfo['base table'];
 
-      // We could also use a single insert (with a JOIN in the nested query),
+      // We could also use a single insert (with a UNION in the nested query),
       // but this method will be mostly called with a single index, anyways.
       foreach ($indexes as $index) {
         // Select all entity ids.
         $query = db_select($table, 't');
-        $query->addField('t', $id_field, 'item_id');
+        $query->addField('t', $this->idKey, 'item_id');
         $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
         $query->addExpression('1', 'changed');
+        if ($bundles = $this->getIndexBundles($index)) {
+          $bundle_column = $this->bundleKey;
+          if (!db_field_exists($table, $bundle_column)) {
+            if ($this->entityType == 'taxonomy_term') {
+              $bundle_column = 'vid';
+              $bundles = db_query('SELECT vid FROM {taxonomy_vocabulary} WHERE machine_name IN (:bundles)', array(':bundles' => $bundles))->fetchCol();
+            }
+            elseif ($this->entityType == 'flagging') {
+              $bundle_column = 'fid';
+              $bundles = db_query('SELECT fid FROM {flag} WHERE name IN (:bundles)', array(':bundles' => $bundles))->fetchCol();
+            }
+            elseif ($this->entityType == 'comment') {
+              // Comments are significantly more complicated, since they don't
+              // store their bundle explicitly in their database table. Instead,
+              // we need to get all the nodes from the enabled types and filter
+              // by those.
+              $bundle_column = 'nid';
+              $node_types = array();
+              foreach ($bundles as $bundle) {
+                if (substr($bundle, 0, 13) === 'comment_node_') {
+                  $node_types[] = substr($bundle, 13);
+                }
+              }
+              if ($node_types) {
+                $bundles = db_query('SELECT nid FROM {node} WHERE type IN (:bundles)', array(':bundles' => $node_types))->fetchCol();
+              }
+              else {
+                continue;
+              }
+            }
+            else {
+              $this->startTrackingFallback(array($index->machine_name => $index));
+              continue;
+            }
+          }
+          if ($bundles) {
+            $query->condition($bundle_column, $bundles);
+          }
+        }
 
         // INSERT ... SELECT ...
         db_insert($this->table)
@@ -184,23 +206,165 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
       }
     }
     else {
-      // In the absence of a 'base table', use the slow entity_load().
-      parent::startTracking($indexes);
+      $this->startTrackingFallback($indexes);
+    }
+  }
+
+  /**
+   * Initializes tracking of the index status of items for the given indexes.
+   *
+   * Fallback for when the items cannot directly be loaded into
+   * {search_api_item} via "INSERT INTO … SELECT …".
+   *
+   * @param SearchApiIndex[] $indexes
+   *   The indexes for which item tracking should be initialized.
+   *
+   * @throws SearchApiDataSourceException
+   *   Thrown if any error state was encountered.
+   *
+   * @see SearchApiEntityDataSourceController::startTracking()
+   */
+  protected function startTrackingFallback(array $indexes) {
+    // In the absence of a 'base table', use the slower way of retrieving the
+    // items and inserting them "manually". For each index we get the item IDs
+    // (since selected bundles might differ) and insert all of them as new.
+    foreach ($indexes as $index) {
+      $query = new EntityFieldQuery();
+      $query->entityCondition('entity_type', $this->entityType);
+      if ($bundles = $this->getIndexBundles($index)) {
+        $query->entityCondition('bundle', $bundles);
+      }
+      $result = $query->execute();
+      $ids = !empty($result[$this->entityType]) ? array_keys($result[$this->entityType]) : array();
+      if ($ids) {
+        $this->trackItemInsert($ids, array($index));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function trackItemInsert(array $item_ids, array $indexes) {
+    $ret = array();
+
+    foreach ($indexes as $index_id => $index) {
+      $ids = $item_ids;
+      if ($bundles = $this->getIndexBundles($index)) {
+        $ids = drupal_map_assoc($ids);
+        foreach (entity_load($this->entityType, $ids) as $id => $entity) {
+          if (empty($bundles[$entity->{$this->bundleKey}])) {
+            unset($ids[$id]);
+          }
+        }
+      }
+      if ($ids) {
+        parent::trackItemInsert($ids, array($index));
+        $ret[$index_id] = $index;
+      }
+    }
+
+    return $ret;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm(array $form, array &$form_state) {
+    $options = $this->getAvailableBundles();
+    if (!$options) {
+      return FALSE;
+    }
+    $form['bundles'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Bundles'),
+      '#description' => t('Restrict the entity bundles that will be included in this index. Leave blank to include all bundles. This setting cannot be changed for enabled indexes.'),
+      '#options' => array_map('check_plain', $options),
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+      '#disabled' => !empty($form_state['index']) && $form_state['index']->enabled,
+    );
+    if (!empty($form_state['index']->options['datasource'])) {
+      $form['bundles']['#default_value'] = drupal_map_assoc($form_state['index']->options['datasource']['bundles']);
     }
+    return $form;
   }
 
   /**
-   * Helper method that can be used by subclasses instead of implementing startTracking().
+   * {@inheritdoc}
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    if (!empty($values['bundles'])) {
+      $values['bundles'] = array_keys(array_filter($values['bundles']));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfigurationSummary(SearchApiIndex $index) {
+    if ($bundles = $this->getIndexBundles($index)) {
+      $args['!bundles'] = implode(', ', array_intersect_key($this->getAvailableBundles(), $bundles));
+      return format_plural(count($bundles), 'Indexed bundle: !bundles.', 'Indexed bundles: !bundles.', $args);
+    }
+    return NULL;
+  }
+
+  /**
+   * Retrieves the available bundles for this entity type.
    *
-   * Returns the IDs of all items that are known for this controller's type.
+   * @return array
+   *   An array (which might be empty) mapping this entity type's bundle keys to
+   *   their labels.
+   */
+  protected function getAvailableBundles() {
+    if (!$this->bundleKey || empty($this->entityInfo['bundles'])) {
+      return array();
+    }
+    $bundles = array();
+    foreach ($this->entityInfo['bundles'] as $bundle => $bundle_info) {
+      $bundles[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+    }
+    return $bundles;
+  }
+
+  /**
+   * Computes the bundles that should be indexed for an index.
    *
-   * Will be used when the entity type doesn't specify a "base table".
+   * @param SearchApiIndex $index
+   *   The index for which to check.
    *
    * @return array
-   *   An array containing all item IDs for this type.
+   *   An array containing all bundles that should be included in this index, as
+   *   both the keys and values. An empty array means all current bundles should
+   *   be included.
+   *
+   * @throws SearchApiException
+   *   If the index doesn't belong to this datasource controller.
    */
-  protected function getAllItemIds() {
-    return array_keys(entity_load($this->type));
+  protected function getIndexBundles(SearchApiIndex $index) {
+    $this->checkIndex($index);
+
+    if (!isset($this->bundles[$index->machine_name])) {
+      $this->bundles[$index->machine_name] = array();
+      if (!empty($index->options['datasource']['bundles'])) {
+        // We retrieve the available bundles here to check whether all of them
+        // are included by the index's setting. In this case, we return an empty
+        // array, too, to save on complexity.
+        // On the other hand, we still want to return deleted bundles since we
+        // do not want to suddenly include all bundles when all selected bundles
+        // were deleted.
+        $available = $this->getAvailableBundles();
+        foreach ($index->options['datasource']['bundles'] as $bundle) {
+          $this->bundles[$index->machine_name][$bundle] = $bundle;
+          unset($available[$bundle]);
+        }
+        if (!$available) {
+          $this->bundles[$index->machine_name] = array();
+        }
+      }
+    }
+
+    return $this->bundles[$index->machine_name];
   }
 
 }

+ 4 - 13
sites/all/modules/contrib/search/search_api/includes/datasource_external.inc

@@ -19,9 +19,7 @@
  * will only have to specify some property information in getPropertyInfo(). If
  * you have a custom service class which already returns the extracted fields
  * with the search results, you will only have to provide a label and a type for
- * each field. To make this use case easier, there is also a
- * getFieldInformation() method which you can implement instead of directly
- * implementing getPropertyInfo().
+ * each field.
  */
 class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceController {
 
@@ -51,7 +49,7 @@ class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceC
    * loadable, specify a function here.
    *
    * @param array $ids
-   *   The IDs of the items to laod.
+   *   The IDs of the items to load.
    *
    * @return array
    *   The loaded items, keyed by ID.
@@ -61,16 +59,9 @@ class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceC
   }
 
   /**
-   * Helper method that can be used by subclasses to specify the property
-   * information to use when creating a metadata wrapper.
+   * Overrides SearchApiAbstractDataSourceController::getPropertyInfo().
    *
-   * For most use cases, you will have to override this method to provide the
-   * real property information for your item type.
-   *
-   * @return array
-   *   Property information as specified by hook_entity_property_info().
-   *
-   * @see hook_entity_property_info()
+   * Only returns a single string ID field.
    */
   protected function getPropertyInfo() {
     $info['property info']['id'] = array(

+ 360 - 0
sites/all/modules/contrib/search/search_api/includes/datasource_multiple.inc

@@ -0,0 +1,360 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiCombinedEntityDataSourceController.
+ */
+
+/**
+ * Provides a datasource for indexing multiple types of entities.
+ */
+class SearchApiCombinedEntityDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $table = 'search_api_item_string_id';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIdFieldInfo() {
+    return array(
+      'key' => 'item_id',
+      'type' => 'string',
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadItems(array $ids) {
+    $ids_by_type = array();
+    foreach ($ids as $id) {
+      list($type, $entity_id) = explode('/', $id);
+      $ids_by_type[$type][$entity_id] = $id;
+    }
+
+    $items = array();
+    foreach ($ids_by_type as $type => $type_ids) {
+      foreach (entity_load($type, array_keys($type_ids)) as $entity_id => $entity) {
+        $id = $type_ids[$entity_id];
+        $item = (object) array($type => $entity);
+        $item->item_id = $id;
+        $item->item_type = $type;
+        $item->item_entity_id = $entity_id;
+        $item->item_bundle = NULL;
+        // Add the item language so the "search_api_language" field will work
+        // correctly.
+        $item->language = isset($entity->language) ? $entity->language : NULL;
+        try {
+          list(, , $bundle) = entity_extract_ids($type, $entity);
+          $item->item_bundle = $bundle ? "$type:$bundle" : NULL;
+        }
+        catch (EntityMalformedException $e) {
+          // Will probably make problems at some other place, but for extracting
+          // the bundle it is really not critical enough to fail on – just
+          // ignore this exception.
+        }
+        $items[$id] = $item;
+        unset($type_ids[$entity_id]);
+      }
+      if ($type_ids) {
+        search_api_track_item_delete($type, array_keys($type_ids));
+      }
+    }
+
+    return $items;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPropertyInfo() {
+    $info = array(
+      'item_id' => array(
+        'label' => t('ID'),
+        'description' => t('The combined ID of the item, containing both entity type and entity ID.'),
+        'type' => 'token',
+      ),
+      'item_type' => array(
+        'label' => t('Entity type'),
+        'description' => t('The entity type of the item.'),
+        'type' => 'token',
+        'options list' => 'search_api_entity_type_options_list',
+      ),
+      'item_entity_id' => array(
+        'label' => t('Entity ID'),
+        'description' => t('The entity ID of the item.'),
+        'type' => 'token',
+      ),
+      'item_bundle' => array(
+        'label' => t('Bundle'),
+        'description' => t('The bundle of the item, if applicable.'),
+        'type' => 'token',
+        'options list' => 'search_api_combined_bundle_options_list',
+      ),
+      'item_label' => array(
+        'label' => t('Label'),
+        'description' => t('The label of the item.'),
+        'type' => 'text',
+        // Since this needs a bit more computation than the others, we don't
+        // include it always when loading the item but use a getter callback.
+        'getter callback' => 'search_api_get_multi_type_item_label',
+      ),
+    );
+
+    foreach ($this->getSelectedEntityTypeOptions() as $type => $label) {
+      $info[$type] = array(
+        'label' => $label,
+        'description' => t('The indexed entity, if it is of type %type.', array('%type' => $label)),
+        'type' => $type,
+      );
+    }
+
+    return array('property info' => $info);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemId($item) {
+    return isset($item->item_id) ? $item->item_id : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemLabel($item) {
+    return search_api_get_multi_type_item_label($item);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemUrl($item) {
+    if ($item->item_type == 'file') {
+      return array(
+        'path' => file_create_url($item->file->uri),
+        'options' => array(
+          'entity_type' => 'file',
+          'entity' => $item,
+        ),
+      );
+    }
+    $url = entity_uri($item->item_type, $item->{$item->item_type});
+    return $url ? $url : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function startTracking(array $indexes) {
+    if (!$this->table) {
+      return;
+    }
+    // We first clear the tracking table for all indexes, so we can just insert
+    // all items again without any key conflicts.
+    $this->stopTracking($indexes);
+
+    foreach ($indexes as $index) {
+      $types = $this->getEntityTypes($index);
+
+      // Wherever possible, use a sub-select instead of the much slower
+      // entity_load().
+      foreach ($types as $type) {
+        $entity_info = entity_get_info($type);
+
+        if (!empty($entity_info['base table'])) {
+          // Assumes that all entities use the "base table" property and the
+          // "entity keys[id]" in the same way as the default controller.
+          $id_field = $entity_info['entity keys']['id'];
+          $table = $entity_info['base table'];
+
+          // Select all entity ids.
+          $query = db_select($table, 't');
+          $query->addExpression("CONCAT(:prefix, t.$id_field)", 'item_id', array(':prefix' => $type . '/'));
+          $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+          $query->addExpression('1', 'changed');
+
+          // INSERT ... SELECT ...
+          db_insert($this->table)
+            ->from($query)
+            ->execute();
+
+          unset($types[$type]);
+        }
+      }
+
+      // In the absence of a "base table", use the slow entity_load().
+      if ($types) {
+        foreach ($types as $type) {
+          $query = new EntityFieldQuery();
+          $query->entityCondition('entity_type', $type);
+          $result = $query->execute();
+          $ids = !empty($result[$type]) ? array_keys($result[$type]) : array();
+          if ($ids) {
+            foreach ($ids as $i => $id) {
+              $ids[$i] = $type . '/' . $id;
+            }
+            $this->trackItemInsert($ids, array($index), TRUE);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Starts tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of new items to track.
+   * @param SearchApiIndex[] $indexes
+   *   The indexes for which items should be tracked.
+   * @param bool $skip_type_check
+   *   (optional) If TRUE, don't check whether the type matches the index's
+   *   datasource configuration. Internal use only.
+   *
+   * @return SearchApiIndex[]|null
+   *   All indexes for which any items were added; or NULL if items were added
+   *   for all of them.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes, $skip_type_check = FALSE) {
+    $ret = array();
+
+    foreach ($indexes as $index_id => $index) {
+      $ids = drupal_map_assoc($item_ids);
+
+      if (!$skip_type_check) {
+        $types = $this->getEntityTypes($index);
+        foreach ($ids as $id) {
+          list($type) = explode('/', $id);
+          if (!isset($types[$type])) {
+            unset($ids[$id]);
+          }
+        }
+      }
+
+      if ($ids) {
+        parent::trackItemInsert($ids, array($index));
+        $ret[$index_id] = $index;
+      }
+    }
+
+    return $ret;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm(array $form, array &$form_state) {
+    $form['types'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Entity types'),
+      '#description' => t('Select the entity types which should be included in this index.'),
+      '#options' => array_map('check_plain', search_api_entity_type_options_list()),
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+      '#disabled' => !empty($form_state['index']),
+      '#required' => TRUE,
+    );
+    if (!empty($form_state['index']->options['datasource']['types'])) {
+      $form['types']['#default_value'] = $this->getEntityTypes($form_state['index']);
+    }
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    if (!empty($values['types'])) {
+      $values['types'] = array_keys(array_filter($values['types']));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfigurationSummary(SearchApiIndex $index) {
+    if ($type_labels = $this->getSelectedEntityTypeOptions($index)) {
+      $args['!types'] = implode(', ', $type_labels);
+      return format_plural(count($type_labels), 'Indexed entity types: !types.', 'Indexed entity types: !types.', $args);
+    }
+    return NULL;
+  }
+
+  /**
+   * Retrieves the index for which the current method was called.
+   *
+   * Very ugly method which uses the stack trace to find the right object.
+   *
+   * @return SearchApiIndex
+   *   The active index.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getCallingIndex() {
+    foreach (debug_backtrace() as $trace) {
+      if (isset($trace['object']) && $trace['object'] instanceof SearchApiIndex) {
+        return $trace['object'];
+      }
+    }
+    // If there's only a single index on the site, it's also easy.
+    $indexes = search_api_index_load_multiple(FALSE);
+    if (count($indexes) === 1) {
+      return reset($indexes);
+    }
+    throw new SearchApiException('Could not determine the active index of the datasource.');
+  }
+
+  /**
+   * Returns the entity types for which this datasource is configured.
+   *
+   * Depends on the index from which this method is (indirectly) called.
+   *
+   * @param SearchApiIndex $index
+   *   (optional) The index for which to get the enabled entity types. If not
+   *   given, will be determined automatically.
+   *
+   * @return string[]
+   *   The machine names of the datasource's enabled entity types, as both keys
+   *   and values.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getEntityTypes(SearchApiIndex $index = NULL) {
+    if (!$index) {
+      $index = $this->getCallingIndex();
+    }
+    if (isset($index->options['datasource']['types'])) {
+      return drupal_map_assoc($index->options['datasource']['types']);
+    }
+    return array();
+  }
+
+  /**
+   * Returns the selected entity type options for this datasource.
+   *
+   * Depends on the index from which this method is (indirectly) called.
+   *
+   * @param SearchApiIndex $index
+   *   (optional) The index for which to get the enabled entity types. If not
+   *   given, will be determined automatically.
+   *
+   * @return string[]
+   *   An associative array, mapping the machine names of the enabled entity
+   *   types to their labels.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getSelectedEntityTypeOptions(SearchApiIndex $index = NULL) {
+    return array_intersect_key(search_api_entity_type_options_list(), $this->getEntityTypes($index));
+  }
+
+}

+ 6 - 1
sites/all/modules/contrib/search/search_api/includes/exception.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiException.
+ */
+
 /**
  * Represents an exception or error that occurred in some part of the Search API
  * framework.
@@ -14,7 +19,7 @@ class SearchApiException extends Exception {
    */
   public function __construct($message = NULL) {
     if (!$message) {
-      $message = t('An error occcurred in the Search API framework.');
+      $message = t('An error occurred in the Search API framework.');
     }
     parent::__construct($message);
   }

+ 284 - 186
sites/all/modules/contrib/search/search_api/includes/index_entity.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiIndex.
+ */
+
 /**
  * Class representing a search index.
  */
@@ -43,6 +48,15 @@ class SearchApiIndex extends Entity {
    */
   protected $added_properties = NULL;
 
+  /**
+   * Static cache for the results of getFields().
+   *
+   * Can be accessed as follows: $this->fields[$only_indexed][$get_additional].
+   *
+   * @var array
+   */
+  protected $fields = array();
+
   /**
    * An array containing two arrays.
    *
@@ -101,7 +115,8 @@ class SearchApiIndex extends Entity {
   public $item_type;
 
   /**
-   * An array of options for configuring this index. The layout is as follows:
+   * An array of options for configuring this index. The layout is as follows
+   * (with all keys being optional):
    * - cron_limit: The maximum number of items to be indexed per cron batch.
    * - index_directly: Boolean setting whether entities are indexed immediately
    *   after they are created or updated.
@@ -136,6 +151,8 @@ class SearchApiIndex extends Entity {
    *   - weight: Used for sorting the processors.
    *   - settings: Processor-specific settings, configured via the processor's
    *     configuration form.
+   * - datasource: Datasource-specific settings, configured via the datasource's
+   *   configuration form.
    *
    * @var array
    */
@@ -158,29 +175,25 @@ class SearchApiIndex extends Entity {
   /**
    * Constructor as a helper to the parent constructor.
    */
-  public function __construct(array $values = array()) {
-    parent::__construct($values, 'search_api_index');
+  public function __construct(array $values = array(), $entity_type = 'search_api_index') {
+    parent::__construct($values, $entity_type);
   }
 
   /**
    * Execute necessary tasks for a newly created index.
    */
   public function postCreate() {
-    if ($this->enabled) {
-      $this->queueItems();
-    }
-    $server = $this->server();
-    if ($server) {
-      // Tell the server about the new index.
-      if ($server->enabled) {
+    try {
+      if ($server = $this->server()) {
+        // Tell the server about the new index.
         $server->addIndex($this);
+        if ($this->enabled) {
+          $this->queueItems();
+        }
       }
-      else {
-        $tasks = variable_get('search_api_tasks', array());
-        // When we add or remove an index, we can ignore all other tasks.
-        $tasks[$server->machine_name][$this->machine_name] = array('add');
-        variable_set('search_api_tasks', $tasks);
-      }
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
     }
   }
 
@@ -188,15 +201,13 @@ class SearchApiIndex extends Entity {
    * Execute necessary tasks when the index is removed from the database.
    */
   public function postDelete() {
-    if ($server = $this->server()) {
-      if ($server->enabled) {
+    try {
+      if ($server = $this->server()) {
         $server->removeIndex($this);
       }
-      else {
-        $tasks = variable_get('search_api_tasks', array());
-        $tasks[$server->machine_name][$this->machine_name] = array('remove');
-        variable_set('search_api_tasks', $tasks);
-      }
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
     }
 
     // Stop tracking entities for indexing.
@@ -208,7 +219,12 @@ class SearchApiIndex extends Entity {
    */
   public function queueItems() {
     if (!$this->read_only) {
-      $this->datasource()->startTracking(array($this));
+      try {
+        $this->datasource()->startTracking(array($this));
+      }
+      catch (SearchApiException $e) {
+        watchdog_exception('search_api', $e);
+      }
     }
   }
 
@@ -216,15 +232,20 @@ class SearchApiIndex extends Entity {
    * Remove all records of entities to index.
    */
   public function dequeueItems() {
-    $this->datasource()->stopTracking(array($this));
-    _search_api_empty_cron_queue($this);
+    try {
+      $this->datasource()->stopTracking(array($this));
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
+    }
   }
 
   /**
-   * Saves this index to the database, either creating a new record or updating
-   * an existing one.
+   * Saves this index to the database.
    *
-   * @return
+   * Either creates a new record or updates the existing one with the same ID.
+   *
+   * @return int|false
    *   Failure to save the index will return FALSE. Otherwise, SAVED_NEW or
    *   SAVED_UPDATED is returned depending on the operation performed. $this->id
    *   will be set if a new index was inserted.
@@ -233,15 +254,25 @@ class SearchApiIndex extends Entity {
     if (empty($this->description)) {
       $this->description = NULL;
     }
-    if (empty($this->server)) {
+    $server = FALSE;
+    if (!empty($this->server)) {
+      $server = search_api_server_load($this->server);
+      if (!$server) {
+        $vars['%server'] = $this->server;
+        $vars['%index'] = $this->name;
+        watchdog('search_api', 'Unknown server %server specified for index %index.', $vars, WATCHDOG_ERROR);
+      }
+    }
+    if (!$server) {
       $this->server = NULL;
       $this->enabled = FALSE;
     }
-    // This will also throw an exception if the server doesn't exist – which is good.
-    elseif (!$this->server(TRUE)->enabled) {
-      $this->enabled = FALSE;
+    if (!empty($this->options['fields'])) {
+      ksort($this->options['fields']);
     }
 
+    $this->resetCaches();
+
     return parent::save();
   }
 
@@ -254,7 +285,7 @@ class SearchApiIndex extends Entity {
    * @param array $fields
    *   The new field values.
    *
-   * @return
+   * @return int|false
    *   SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
    *   the specified values.
    */
@@ -283,7 +314,7 @@ class SearchApiIndex extends Entity {
   /**
    * Schedules this search index for re-indexing.
    *
-   * @return
+   * @return bool
    *   TRUE on success, FALSE on failure.
    */
   public function reindex() {
@@ -298,7 +329,7 @@ class SearchApiIndex extends Entity {
   /**
    * Clears this search index and schedules all of its items for re-indexing.
    *
-   * @return
+   * @return bool
    *   TRUE on success, FALSE on failure.
    */
   public function clear() {
@@ -306,19 +337,11 @@ class SearchApiIndex extends Entity {
       return TRUE;
     }
 
-    $server = $this->server();
-    if ($server->enabled) {
-      $server->deleteItems('all', $this);
+    try {
+      $this->server()->deleteItems('all', $this);
     }
-    else {
-      $tasks = variable_get('search_api_tasks', array());
-      // If the index was cleared or newly added since the server was last enabled, we don't need to do anything.
-      if (!isset($tasks[$server->machine_name][$this->machine_name])
-          || (array_search('add', $tasks[$server->machine_name][$this->machine_name]) === FALSE
-              && array_search('clear', $tasks[$server->machine_name][$this->machine_name]) === FALSE)) {
-        $tasks[$server->machine_name][$this->machine_name][] = 'clear';
-        variable_set('search_api_tasks', $tasks);
-      }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
     }
 
     _search_api_index_reindex($this);
@@ -356,6 +379,22 @@ class SearchApiIndex extends Entity {
     return $this->datasource;
   }
 
+  /**
+   * Get the entity type of items in this index.
+   *
+   * @return string|null
+   *   An entity type string if the items in this index are entities; NULL
+   *   otherwise.
+   */
+  public function getEntityType() {
+    try {
+      return $this->datasource()->getEntityType();
+    }
+    catch (SearchApiException $e) {
+      return NULL;
+    }
+  }
+
   /**
    * Get the server this index lies on.
    *
@@ -388,7 +427,7 @@ class SearchApiIndex extends Entity {
    *   SearchApiQueryInterface::__construct().
    *
    * @throws SearchApiException
-   *   If the index is currently disabled.
+   *   If the index is currently disabled or its server doesn't exist.
    *
    * @return SearchApiQueryInterface
    *   A query object for searching this index.
@@ -400,17 +439,21 @@ class SearchApiIndex extends Entity {
     return $this->server()->query($this, $options);
   }
 
-
   /**
-   * Indexes items on this index. Will return an array of IDs of items that
-   * should be marked as indexed – i.e., items that were either rejected by a
-   * data-alter callback or were successfully indexed.
+   * Indexes items on this index.
+   *
+   * Will return an array of IDs of items that should be marked as indexed –
+   * i.e., items that were either rejected by a data-alter callback or were
+   * successfully indexed.
    *
    * @param array $items
-   *   An array of items to index.
+   *   An array of items to index, of this index's item type.
    *
    * @return array
    *   An array of the IDs of all items that should be marked as indexed.
+   *
+   * @throws SearchApiException
+   *   If an error occurred during indexing.
    */
   public function index(array $items) {
     if ($this->read_only) {
@@ -528,8 +571,8 @@ class SearchApiIndex extends Entity {
           'options list' => 'entity_metadata_language_list',
         ),
       );
-      // We use the reverse order here so the hierarchy for overwriting property infos is the same
-      // as for actually overwriting the properties.
+      // We use the reverse order here so the hierarchy for overwriting property
+      // infos is the same as for actually overwriting the properties.
       foreach (array_reverse($this->getAlterCallbacks()) as $callback) {
         $props = $callback->propertyInfo();
         if ($props) {
@@ -543,16 +586,14 @@ class SearchApiIndex extends Entity {
     return $property_info;
   }
 
-   /**
-   * Fills the $processors array for use by the pre-/postprocessing functions.
+  /**
+   * Loads all enabled data alterations for this index in proper order.
    *
-   * @return SearchApiIndex
-   *   The called object.
    * @return array
    *   All enabled callbacks for this index, as SearchApiAlterCallbackInterface
    *   objects.
    */
-  protected function getAlterCallbacks() {
+  public function getAlterCallbacks() {
     if (isset($this->callbacks)) {
       return $this->callbacks;
     }
@@ -585,11 +626,13 @@ class SearchApiIndex extends Entity {
   }
 
   /**
+   * Loads all enabled processors for this index in proper order.
+   *
    * @return array
    *   All enabled processors for this index, as SearchApiProcessorInterface
    *   objects.
    */
-  protected function getProcessors() {
+  public function getProcessors() {
     if (isset($this->processors)) {
       return $this->processors;
     }
@@ -721,140 +764,162 @@ class SearchApiIndex extends Entity {
    *   "additional fields" key.
    */
   public function getFields($only_indexed = TRUE, $get_additional = FALSE) {
-    $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
-    $wrapper = $this->entityWrapper();
-    $additional = array();
-    $entity_types = entity_get_info();
-
-    // First we need all already added prefixes.
-    $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
-    foreach (array_keys($fields) as $key) {
-      $len = strlen($key) + 1;
-      $pos = $len;
-      // The third parameter ($offset) to strrpos has rather weird behaviour,
-      // necessitating this rather awkward code. It will iterate over all
-      // prefixes of each field, beginning with the longest, adding all of them
-      // to $added until one is encountered that was already added (which means
-      // all shorter ones will have already been added, too).
-      while ($pos = strrpos($key, ':', $pos - $len)) {
-        $prefix = substr($key, 0, $pos);
-        if (isset($added[$prefix])) {
-          break;
-        }
-        $added[$prefix] = $prefix;
+    global $language;
+
+    $only_indexed = $only_indexed ? 1 : 0;
+    $get_additional = $get_additional ? 1 : 0;
+
+    // First, try the static cache and the persistent cache bin.
+    if (empty($this->fields[$only_indexed][$get_additional])) {
+      $cid = $this->getCacheId() . "-$only_indexed-$get_additional-{$language->language}";
+      $cache = cache_get($cid);
+      if ($cache) {
+        $this->fields[$only_indexed][$get_additional] = $cache->data;
       }
     }
 
-    // Then we walk through all properties and look if they are already
-    // contained in one of the arrays.
-    // Since this uses an iterative instead of a recursive approach, it is a bit
-    // complicated, with three arrays tracking the current depth.
-
-    // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
-    $wrappers = array('' => $wrapper);
-    // Display names for the prefixes
-    $prefix_names = array('' => '');
-      // The list nesting level for entities with a certain prefix
-    $nesting_levels = array('' => 0);
-
-    $types = search_api_default_field_types();
-    $flat = array();
-    while ($wrappers) {
-      foreach ($wrappers as $prefix => $wrapper) {
-        $prefix_name = $prefix_names[$prefix];
-        // Deal with lists of entities.
-        $nesting_level = $nesting_levels[$prefix];
-        $type_prefix = str_repeat('list<', $nesting_level);
-        $type_suffix = str_repeat('>', $nesting_level);
-        if ($nesting_level) {
-          $info = $wrapper->info();
-          // The real nesting level of the wrapper, not the accumulated one.
-          $level = search_api_list_nesting_level($info['type']);
-          for ($i = 0; $i < $level; ++$i) {
-            $wrapper = $wrapper[0];
+    // Otherwise, we have to compute the result.
+    if (empty($this->fields[$only_indexed][$get_additional])) {
+      $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
+      $wrapper = $this->entityWrapper();
+      $additional = array();
+      $entity_types = entity_get_info();
+
+      // First we need all already added prefixes.
+      $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
+      foreach (array_keys($fields) as $key) {
+        $len = strlen($key) + 1;
+        $pos = $len;
+        // The third parameter ($offset) to strrpos has rather weird behaviour,
+        // necessitating this rather awkward code. It will iterate over all
+        // prefixes of each field, beginning with the longest, adding all of them
+        // to $added until one is encountered that was already added (which means
+        // all shorter ones will have already been added, too).
+        while ($pos = strrpos($key, ':', $pos - $len)) {
+          $prefix = substr($key, 0, $pos);
+          if (isset($added[$prefix])) {
+            break;
           }
+          $added[$prefix] = $prefix;
         }
-        // Now look at all properties.
-        foreach ($wrapper as $property => $value) {
-          $info = $value->info();
-          // We hide the complexity of multi-valued types from the user here.
-          $type = search_api_extract_inner_type($info['type']);
-          // Treat Entity API type "token" as our "string" type.
-          // Also let text fields with limited options be of type "string" by default.
-          if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
-            // Inner type is changed to "string".
-            $type = 'string';
-            // Set the field type accordingly.
-            $info['type'] = search_api_nest_type('string', $info['type']);
+      }
+
+      // Then we walk through all properties and look if they are already
+      // contained in one of the arrays.
+      // Since this uses an iterative instead of a recursive approach, it is a bit
+      // complicated, with three arrays tracking the current depth.
+
+      // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
+      $wrappers = array('' => $wrapper);
+      // Display names for the prefixes
+      $prefix_names = array('' => '');
+        // The list nesting level for entities with a certain prefix
+      $nesting_levels = array('' => 0);
+
+      $types = search_api_default_field_types();
+      $flat = array();
+      while ($wrappers) {
+        foreach ($wrappers as $prefix => $wrapper) {
+          $prefix_name = $prefix_names[$prefix];
+          // Deal with lists of entities.
+          $nesting_level = $nesting_levels[$prefix];
+          $type_prefix = str_repeat('list<', $nesting_level);
+          $type_suffix = str_repeat('>', $nesting_level);
+          if ($nesting_level) {
+            $info = $wrapper->info();
+            // The real nesting level of the wrapper, not the accumulated one.
+            $level = search_api_list_nesting_level($info['type']);
+            for ($i = 0; $i < $level; ++$i) {
+              $wrapper = $wrapper[0];
+            }
           }
-          $info['type'] = $type_prefix . $info['type'] . $type_suffix;
-          $key = $prefix . $property;
-          if ((isset($types[$type]) || isset($entity_types[$type])) && (!$only_indexed || !empty($fields[$key]))) {
-            if (!empty($fields[$key])) {
-              // This field is already known in the index configuration.
-              $flat[$key] = $fields[$key] + array(
-                'name' => $prefix_name . $info['label'],
-                'description' => empty($info['description']) ? NULL : $info['description'],
-                'boost' => '1.0',
-                'indexed' => TRUE,
-              );
-              // Update the type and its nesting level for non-entity properties.
-              if (!isset($entity_types[$type])) {
-                $flat[$key]['type'] = search_api_nest_type(search_api_extract_inner_type($flat[$key]['type']), $info['type']);
-                if (isset($flat[$key]['real_type'])) {
-                  $real_type = search_api_extract_inner_type($flat[$key]['real_type']);
-                  $flat[$key]['real_type'] = search_api_nest_type($real_type, $info['type']);
+          // Now look at all properties.
+          foreach ($wrapper as $property => $value) {
+            $info = $value->info();
+            // We hide the complexity of multi-valued types from the user here.
+            $type = search_api_extract_inner_type($info['type']);
+            // Treat Entity API type "token" as our "string" type.
+            // Also let text fields with limited options be of type "string" by default.
+            if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
+              // Inner type is changed to "string".
+              $type = 'string';
+              // Set the field type accordingly.
+              $info['type'] = search_api_nest_type('string', $info['type']);
+            }
+            $info['type'] = $type_prefix . $info['type'] . $type_suffix;
+            $key = $prefix . $property;
+            if ((isset($types[$type]) || isset($entity_types[$type])) && (!$only_indexed || !empty($fields[$key]))) {
+              if (!empty($fields[$key])) {
+                // This field is already known in the index configuration.
+                $flat[$key] = $fields[$key] + array(
+                  'name' => $prefix_name . $info['label'],
+                  'description' => empty($info['description']) ? NULL : $info['description'],
+                  'boost' => '1.0',
+                  'indexed' => TRUE,
+                );
+                // Update the type and its nesting level for non-entity properties.
+                if (!isset($entity_types[$type])) {
+                  $flat[$key]['type'] = search_api_nest_type(search_api_extract_inner_type($flat[$key]['type']), $info['type']);
+                  if (isset($flat[$key]['real_type'])) {
+                    $real_type = search_api_extract_inner_type($flat[$key]['real_type']);
+                    $flat[$key]['real_type'] = search_api_nest_type($real_type, $info['type']);
+                  }
                 }
               }
+              else {
+                $flat[$key] = array(
+                  'name'    => $prefix_name . $info['label'],
+                  'description' => empty($info['description']) ? NULL : $info['description'],
+                  'type'    => $info['type'],
+                  'boost' => '1.0',
+                  'indexed' => FALSE,
+                );
+              }
+              if (isset($entity_types[$type])) {
+                $base_type = isset($entity_types[$type]['entity keys']['name']) ? 'string' : 'integer';
+                $flat[$key]['type'] = search_api_nest_type($base_type, $info['type']);
+                $flat[$key]['entity_type'] = $type;
+              }
             }
-            else {
-              $flat[$key] = array(
-                'name'    => $prefix_name . $info['label'],
-                'description' => empty($info['description']) ? NULL : $info['description'],
-                'type'    => $info['type'],
-                'boost' => '1.0',
-                'indexed' => FALSE,
-              );
-            }
-            if (isset($entity_types[$type])) {
-              $base_type = isset($entity_types[$type]['entity keys']['name']) ? 'string' : 'integer';
-              $flat[$key]['type'] = search_api_nest_type($base_type, $info['type']);
-              $flat[$key]['entity_type'] = $type;
-            }
-          }
-          if (empty($types[$type])) {
-            if (isset($added[$key])) {
-              // Visit this entity/struct in a later iteration.
-              $wrappers[$key . ':'] = $value;
-              $prefix_names[$key . ':'] = $prefix_name . $info['label'] . ' » ';
-              $nesting_levels[$key . ':'] = search_api_list_nesting_level($info['type']);
-            }
-            else {
-              $name = $prefix_name . $info['label'];
-              // Add machine names to discern fields with identical labels.
-              if (isset($used_names[$name])) {
-                if ($used_names[$name] !== FALSE) {
-                  $additional[$used_names[$name]] .= ' [' . $used_names[$name] . ']';
-                  $used_names[$name] = FALSE;
+            if (empty($types[$type])) {
+              if (isset($added[$key])) {
+                // Visit this entity/struct in a later iteration.
+                $wrappers[$key . ':'] = $value;
+                $prefix_names[$key . ':'] = $prefix_name . $info['label'] . ' » ';
+                $nesting_levels[$key . ':'] = search_api_list_nesting_level($info['type']);
+              }
+              else {
+                $name = $prefix_name . $info['label'];
+                // Add machine names to discern fields with identical labels.
+                if (isset($used_names[$name])) {
+                  if ($used_names[$name] !== FALSE) {
+                    $additional[$used_names[$name]] .= ' [' . $used_names[$name] . ']';
+                    $used_names[$name] = FALSE;
+                  }
+                  $name .= ' [' . $key . ']';
                 }
-                $name .= ' [' . $key . ']';
+                $additional[$key] = $name;
+                $used_names[$name] = $key;
               }
-              $additional[$key] = $name;
-              $used_names[$name] = $key;
             }
           }
+          unset($wrappers[$prefix]);
         }
-        unset($wrappers[$prefix]);
       }
-    }
 
-    if (!$get_additional) {
-      return $flat;
+      if (!$get_additional) {
+        $this->fields[$only_indexed][$get_additional] = $flat;
+      }
+      else {
+        $options = array();
+        $options['fields'] = $flat;
+        $options['additional fields'] = $additional;
+        $this->fields[$only_indexed][$get_additional] =  $options;
+      }
+      cache_set($cid, $this->fields[$only_indexed][$get_additional]);
     }
-    $options = array();
-    $options['fields'] = $flat;
-    $options['additional fields'] = $additional;
-    return $options;
+
+    return $this->fields[$only_indexed][$get_additional];
   }
 
   /**
@@ -871,7 +936,12 @@ class SearchApiIndex extends Entity {
     $i = $only_indexed ? 1 : 0;
     if (!isset($this->fulltext_fields[$i])) {
       $this->fulltext_fields[$i] = array();
-      $fields = $only_indexed ? $this->options['fields'] : $this->getFields(FALSE);
+      if ($only_indexed) {
+        $fields = isset($this->options['fields']) ? $this->options['fields'] : array();
+      }
+      else {
+        $fields = $this->getFields(FALSE);
+      }
       foreach ($fields as $key => $field) {
         if (search_api_is_text_type($field['type'])) {
           $this->fulltext_fields[$i][] = $key;
@@ -881,6 +951,19 @@ class SearchApiIndex extends Entity {
     return $this->fulltext_fields[$i];
   }
 
+  /**
+   * Get the cache ID prefix used for this index's caches.
+   *
+   * @param $type
+   *   The type of cache. Currently only "fields" is used.
+   *
+   * @return
+   *   The cache ID (prefix) for this index's caches.
+   */
+  public function getCacheId($type = 'fields') {
+    return 'search_api:index-' . $this->machine_name . '--' . $type;
+  }
+
   /**
    * Helper function for creating an entity metadata wrapper appropriate for
    * this index.
@@ -895,12 +978,18 @@ class SearchApiIndex extends Entity {
    * @return EntityMetadataWrapper
    *   A wrapper for the item type of this index, optionally loaded with the
    *   given data and having additional fields according to the data alterations
-   *   of this index.
+   *   of this index (if $alter wasn't set to FALSE).
    */
   public function entityWrapper($item = NULL, $alter = TRUE) {
-    $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
-    $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
-    return $this->datasource()->getMetadataWrapper($item, $info);
+    try {
+      $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
+      $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
+      return $this->datasource()->getMetadataWrapper($item, $info);
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
+      return entity_metadata_wrapper($this->item_type);
+    }
   }
 
   /**
@@ -915,21 +1004,30 @@ class SearchApiIndex extends Entity {
    * @see SearchApiDataSourceControllerInterface::loadItems()
    */
   public function loadItems(array $ids) {
-    return $this->datasource()->loadItems($ids);
+    try {
+      return $this->datasource()->loadItems($ids);
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
+      return array();
+    }
   }
 
   /**
-   * Reset internal static caches.
+   * Reset internal caches.
    *
    * Should be used when things like fields or data alterations change to avoid
    * using stale data.
    */
   public function resetCaches() {
+    cache_clear_all($this->getCacheId(''), 'cache', TRUE);
+
     $this->datasource = NULL;
     $this->server_object = NULL;
     $this->callbacks = NULL;
     $this->processors = NULL;
     $this->added_properties = NULL;
+    $this->fields = array();
     $this->fulltext_fields = array();
   }
 

+ 64 - 10
sites/all/modules/contrib/search/search_api/includes/processor.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiProcessorInterface and SearchApiAbstractProcessor.
+ */
+
 /**
  * Interface representing a Search API pre- and/or post-processor.
  *
@@ -27,7 +32,7 @@ interface SearchApiProcessorInterface {
   /**
    * Check whether this processor is applicable for a certain index.
    *
-   * This can be used for hiding the processor on the index's "Workflow" tab. To
+   * This can be used for hiding the processor on the index's "Filters" tab. To
    * avoid confusion, you should only use criteria that are immutable, such as
    * the index's item type. Also, since this is only used for UI purposes, you
    * should not completely rely on this to ensure certain index configurations
@@ -167,7 +172,7 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
       $default_fields = drupal_map_assoc(array_keys($this->options['fields']));
     }
     foreach ($fields as $name => $field) {
-      $field_options[$name] = $field['name'];
+      $field_options[$name] = check_plain($field['name']);
       if (!empty($default_fields[$name]) || (!isset($this->options['fields']) && $this->testField($name, $field))) {
         $default_fields[$name] = $name;
       }
@@ -187,7 +192,7 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     $fields = array_filter($values['fields']);
     if ($fields) {
-      $fields = array_combine($fields, array_fill(0, count($fields), TRUE));
+      $fields = array_fill_keys($fields, TRUE);
     }
     $values['fields'] = $fields;
   }
@@ -272,8 +277,14 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
       $this->processFieldValue($value);
     }
     if (is_array($value)) {
-      $type = 'tokens';
-      $value = $this->normalizeTokens($value);
+      // Don't tokenize non-fulltext content!
+      if (in_array($type, array('text', 'tokens'))) {
+        $type = 'tokens';
+        $value = $this->normalizeTokens($value);
+      }
+      else {
+        $value = $this->implodeTokens($value);
+      }
     }
   }
 
@@ -305,6 +316,32 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
     return $ret;
   }
 
+  /**
+   * Internal helper function for imploding tokens into a single string.
+   *
+   * @param array $tokens
+   *   The tokens array to implode.
+   *
+   * @return string
+   *   The text data from the tokens concatenated into a single string.
+   */
+  protected function implodeTokens(array $tokens) {
+    $ret = array();
+    foreach ($tokens as $token) {
+      if (empty($token['value']) && !is_numeric($token['value'])) {
+        // Filter out empty tokens.
+        continue;
+      }
+      if (is_array($token['value'])) {
+        $ret[] = $this->implodeTokens($token['value']);
+      }
+      else {
+        $ret[] = $token['value'];
+      }
+    }
+    return implode(' ', $ret);
+  }
+
   /**
    * Method for preprocessing search keys.
    */
@@ -329,10 +366,20 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
    */
   protected function processFilters(array &$filters) {
     $fields = $this->index->options['fields'];
-    foreach ($filters as &$f) {
+    foreach ($filters as $key => &$f) {
       if (is_array($f)) {
         if (isset($fields[$f[0]]) && $this->testField($f[0], $fields[$f[0]])) {
+          // We want to allow processors also to easily remove complete filters.
+          // However, we can't use empty() or the like, as that would sort out
+          // filters for 0 or NULL. So we specifically check only for the empty
+          // string, and we also make sure the filter value was actually changed
+          // by storing whether it was empty before.
+          $empty_string = $f[1] === '';
           $this->processFilterValue($f[1]);
+
+          if ($f[1] === '' && !$empty_string) {
+            unset($filters[$key]);
+          }
         }
       }
       else {
@@ -343,13 +390,15 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
   }
 
   /**
+   * Determines whether to process data from the given field.
+   *
    * @param $name
    *   The field's machine name.
    * @param array $field
    *   The field's information.
    *
-   * @return
-   *   TRUE, iff the field should be processed.
+   * @return bool
+   *   TRUE, if the field should be processed, FALSE otherwise.
    */
   protected function testField($name, array $field) {
     if (empty($this->options['fields'])) {
@@ -359,8 +408,13 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
   }
 
   /**
-   * @return
-   *   TRUE, iff the type should be processed.
+   * Determines whether fields of the given type should normally be processed.
+   *
+   * Defaults to processing text types, but can easily be overridden by
+   * subclasses.
+   *
+   * @return bool
+   *   TRUE, if the type should be processed, FALSE otherwise.
    */
   protected function testType($type) {
     return search_api_is_text_type($type, array('text', 'tokens'));

+ 527 - 0
sites/all/modules/contrib/search/search_api/includes/processor_highlight.inc

@@ -0,0 +1,527 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiHighlight class.
+ */
+
+/**
+ * Processor for highlighting search results.
+ */
+class SearchApiHighlight extends SearchApiAbstractProcessor {
+
+  /**
+   * PREG regular expression for a word boundary.
+   *
+   * We highlight around non-indexable or CJK characters.
+   *
+   * @var string
+   */
+  protected static $boundary;
+
+  /**
+   * PREG regular expression for splitting words.
+   *
+   * @var string
+   */
+  protected static $split;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    parent::__construct($index, $options);
+
+    $cjk = '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
+        '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
+        '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
+        '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
+        '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
+    self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']))';
+    self::$split = '/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/iu';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm() {
+    $this->options += array(
+      'prefix' => '<strong>',
+      'suffix' => '</strong>',
+      'excerpt' => TRUE,
+      'excerpt_length' => 256,
+      'highlight' => 'always',
+      'highlight_partial' => FALSE,
+      'exclude_fields' => array(),
+    );
+
+    $form['prefix'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Highlighting prefix'),
+      '#description' => t('Text/HTML that will be prepended to all occurrences of search keywords in highlighted text.'),
+      '#default_value' => $this->options['prefix'],
+    );
+    $form['suffix'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Highlighting suffix'),
+      '#description' => t('Text/HTML that will be appended to all occurrences of search keywords in highlighted text.'),
+      '#default_value' => $this->options['suffix'],
+    );
+    $form['excerpt'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Create excerpt'),
+      '#description' => t('When enabled, an excerpt will be created for searches with keywords, containing all occurrences of keywords in a fulltext field.'),
+      '#default_value' => $this->options['excerpt'],
+    );
+    $form['excerpt_length'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Excerpt length'),
+      '#description' => t('The requested length of the excerpt, in characters.'),
+      '#default_value' => $this->options['excerpt_length'],
+      '#element_validate' => array('element_validate_integer_positive'),
+      '#states' => array(
+        'visible' => array(
+          '#edit-processors-search-api-highlighting-settings-excerpt' => array(
+            'checked' => TRUE,
+          ),
+        ),
+      ),
+    );
+    // Exclude certain fulltext fields.
+    $fields = $this->index->getFields();
+    $fulltext_fields = array();
+    foreach ($this->index->getFulltextFields() as $field) {
+      if (isset($fields[$field])) {
+        $fulltext_fields[$field] = check_plain($fields[$field]['name'] . ' (' . $field . ')');
+      }
+    }
+    $form['exclude_fields'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Exclude fields from excerpt'),
+      '#description' => t('Exclude certain fulltext fields from being displayed in the excerpt.'),
+      '#options' => $fulltext_fields,
+      '#default_value' => $this->options['exclude_fields'],
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+    );
+    $form['highlight'] = array(
+      '#type' => 'select',
+      '#title' => t('Highlight returned field data'),
+      '#description' => t('Select whether returned fields should be highlighted.'),
+      '#options' => array(
+        'always' => t('Always'),
+        'server' => t('If the server returns fields'),
+        'never' => t('Never'),
+      ),
+      '#default_value' => $this->options['highlight'],
+    );
+
+    $form['highlight_partial'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Highlight partial matches'),
+      '#description' => t('When enabled, matches in parts of words will be highlighted as well.'),
+      '#default_value' => $this->options['highlight_partial'],
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    $values['exclude_fields'] = array_filter($values['exclude_fields']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+    if (empty($response['results']) || !($keys = $this->getKeywords($query))) {
+      return;
+    }
+
+    $fulltext_fields = $this->index->getFulltextFields();
+    if (!empty($this->options['exclude_fields'])) {
+      $fulltext_fields = drupal_map_assoc($fulltext_fields);
+      foreach ($this->options['exclude_fields'] as $field) {
+        unset($fulltext_fields[$field]);
+      }
+    }
+
+    foreach ($response['results'] as $id => &$result) {
+      if ($this->options['excerpt']) {
+        $text = array();
+        $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields);
+        foreach ($fields as $data) {
+          if (is_array($data)) {
+            $text = array_merge($text, $data);
+          }
+          else {
+            $text[] = $data;
+          }
+        }
+
+        $result['excerpt'] = $this->createExcerpt($this->flattenArrayValues($text), $keys);
+      }
+      if ($this->options['highlight'] != 'never') {
+        $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields, $this->options['highlight'] == 'always');
+        foreach ($fields as $field => $data) {
+          $result['fields'][$field] = array('#sanitize_callback' => FALSE);
+          if (is_array($data)) {
+            foreach ($data as $i => $text) {
+              $result['fields'][$field]['#value'][$i] = $this->highlightField($text, $keys);
+            }
+          }
+          else {
+            $result['fields'][$field]['#value'] = $this->highlightField($data, $keys);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Retrieves the fulltext data of a result.
+   *
+   * @param array $results
+   *   All results returned in the search, by reference.
+   * @param int|string $i
+   *   The index in the results array of the result whose data should be
+   *   returned.
+   * @param array $fulltext_fields
+   *   The fulltext fields from which the excerpt should be created.
+   * @param bool $load
+   *   TRUE if the item should be loaded if necessary, FALSE if only fields
+   *   already returned in the results should be used.
+   *
+   * @return array
+   *   An array containing fulltext field names mapped to the text data
+   *   contained in them for the given result.
+   */
+  protected function getFulltextFields(array &$results, $i, array $fulltext_fields, $load = TRUE) {
+    global $language;
+    $data = array();
+
+    $result = &$results[$i];
+    // Act as if $load is TRUE if we have a loaded item.
+    $load |= !empty($result['entity']);
+    $result += array('fields' => array());
+    // We only need detailed fields data if $load is TRUE.
+    $fields = $load ? $this->index->getFields() : array();
+    $needs_extraction = array();
+    $returned_fields = search_api_get_sanitized_field_values(array_intersect_key($result['fields'], array_flip($fulltext_fields)));
+    foreach ($fulltext_fields as $field) {
+      if (array_key_exists($field, $returned_fields)) {
+        $data[$field] = $returned_fields[$field];
+      }
+      elseif ($load) {
+        $needs_extraction[$field] = $fields[$field];
+      }
+    }
+
+    if (!$needs_extraction) {
+      return $data;
+    }
+
+    if (empty($result['entity'])) {
+      $items = $this->index->loadItems(array_keys($results));
+      foreach ($items as $id => $item) {
+        $results[$id]['entity'] = $item;
+      }
+    }
+    // If we still don't have a loaded item, we should stop trying.
+    if (empty($result['entity'])) {
+      return $data;
+    }
+    $wrapper = $this->index->entityWrapper($result['entity'], FALSE);
+    $wrapper->language($language->language);
+    $extracted = search_api_extract_fields($wrapper, $needs_extraction, array('sanitize' => TRUE));
+
+    foreach ($extracted as $field => $info) {
+      if (isset($info['value'])) {
+        $data[$field] = $info['value'];
+      }
+    }
+
+    return $data;
+  }
+
+  /**
+   * Extracts the positive keywords used in a search query.
+   *
+   * @param SearchApiQuery $query
+   *   The query from which to extract the keywords.
+   *
+   * @return array
+   *   An array of all unique positive keywords used in the query.
+   */
+  protected function getKeywords(SearchApiQuery $query) {
+    $keys = $query->getKeys();
+    if (!$keys) {
+      return array();
+    }
+    if (is_array($keys)) {
+      return $this->flattenKeysArray($keys);
+    }
+
+    $keywords = preg_split(self::$split, $keys);
+    // Assure there are no duplicates. (This is actually faster than
+    // array_unique() by a factor of 3 to 4.)
+    $keywords = drupal_map_assoc(array_filter($keywords));
+    // Remove quotes from keywords.
+    foreach ($keywords as $key) {
+      $keywords[$key] = trim($key, "'\"");
+    }
+    return drupal_map_assoc(array_filter($keywords));
+  }
+
+  /**
+   * Extracts the positive keywords from a keys array.
+   *
+   * @param array $keys
+   *   A search keys array, as specified by SearchApiQueryInterface::getKeys().
+   *
+   * @return array
+   *   An array of all unique positive keywords contained in the keys.
+   */
+  protected function flattenKeysArray(array $keys) {
+    if (!empty($keys['#negation'])) {
+      return array();
+    }
+
+    $keywords = array();
+    foreach ($keys as $i => $key) {
+      if (!element_child($i)) {
+        continue;
+      }
+      if (is_array($key)) {
+        $keywords += $this->flattenKeysArray($key);
+      }
+      else {
+        $keywords[$key] = $key;
+      }
+    }
+
+    return $keywords;
+  }
+
+  /**
+   * Returns snippets from a piece of text, with certain keywords highlighted.
+   *
+   * Largely copied from search_excerpt().
+   *
+   * @param string $text
+   *   The text to extract fragments from.
+   * @param array $keys
+   *   Search keywords entered by the user.
+   *
+   * @return string|null
+   *   A string containing HTML for the excerpt, or NULL if none could be
+   *   created.
+   */
+  protected function createExcerpt($text, array $keys) {
+    // Prepare text by stripping HTML tags and decoding HTML entities.
+    $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text));
+    $text = decode_entities($text);
+    $text = preg_replace('/\s+/', ' ', $text);
+    $text = trim($text, ' ');
+    $text_length = strlen($text);
+
+    // Try to reach the requested excerpt length with about two fragments (each
+    // with a keyword and some context).
+    $ranges = array();
+    $length = 0;
+    $look_start = array();
+    $remaining_keys = $keys;
+
+    // Get the set excerpt length from the configuration. If the length is too
+    // small, only use one fragment.
+    $excerpt_length = $this->options['excerpt_length'];
+    $context_length = round($excerpt_length / 4) - 3;
+    if ($context_length < 32) {
+      $context_length = round($excerpt_length / 2) - 1;
+    }
+
+    while ($length < $excerpt_length && !empty($remaining_keys)) {
+      $found_keys = array();
+      foreach ($remaining_keys as $key) {
+        if ($length >= $excerpt_length) {
+          break;
+        }
+
+        // Remember where we last found $key, in case we are coming through a
+        // second time.
+        if (!isset($look_start[$key])) {
+          $look_start[$key] = 0;
+        }
+
+        // See if we can find $key after where we found it the last time. Since
+        // we are requiring a match on a word boundary, make sure $text starts
+        // and ends with a space.
+        $matches = array();
+
+        if (!$this->options['highlight_partial']) {
+          $found_position = FALSE;
+          $regex = '/' . static::$boundary . preg_quote($key, '/') . static::$boundary . '/iu';
+          if (preg_match($regex, ' ' . $text . ' ', $matches, PREG_OFFSET_CAPTURE, $look_start[$key])) {
+            $found_position = $matches[0][1];
+          }
+        }
+        else {
+          $found_position = stripos($text, $key, $look_start[$key]);
+        }
+        if ($found_position !== FALSE) {
+          $look_start[$key] = $found_position + 1;
+          // Keep track of which keys we found this time, in case we need to
+          // pass through again to find more text.
+          $found_keys[] = $key;
+
+          // Locate a space before and after this match, leaving some context on
+          // each end.
+          if ($found_position > $context_length) {
+            $before = strpos($text, ' ', $found_position - $context_length);
+            if ($before !== FALSE) {
+              ++$before;
+            }
+          }
+          else {
+            $before = 0;
+          }
+          if ($before !== FALSE && $before <= $found_position) {
+            if ($text_length > $found_position + $context_length) {
+              $after = strrpos(substr($text, 0, $found_position + $context_length), ' ', $found_position);
+            }
+            else {
+              $after = $text_length;
+            }
+            if ($after !== FALSE && $after > $found_position) {
+              if ($before < $after) {
+                // Save this range.
+                $ranges[$before] = $after;
+                $length += $after - $before;
+              }
+            }
+          }
+        }
+      }
+      // Next time through this loop, only look for keys we found this time,
+      // if any.
+      $remaining_keys = $found_keys;
+    }
+
+    if (!$ranges) {
+      // We didn't find any keyword matches, return NULL.
+      return NULL;
+    }
+
+    // Sort the text ranges by starting position.
+    ksort($ranges);
+
+    // Collapse overlapping text ranges into one. The sorting makes it O(n).
+    $newranges = array();
+    $from1 = $to1 = NULL;
+    foreach ($ranges as $from2 => $to2) {
+      if ($from1 === NULL) {
+        // This is the first time through this loop: initialize.
+        $from1 = $from2;
+        $to1 = $to2;
+        continue;
+      }
+      if ($from2 <= $to1) {
+        // The ranges overlap: combine them.
+        $to1 = max($to1, $to2);
+      }
+      else {
+        // The ranges do not overlap: save the working range and start a new
+        // one.
+        $newranges[$from1] = $to1;
+        $from1 = $from2;
+        $to1 = $to2;
+      }
+    }
+    // Save the remaining working range.
+    $newranges[$from1] = $to1;
+
+    // Fetch text within the combined ranges we found.
+    $out = array();
+    foreach ($newranges as $from => $to) {
+      $out[] = substr($text, $from, $to - $from);
+    }
+    if (!$out) {
+      return NULL;
+    }
+
+    // Let translators have the ... separator text as one chunk.
+    $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...'));
+
+    $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
+    $text = check_plain($text);
+
+    // Since we stripped the tags at the beginning, highlighting doesn't need to
+    // handle HTML anymore.
+    return $this->highlightField($text, $keys, FALSE);
+  }
+
+  /**
+   * Marks occurrences of the search keywords in a text field.
+   *
+   * @param string $text
+   *   The text of the field.
+   * @param array $keys
+   *   Search keywords entered by the user.
+   * @param bool $html
+   *   Whether the text can contain HTML tags or not. In the former case, text
+   *   inside tags (i.e., tag names and attributes) won't be highlighted.
+   *
+   * @return string
+   *   The field's text with all occurrences of search keywords highlighted.
+   */
+  protected function highlightField($text, array $keys, $html = TRUE) {
+    if (is_array($text)) {
+      $text = $this->flattenArrayValues($text);
+    }
+
+    if ($html) {
+      $texts = preg_split('#((?:</?[[:alpha:]](?:[^>"\']*|"[^"]*"|\'[^\']\')*>)+)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+      for ($i = 0; $i < count($texts); $i += 2) {
+        $texts[$i] = $this->highlightField($texts[$i], $keys, FALSE);
+      }
+      return implode('', $texts);
+    }
+
+    $keys = implode('|', array_map('preg_quote', $keys, array_fill(0, count($keys), '/')));
+    // If "Highlight partial matches" is disabled, we only want to highlight
+    // matches that are complete words. Otherwise, we want all of them.
+    $boundary = empty($this->options['highlight_partial']) ? self::$boundary : '';
+    $regex = '/' . $boundary . '(?:' . $keys . ')' . $boundary . '/iu';
+    $replace = $this->options['prefix'] . '\0' . $this->options['suffix'];
+    $text = preg_replace($regex, $replace, ' ' . $text . ' ');
+    return substr($text, 1, -1);
+  }
+
+  /**
+   * Flattens a (possibly multidimensional) array into a string.
+   *
+   * @param array $array
+   *   The array to flatten.
+   * @param string $glue
+   *   (optional) The separator to insert between individual array items.
+   *
+   * @return string
+   *   The glued string.
+   */
+  protected function flattenArrayValues(array $array, $glue = " \n\n ") {
+    $ret = array();
+    foreach ($array as $item) {
+      if (is_array($item)) {
+        $ret[] = $this->flattenArrayValues($item, $glue);
+      }
+      else {
+        $ret[] = $item;
+      }
+    }
+
+    return implode($glue, $ret);
+  }
+
+}

+ 19 - 4
sites/all/modules/contrib/search/search_api/includes/processor_html_filter.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiHtmlFilter.
+ */
+
 /**
  * Processor for stripping HTML from indexed fulltext data. Supports assigning
  * custom boosts for any HTML element.
@@ -96,7 +101,9 @@ class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
       $value = $this->parseText($text);
     }
     else {
-      $value = strip_tags($text);
+      $value = html_entity_decode(strip_tags($text));
+      // Remove any multiple or leading/trailing spaces we might have introduced.
+      $value = preg_replace('/\s\s+/', ' ', trim($value));
     }
   }
 
@@ -104,13 +111,18 @@ class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
     $ret = array();
     while (($pos = strpos($text, '<')) !== FALSE) {
       if ($boost && $pos > 0) {
+        $token = html_entity_decode(substr($text, 0, $pos), ENT_QUOTES, 'UTF-8');
+        // Remove any multiple or leading/trailing spaces we might have introduced.
+        $token = preg_replace('/\s\s+/', ' ', trim($token));
         $ret[] = array(
-          'value' => html_entity_decode(substr($text, 0, $pos), ENT_QUOTES, 'UTF-8'),
+          'value' => $token,
           'score' => $boost,
         );
       }
       $text = substr($text, $pos + 1);
-      preg_match('#^(/?)([-:_a-zA-Z]+)#', $text, $m);
+      if (!preg_match('#^(/?)([:_a-zA-Z][-:_a-zA-Z0-9.]*)#', $text, $m)) {
+        continue;
+      }
       $text = substr($text, strpos($text, '>') + 1);
       if ($m[1]) {
         // Closing tag.
@@ -125,8 +137,11 @@ class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
       }
     }
     if ($text) {
+      $token = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
+      // Remove any multiple or leading/trailing spaces we might have introduced.
+      $token = preg_replace('/\s\s+/', ' ', trim($token));
       $ret[] = array(
-        'value' => html_entity_decode($text, ENT_QUOTES, 'UTF-8'),
+        'value' => $token,
         'score' => $boost,
       );
       $text = '';

+ 9 - 1
sites/all/modules/contrib/search/search_api/includes/processor_ignore_case.inc

@@ -1,12 +1,20 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiIgnoreCase.
+ */
+
 /**
  * Processor for making searches case-insensitive.
  */
 class SearchApiIgnoreCase extends SearchApiAbstractProcessor {
 
   protected function process(&$value) {
-    $value = drupal_strtolower($value);
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $value = drupal_strtolower($value);
+    }
   }
 
 }

+ 731 - 0
sites/all/modules/contrib/search/search_api/includes/processor_stemmer.inc

@@ -0,0 +1,731 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiPorterStemmer and SearchApiPorter2.
+ */
+
+/**
+ * Stems words to their roots.
+ */
+class SearchApiPorterStemmer extends SearchApiAbstractProcessor {
+
+  /**
+   * Static cache for already generated stems.
+   *
+   * @var array
+   */
+  protected $stems = array();
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm() {
+    $form = parent::configurationForm();
+
+    $args = array(
+      '@algorithm' => url('http://snowball.tartarus.org/algorithms/english/stemmer.html'),
+    );
+    $form += array(
+      'help' => array(
+        '#markup' => '<p>' . t('Optionally, provide an exclusion list to override the stemmer algorithm. (<a href="@algorithm">Read about the algorithm</a>.)', $args) . '</p>',
+      ),
+      'exceptions' => array(
+        '#type' => 'textarea',
+        '#title' => t('Exceptions'),
+        '#description' => t('Enter exceptions in the form of WORD=STEM, where "WORD" is the term entered and "STEM" is the resulting stem. List each exception on a separate line.'),
+        '#default_value' => "texan=texa",
+      ),
+    );
+
+    if (!empty($this->options['exceptions'])) {
+      $form['exceptions']['#default_value'] = $this->options['exceptions'];
+    }
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function process(&$value) {
+    // Load custom exceptions.
+    $exceptions = $this->getExceptions();
+
+    $words = preg_split('/[^\p{L}\p{N}]+/u', $value, -1 , PREG_SPLIT_DELIM_CAPTURE);
+    $stemmed = array();
+    foreach ($words as $i => $word) {
+      if ($i % 2 == 0 && strlen($word)) {
+        if (!isset($this->stems[$word])) {
+          $stem = new SearchApiPorter2($word, $exceptions);
+          $this->stems[$word] = $stem->stem();
+        }
+        $stemmed[] = $this->stems[$word];
+      }
+      else {
+        $stemmed[] = $word;
+      }
+    }
+    $value = implode(' ', $stemmed);
+  }
+
+  /**
+   * Retrieves the processor's configured exceptions.
+   *
+   * @return string[]
+   *   An associative array of exceptions, with words as keys and stems as their
+   *   replacements.
+   */
+  protected function getExceptions() {
+    if (!empty($this->options['exceptions'])) {
+      $exceptions = parse_ini_string($this->options['exceptions'], TRUE);
+      return is_array($exceptions) ? $exceptions : array();
+    }
+    return array();
+  }
+
+}
+
+/**
+ * Implements the Porter2 stemming algorithm.
+ *
+ * @see https://github.com/markfullmer/porter2
+ */
+class SearchApiPorter2 {
+
+  /**
+   * The word being stemmed.
+   *
+   * @var string
+   */
+  protected $word;
+
+  /**
+   * The R1 of the word.
+   *
+   * @var int
+   *
+   * @see http://snowball.tartarus.org/texts/r1r2.html.
+   */
+  protected $r1;
+
+  /**
+   * The R2 of the word.
+   *
+   * @var int
+   *
+   * @see http://snowball.tartarus.org/texts/r1r2.html.
+   */
+  protected $r2;
+
+  /**
+   * List of exceptions to be used.
+   *
+   * @var string[]
+   */
+  protected $exceptions = array();
+
+  /**
+   * Constructs a SearchApiPorter2 object.
+   *
+   * @param string $word
+   *   The word to stem.
+   * @param string[] $custom_exceptions
+   *   (optional) A custom list of exceptions.
+   */
+  public function __construct($word, $custom_exceptions = array()) {
+    $this->word = $word;
+    $this->exceptions = $custom_exceptions + array(
+      'skis' => 'ski',
+      'skies' => 'sky',
+      'dying' => 'die',
+      'lying' => 'lie',
+      'tying' => 'tie',
+      'idly' => 'idl',
+      'gently' => 'gentl',
+      'ugly' => 'ugli',
+      'early' => 'earli',
+      'only' => 'onli',
+      'singly' => 'singl',
+      'sky' => 'sky',
+      'news' => 'news',
+      'howe' => 'howe',
+      'atlas' => 'atlas',
+      'cosmos' => 'cosmos',
+      'bias' => 'bias',
+      'andes' => 'andes',
+    );
+
+    // Set initial y, or y after a vowel, to Y.
+    $inc = 0;
+    while ($inc <= $this->length()) {
+      if (substr($this->word, $inc, 1) === 'y' && ($inc == 0 || $this->isVowel($inc - 1))) {
+        $this->word = substr_replace($this->word, 'Y', $inc, 1);
+
+      }
+      $inc++;
+    }
+    // Establish the regions R1 and R2. See function R().
+    $this->r1 = $this->R(1);
+    $this->r2 = $this->R(2);
+  }
+
+  /**
+   * Computes the stem of the word.
+   *
+   * @return string
+   *   The word's stem.
+   */
+  public function stem() {
+    // Ignore exceptions & words that are two letters or less.
+    if ($this->exceptions() || $this->length() <= 2) {
+      return strtolower($this->word);
+    }
+    else {
+      $this->step0();
+      $this->step1a();
+      $this->step1b();
+      $this->step1c();
+      $this->step2();
+      $this->step3();
+      $this->step4();
+      $this->step5();
+    }
+    return strtolower($this->word);
+  }
+
+  /**
+   * Determines whether the word is contained in our list of exceptions.
+   *
+   * If so, the $word property is changed to the stem listed in the exceptions.
+   *
+   * @return bool
+   *   TRUE if the word was an exception, FALSE otherwise.
+   */
+  protected function exceptions() {
+    if (isset($this->exceptions[$this->word])) {
+      $this->word = $this->exceptions[$this->word];
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Searches for the longest among the "s" suffixes and removes it.
+   *
+   * Implements step 0 of the Porter2 algorithm.
+   */
+  protected function step0() {
+    $found = FALSE;
+    $checks = array("'s'", "'s", "'");
+    foreach ($checks as $check) {
+      if (!$found && $this->hasEnding($check)) {
+        $this->removeEnding($check);
+        $found = TRUE;
+      }
+    }
+  }
+
+  /**
+   * Handles various suffixes, of which the longest is replaced.
+   *
+   * Implements step 1a of the Porter2 algorithm.
+   */
+  protected function step1a() {
+    $found = FALSE;
+    if ($this->hasEnding('sses')) {
+      $this->removeEnding('sses');
+      $this->addEnding('ss');
+      $found = TRUE;
+    }
+    $checks = array('ied', 'ies');
+    foreach ($checks as $check) {
+      if (!$found && $this->hasEnding($check)) {
+        $length = $this->length();
+        $this->removeEnding($check);
+        if ($length > 4) {
+          $this->addEnding('i');
+        }
+        else {
+          $this->addEnding('ie');
+        }
+        $found = TRUE;
+      }
+    }
+    if ($this->hasEnding('us') || $this->hasEnding('ss')) {
+      $found = TRUE;
+    }
+    // Delete if preceding word part has a vowel not immediately before the s.
+    if (!$found && $this->hasEnding('s') && $this->containsVowel(substr($this->word, 0, -2))) {
+      $this->removeEnding('s');
+    }
+  }
+
+  /**
+   * Handles various suffixes, of which the longest is replaced.
+   *
+   * Implements step 1b of the Porter2 algorithm.
+   */
+  protected function step1b() {
+    $exceptions = array(
+      'inning',
+      'outing',
+      'canning',
+      'herring',
+      'earring',
+      'proceed',
+      'exceed',
+      'succeed',
+    );
+    if (in_array($this->word, $exceptions)) {
+      return;
+    }
+    $checks = array('eedly', 'eed');
+    foreach ($checks as $check) {
+      if ($this->hasEnding($check)) {
+        if ($this->r1 !== $this->length()) {
+          $this->removeEnding($check);
+          $this->addEnding('ee');
+        }
+        return;
+      }
+    }
+    $checks = array('ingly', 'edly', 'ing', 'ed');
+    $second_endings = array('at', 'bl', 'iz');
+    foreach ($checks as $check) {
+      // If the ending is present and the previous part contains a vowel.
+      if ($this->hasEnding($check) && $this->containsVowel(substr($this->word, 0, -strlen($check)))) {
+        $this->removeEnding($check);
+        foreach ($second_endings as $ending) {
+          if ($this->hasEnding($ending)) {
+            $this->addEnding('e');
+            return;
+          }
+        }
+        // If the word ends with a double, remove the last letter.
+        $found = $this->removeDoubles();
+        // If the word is short, add e (so hop -> hope).
+        if (!$found && ($this->isShort())) {
+          $this->addEnding('e');
+        }
+        return;
+      }
+    }
+  }
+
+  /**
+   * Replaces suffix y or Y with i if after non-vowel not @ word begin.
+   *
+   * Implements step 1c of the Porter2 algorithm.
+   */
+  protected function step1c() {
+    if (($this->hasEnding('y') || $this->hasEnding('Y')) && $this->length() > 2 && !($this->isVowel($this->length() - 2))) {
+      $this->removeEnding('y');
+      $this->addEnding('i');
+    }
+  }
+
+  /**
+   * Implements step 2 of the Porter2 algorithm.
+   */
+  protected function step2() {
+    $checks = array(
+      "ization" => "ize",
+      "iveness" => "ive",
+      "fulness" => "ful",
+      "ational" => "ate",
+      "ousness" => "ous",
+      "biliti" => "ble",
+      "tional" => "tion",
+      "lessli" => "less",
+      "fulli" => "ful",
+      "entli" => "ent",
+      "ation" => "ate",
+      "aliti" => "al",
+      "iviti" => "ive",
+      "ousli" => "ous",
+      "alism" => "al",
+      "abli" => "able",
+      "anci" => "ance",
+      "alli" => "al",
+      "izer" => "ize",
+      "enci" => "ence",
+      "ator" => "ate",
+      "bli" => "ble",
+      "ogi" => "og",
+    );
+    foreach ($checks as $find => $replace) {
+      if ($this->hasEnding($find)) {
+        if ($this->inR1($find)) {
+          $this->removeEnding($find);
+          $this->addEnding($replace);
+        }
+        return;
+      }
+    }
+    if ($this->hasEnding('li')) {
+      if ($this->length() > 4 && $this->validLi($this->charAt(-3))) {
+        $this->removeEnding('li');
+      }
+    }
+  }
+
+  /**
+   * Implements step 3 of the Porter2 algorithm.
+   */
+  protected function step3() {
+    $checks = array(
+      'ational' => 'ate',
+      'tional' => 'tion',
+      'alize' => 'al',
+      'icate' => 'ic',
+      'iciti' => 'ic',
+      'ical' => 'ic',
+      'ness' => '',
+      'ful' => '',
+    );
+    foreach ($checks as $find => $replace) {
+      if ($this->hasEnding($find)) {
+        if ($this->inR1($find)) {
+          $this->removeEnding($find);
+          $this->addEnding($replace);
+        }
+        return;
+      }
+    }
+    if ($this->hasEnding('ative')) {
+      if ($this->inR2('ative')) {
+        $this->removeEnding('ative');
+      }
+    }
+  }
+
+  /**
+   * Implements step 4 of the Porter2 algorithm.
+   */
+  protected function step4() {
+    $checks = array(
+      'ement',
+      'ment',
+      'ance',
+      'ence',
+      'able',
+      'ible',
+      'ant',
+      'ent',
+      'ion',
+      'ism',
+      'ate',
+      'iti',
+      'ous',
+      'ive',
+      'ize',
+      'al',
+      'er',
+      'ic',
+    );
+    foreach ($checks as $check) {
+      // Among the suffixes, if found and in R2, delete.
+      if ($this->hasEnding($check)) {
+        if ($this->inR2($check)) {
+          if ($check !== 'ion' || in_array($this->charAt(-4), array('s', 't'))) {
+            $this->removeEnding($check);
+          }
+        }
+        return;
+      }
+    }
+  }
+
+  /**
+   * Implements step 5 of the Porter2 algorithm.
+   */
+  protected function step5() {
+    if ($this->hasEnding('e')) {
+      // Delete if in R2, or in R1 and not preceded by a short syllable.
+      if ($this->inR2('e') || ($this->inR1('e') && !$this->isShortSyllable($this->length() - 3))) {
+        $this->removeEnding('e');
+      }
+      return;
+    }
+    if ($this->hasEnding('l')) {
+      // Delete if in R2 and preceded by l.
+      if ($this->inR2('l') && $this->charAt(-2) == 'l') {
+        $this->removeEnding('l');
+      }
+    }
+  }
+
+  /**
+   * Removes certain double consonants from the word's end.
+   *
+   * @return bool
+   *   TRUE if a match was found and removed, FALSE otherwise.
+   */
+  protected function removeDoubles() {
+    $found = FALSE;
+    $doubles = array('bb', 'dd', 'ff', 'gg', 'mm', 'nn', 'pp', 'rr', 'tt');
+    foreach ($doubles as $double) {
+      if (substr($this->word, -2) == $double) {
+        $this->word = substr($this->word, 0, -1);
+        $found = TRUE;
+        break;
+      }
+    }
+    return $found;
+  }
+
+  /**
+   * Checks whether a character is a vowel.
+   *
+   * @param int $position
+   *   The character's position.
+   * @param string|null $word
+   *   (optional) The word in which to check. Defaults to $this->word.
+   * @param string[] $additional
+   *   (optional) Additional characters that should count as vowels.
+   *
+   * @return bool
+   *   TRUE if the character is a vowel, FALSE otherwise.
+   */
+  protected function isVowel($position, $word = NULL, $additional = array()) {
+    if ($word === NULL) {
+      $word = $this->word;
+    }
+    $vowels = array_merge(array('a', 'e', 'i', 'o', 'u', 'y'), $additional);
+    return in_array($this->charAt($position, $word), $vowels);
+  }
+
+  /**
+   * Retrieves the character at the given position.
+   *
+   * @param int $position
+   *   The 0-based index of the character. If a negative number is given, the
+   *   position is counted from the end of the string.
+   * @param string|null $word
+   *   (optional) The word from which to retrieve the character. Defaults to
+   *   $this->word.
+   *
+   * @return string
+   *   The character at the given position, or an empty string if the given
+   *   position was illegal.
+   */
+  protected function charAt($position, $word = NULL) {
+    if ($word === NULL) {
+      $word = $this->word;
+    }
+    $length = strlen($word);
+    if (abs($position) >= $length) {
+      return '';
+    }
+    if ($position < 0) {
+      $position += $length;
+    }
+    return $word[$position];
+  }
+
+  /**
+   * Determines whether the word ends in a "vowel-consonant" suffix.
+   *
+   * Unless the word is only two characters long, it also checks that the
+   * third-last character is neither "w", "x" nor "Y".
+   *
+   * @param int|null $position
+   *   (optional) If given, do not check the end of the word, but the character
+   *   at the given position, and the next one.
+   *
+   * @return bool
+   *   TRUE if the word has the described suffix, FALSE otherwise.
+   */
+  protected function isShortSyllable($position = NULL) {
+    if ($position === NULL) {
+      $position = $this->length() - 2;
+    }
+    // A vowel at the beginning of the word followed by a non-vowel.
+    if ($position === 0) {
+      return $this->isVowel(0) && !$this->isVowel(1);
+    }
+    // Vowel followed by non-vowel other than w, x, Y and preceded by
+    // non-vowel.
+    $additional = array('w', 'x', 'Y');
+    return !$this->isVowel($position - 1) && $this->isVowel($position) && !$this->isVowel($position + 1, NULL, $additional);
+  }
+
+  /**
+   * Determines whether the word is short.
+   *
+   * A word is called short if it ends in a short syllable and if R1 is null.
+   *
+   * @return bool
+   *   TRUE if the word is short, FALSE otherwise.
+   */
+  protected function isShort() {
+    return $this->isShortSyllable() && $this->r1 == $this->length();
+  }
+
+  /**
+   * Determines the start of a certain "R" region.
+   *
+   * R is a region after the first non-vowel following a vowel, or end of word.
+   *
+   * @param int $type
+   *   (optional) 1 or 2. If 2, then calculate the R after the R1.
+   *
+   * @return int
+   *   The R position.
+   */
+  protected function R($type = 1) {
+    $inc = 1;
+    if ($type === 2) {
+      $inc = $this->r1;
+    }
+    elseif ($this->length() > 5) {
+      $prefix_5 = substr($this->word, 0, 5);
+      if ($prefix_5 === 'gener' || $prefix_5 === 'arsen') {
+        return 5;
+      }
+      if ($this->length() > 6 && substr($this->word, 0, 6) === 'commun') {
+        return 6;
+      }
+    }
+
+    while ($inc <= $this->length()) {
+      if (!$this->isVowel($inc) && $this->isVowel($inc - 1)) {
+        $position = $inc;
+        break;
+      }
+      $inc++;
+    }
+    if (!isset($position)) {
+      $position = $this->length();
+    }
+    else {
+      // We add one, as this is the position AFTER the first non-vowel.
+      $position++;
+    }
+    return $position;
+  }
+
+  /**
+   * Checks whether the given string is contained in R1.
+   *
+   * @param string $string
+   *   The string.
+   *
+   * @return bool
+   *   TRUE if the string is in R1, FALSE otherwise.
+   */
+  protected function inR1($string) {
+    $r1 = substr($this->word, $this->r1);
+    return strpos($r1, $string) !== FALSE;
+  }
+
+  /**
+   * Checks whether the given string is contained in R2.
+   *
+   * @param string $string
+   *   The string.
+   *
+   * @return bool
+   *   TRUE if the string is in R2, FALSE otherwise.
+   */
+  protected function inR2($string) {
+    $r2 = substr($this->word, $this->r2);
+    return strpos($r2, $string) !== FALSE;
+  }
+
+  /**
+   * Determines the string length of the current word.
+   *
+   * @return int
+   *   The string length of the current word.
+   */
+  protected function length() {
+    return strlen($this->word);
+  }
+
+  /**
+   * Checks whether the word ends with the given string.
+   *
+   * @param string $string
+   *   The string.
+   *
+   * @return bool
+   *   TRUE if the word ends with the given string, FALSE otherwise.
+   */
+  protected function hasEnding($string) {
+    $length = strlen($string);
+    if ($length > $this->length()) {
+      return FALSE;
+    }
+    return (substr_compare($this->word, $string, -1 * $length, $length) === 0);
+  }
+
+  /**
+   * Appends a given string to the current word.
+   *
+   * @param string $string
+   *   The ending to append.
+   */
+  protected function addEnding($string) {
+    $this->word = $this->word . $string;
+  }
+
+  /**
+   * Removes a given string from the end of the current word.
+   *
+   * Does not check whether the ending is actually there.
+   *
+   * @param string $string
+   *   The ending to remove.
+   */
+  protected function removeEnding($string) {
+    $this->word = substr($this->word, 0, -strlen($string));
+  }
+
+  /**
+   * Checks whether the given string contains a vowel.
+   *
+   * @param string $string
+   *   The string to check.
+   *
+   * @return bool
+   *   TRUE if the string contains a vowel, FALSE otherwise.
+   */
+  protected function containsVowel($string) {
+    $inc = 0;
+    $return = FALSE;
+    while ($inc < strlen($string)) {
+      if ($this->isVowel($inc, $string)) {
+        $return = TRUE;
+        break;
+      }
+      $inc++;
+    }
+    return $return;
+  }
+
+  /**
+   * Checks whether the given string is a valid -li prefix.
+   *
+   * @param string $string
+   *   The string to check.
+   *
+   * @return bool
+   *   TRUE if the given string is a valid -li prefix, FALSE otherwise.
+   */
+  protected function validLi($string) {
+    return in_array($string, array(
+      'c',
+      'd',
+      'e',
+      'g',
+      'h',
+      'k',
+      'm',
+      'n',
+      'r',
+      't',
+    ));
+  }
+
+}

+ 28 - 13
sites/all/modules/contrib/search/search_api/includes/processor_stopwords.inc

@@ -1,10 +1,22 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiStopWords.
+ */
+
 /**
  * Processor for removing stopwords from index and search terms.
  */
 class SearchApiStopWords extends SearchApiAbstractProcessor {
 
+  /**
+   * Holds all words ignored for the last query.
+   *
+   * @var array
+   */
+  protected $ignored = array();
+
   public function configurationForm() {
     $form = parent::configurationForm();
 
@@ -14,8 +26,7 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
       ),
       'file' => array(
         '#type' => 'textfield',
-        '#title' => t('Stopwords file URI'),
-        '#title' => t('Enter the URI of your stopwords.txt file'),
+        '#title' => t('Stopwords file'),
         '#description' => t('This must be a stream-type description like <code>public://stopwords/stopwords.txt</code> or <code>http://example.com/stopwords.txt</code> or <code>private://stopwords.txt</code>.'),
       ),
       'stopwords' => array(
@@ -36,13 +47,8 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     parent::configurationFormValidate($form, $values, $form_state);
 
-    $stopwords = trim($values['stopwords']);
     $uri = $values['file'];
-    if (empty($stopwords) && empty($uri)) {
-      $el = $form['file'];
-      form_error($el, $el['#title'] . ': ' . t('At stopwords file or words are required.'));
-    }
-    if (!empty($uri) && !file_get_contents($uri)) {
+    if (!empty($uri) && !@file_get_contents($uri)) {
       $el = $form['file'];
       form_error($el, t('Stopwords file') . ': ' . t('The file %uri is not readable or does not exist.', array('%uri' => $uri)));
     }
@@ -50,7 +56,7 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
 
   public function process(&$value) {
     $stopwords = $this->getStopWords();
-    if (empty($stopwords)) {
+    if (empty($stopwords) || !is_string($value)) {
       return;
     }
     $words = preg_split('/\s+/', $value);
@@ -63,17 +69,26 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
     $value = implode(' ', $words);
   }
 
+  public function preprocessSearchQuery(SearchApiQuery $query) {
+    $this->ignored = array();
+    parent::preprocessSearchQuery($query);
+  }
+
   public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
-    if (isset($this->ignored)) {
+    if ($this->ignored) {
       if (isset($response['ignored'])) {
         $response['ignored'] = array_merge($response['ignored'], $this->ignored);
       }
-      else $response['ignored'] = $this->ignored;
+      else {
+        $response['ignored'] = $this->ignored;
+      }
     }
   }
 
   /**
-   * @return
+   * Retrieves the processor's configured stopwords.
+   *
+   * @return array
    *   An array whose keys are the stopwords set in either the file or the text
    *   field.
    */
@@ -91,4 +106,4 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
     $this->stopwords = array_flip(array_merge($file_words, $form_words));
     return $this->stopwords;
   }
-}
+}

+ 26 - 7
sites/all/modules/contrib/search/search_api/includes/processor_tokenizer.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiTokenizer.
+ */
+
 /**
  * Processor for tokenizing fulltext data by replacing (configurable)
  * non-letters with spaces.
@@ -18,6 +23,17 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
 
   public function configurationForm() {
     $form = parent::configurationForm();
+
+    // Only make fulltext fields available as options.
+    $fields = $this->index->getFields();
+    $field_options = array();
+    foreach ($fields as $name => $field) {
+      if (empty($field['real_type']) && search_api_is_text_type($field['type'])) {
+        $field_options[$name] = $field['name'];
+      }
+    }
+    $form['fields']['#options'] = $field_options;
+
     $form += array(
       'spaces' => array(
         '#type' => 'textfield',
@@ -37,7 +53,7 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
     );
 
     if (!empty($this->options)) {
-      $form['spaces']['#default_value']   = $this->options['spaces'];
+      $form['spaces']['#default_value'] = $this->options['spaces'];
       $form['ignorable']['#default_value'] = $this->options['ignorable'];
     }
 
@@ -76,12 +92,15 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
   }
 
   protected function process(&$value) {
-    $this->prepare();
-    if ($this->ignorable) {
-      $value = preg_replace('/' . $this->ignorable . '+/u', '', $value);
-    }
-    if ($this->spaces) {
-      $value = preg_replace('/' . $this->spaces . '+/u', ' ', $value);
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $this->prepare();
+      if ($this->ignorable) {
+        $value = preg_replace('/' . $this->ignorable . '+/u', '', $value);
+      }
+      if ($this->spaces) {
+        $value = preg_replace('/' . $this->spaces . '+/u', ' ', $value);
+      }
     }
   }
 

+ 20 - 0
sites/all/modules/contrib/search/search_api/includes/processor_transliteration.inc

@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiTransliteration.
+ */
+
+/**
+ * Processor for making searches insensitive to accents and other non-ASCII characters.
+ */
+class SearchApiTransliteration extends SearchApiAbstractProcessor {
+
+  protected function process(&$value) {
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $value = transliteration_get($value, '', language_default('language'));
+    }
+  }
+
+}

File diff suppressed because it is too large
+ 323 - 299
sites/all/modules/contrib/search/search_api/includes/query.inc


+ 176 - 8
sites/all/modules/contrib/search/search_api/includes/server_entity.inc

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiServer.
+ */
+
 /**
  * Class representing a search server.
  *
@@ -69,8 +74,8 @@ class SearchApiServer extends Entity {
   /**
    * Constructor as a helper to the parent constructor.
    */
-  public function __construct(array $values = array()) {
-    parent::__construct($values, 'search_api_server');
+  public function __construct(array $values = array(), $entity_type = 'search_api_server') {
+    parent::__construct($values, $entity_type);
   }
 
   /**
@@ -82,7 +87,7 @@ class SearchApiServer extends Entity {
    * @param array $fields
    *   The new field values.
    *
-   * @return
+   * @return int|false
    *   SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
    *   the specified values.
    */
@@ -136,6 +141,8 @@ class SearchApiServer extends Entity {
   }
 
   /**
+   * Reacts to calls of undefined methods on this object.
+   *
    * If the service class defines additional methods, not specified in the
    * SearchApiServiceInterface interface, then they are called via this magic
    * method.
@@ -148,81 +155,242 @@ class SearchApiServer extends Entity {
   // Proxy methods
 
   // For increased clarity, and since some parameters are passed by reference,
-  // we don't use the __call() magic method for those.
+  // we don't use the __call() magic method for those. This also gives us the
+  // opportunity to do additional error handling.
 
+  /**
+   * Form constructor for the server configuration form.
+   *
+   * @see SearchApiServiceInterface::configurationForm()
+   */
   public function configurationForm(array $form, array &$form_state) {
     $this->ensureProxy();
     return $this->proxy->configurationForm($form, $form_state);
   }
 
+  /**
+   * Validation callback for the form returned by configurationForm().
+   *
+   * @see SearchApiServiceInterface::configurationFormValidate()
+   */
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     $this->ensureProxy();
     return $this->proxy->configurationFormValidate($form, $values, $form_state);
   }
 
+  /**
+   * Submit callback for the form returned by configurationForm().
+   *
+   * @see SearchApiServiceInterface::configurationFormSubmit()
+   */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     $this->ensureProxy();
     return $this->proxy->configurationFormSubmit($form, $values, $form_state);
   }
 
+  /**
+   * Determines whether this service class supports a given feature.
+   *
+   * @see SearchApiServiceInterface::supportsFeature()
+   */
   public function supportsFeature($feature) {
     $this->ensureProxy();
     return $this->proxy->supportsFeature($feature);
   }
 
+  /**
+   * Displays this server's settings.
+   *
+   * @see SearchApiServiceInterface::viewSettings()
+   */
   public function viewSettings() {
     $this->ensureProxy();
     return $this->proxy->viewSettings();
   }
 
+  /**
+   * Reacts to the server's creation.
+   *
+   * @see SearchApiServiceInterface::postCreate()
+   */
   public function postCreate() {
     $this->ensureProxy();
     return $this->proxy->postCreate();
   }
 
+  /**
+   * Notifies this server that its fields are about to be updated.
+   *
+   * @see SearchApiServiceInterface::postUpdate()
+   */
   public function postUpdate() {
     $this->ensureProxy();
     return $this->proxy->postUpdate();
   }
 
+  /**
+   * Notifies this server that it is about to be deleted from the database.
+   *
+   * @see SearchApiServiceInterface::preDelete()
+   */
   public function preDelete() {
     $this->ensureProxy();
     return $this->proxy->preDelete();
   }
 
+  /**
+   * Adds a new index to this server.
+   *
+   * If an exception in the service class implementation of this method occurs,
+   * it will be caught and the operation saved as an pending server task.
+   *
+   * @see SearchApiServiceInterface::addIndex()
+   * @see search_api_server_tasks_add()
+   */
   public function addIndex(SearchApiIndex $index) {
     $this->ensureProxy();
-    return $this->proxy->addIndex($index);
+    try {
+      $this->proxy->addIndex($index);
+    }
+    catch (SearchApiException $e) {
+      $vars = array(
+        '%server' => $this->name,
+        '%index' => $index->name,
+      );
+      watchdog_exception('search_api', $e, '%type while adding index %index to server %server: !message in %function (line %line of %file).', $vars);
+      search_api_server_tasks_add($this, __FUNCTION__, $index);
+    }
   }
 
+  /**
+   * Notifies the server that the field settings for the index have changed.
+   *
+   * If the service class implementation of the method returns TRUE, this will
+   * automatically take care of marking the items on the index for re-indexing.
+   *
+   * If an exception in the service class implementation of this method occurs,
+   * it will be caught and the operation saved as an pending server task.
+   *
+   * @see SearchApiServiceInterface::fieldsUpdated()
+   * @see search_api_server_tasks_add()
+   */
   public function fieldsUpdated(SearchApiIndex $index) {
     $this->ensureProxy();
-    return $this->proxy->fieldsUpdated($index);
+    try {
+      if ($this->proxy->fieldsUpdated($index)) {
+        _search_api_index_reindex($index);
+        return TRUE;
+      }
+    }
+    catch (SearchApiException $e) {
+      $vars = array(
+        '%server' => $this->name,
+        '%index' => $index->name,
+      );
+      watchdog_exception('search_api', $e, '%type while updating the fields of index %index on server %server: !message in %function (line %line of %file).', $vars);
+      search_api_server_tasks_add($this, __FUNCTION__, $index, isset($index->original) ? $index->original : NULL);
+    }
+    return FALSE;
   }
 
+  /**
+   * Removes an index from this server.
+   *
+   * If an exception in the service class implementation of this method occurs,
+   * it will be caught and the operation saved as an pending server task.
+   *
+   * @see SearchApiServiceInterface::removeIndex()
+   * @see search_api_server_tasks_add()
+   */
   public function removeIndex($index) {
+    // When removing an index from a server, it doesn't make any sense anymore to
+    // delete items from it, or react to other changes.
+    search_api_server_tasks_delete(NULL, $this, $index);
+
     $this->ensureProxy();
-    return $this->proxy->removeIndex($index);
+    try {
+      $this->proxy->removeIndex($index);
+    }
+    catch (SearchApiException $e) {
+      $vars = array(
+        '%server' => $this->name,
+        '%index' => is_object($index) ? $index->name : $index,
+      );
+      watchdog_exception('search_api', $e, '%type while removing index %index from server %server: !message in %function (line %line of %file).', $vars);
+      search_api_server_tasks_add($this, __FUNCTION__, $index);
+    }
   }
 
+  /**
+   * Indexes the specified items.
+   *
+   * @see SearchApiServiceInterface::indexItems()
+   */
   public function indexItems(SearchApiIndex $index, array $items) {
     $this->ensureProxy();
     return $this->proxy->indexItems($index, $items);
   }
 
+  /**
+   * Deletes indexed items from this server.
+   *
+   * If an exception in the service class implementation of this method occurs,
+   * it will be caught and the operation saved as an pending server task.
+   *
+   * @see SearchApiServiceInterface::deleteItems()
+   * @see search_api_server_tasks_add()
+   */
   public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
     $this->ensureProxy();
-    return $this->proxy->deleteItems($ids, $index);
+    try {
+      $this->proxy->deleteItems($ids, $index);
+    }
+    catch (SearchApiException $e) {
+      $vars = array(
+        '%server' => $this->name,
+      );
+      watchdog_exception('search_api', $e, '%type while deleting items from server %server: !message in %function (line %line of %file).', $vars);
+      search_api_server_tasks_add($this, __FUNCTION__, $index, $ids);
+    }
   }
 
+  /**
+   * Creates a query object for searching on an index lying on this server.
+   *
+   * @see SearchApiServiceInterface::query()
+   */
   public function query(SearchApiIndex $index, $options = array()) {
     $this->ensureProxy();
     return $this->proxy->query($index, $options);
   }
 
+  /**
+   * Executes a search on the server represented by this object.
+   *
+   * @see SearchApiServiceInterface::search()
+   */
   public function search(SearchApiQueryInterface $query) {
     $this->ensureProxy();
     return $this->proxy->search($query);
   }
 
+  /**
+   * Retrieves additional information for the server, if available.
+   *
+   * Retrieving such information is only supported if the service class supports
+   * the "search_api_service_extra" feature.
+   *
+   * @return array
+   *   An array containing additional, service class-specific information about
+   *   the server.
+   *
+   * @see SearchApiAbstractService::getExtraInformation()
+   */
+  public function getExtraInformation() {
+    if ($this->proxy->supportsFeature('search_api_service_extra')) {
+      return $this->proxy->getExtraInformation();
+    }
+    return array();
+  }
+
 }

+ 121 - 117
sites/all/modules/contrib/search/search_api/includes/service.inc

@@ -1,16 +1,27 @@
 <?php
 
+/**
+ * @file
+ * Contains SearchApiServiceInterface and SearchApiAbstractService.
+ */
+
 /**
  * Interface defining the methods search services have to implement.
  *
  * Before a service object is used, the corresponding server's data will be read
  * from the database (see SearchApiAbstractService for a list of fields).
+ *
+ * Most methods in this interface (where any change in data occurs) can throw a
+ * SearchApiException. The server entity class SearchApiServer catches these
+ * exceptions and uses the server tasks system to assure that the action is
+ * later retried.
  */
 interface SearchApiServiceInterface {
 
   /**
-   * Constructor for a service class, setting the server configuration used with
-   * this service.
+   * Constructs a service object.
+   *
+   * This will set the server configuration used with this service.
    *
    * @param SearchApiServer $server
    *   The server object for this service.
@@ -18,8 +29,15 @@ interface SearchApiServiceInterface {
   public function __construct(SearchApiServer $server);
 
   /**
-   * Form callback. Might be called on an uninitialized object - in this case,
-   * the form is for configuring a newly created server.
+   * Form constructor for the server configuration form.
+   *
+   * Might be called with an incomplete server (no ID). In this case, the form
+   * is displayed for the initial creation of the server.
+   *
+   * @param array $form
+   *   The server options part of the form.
+   * @param array $form_state
+   *   The current form state.
    *
    * @return array
    *   A form array for setting service-specific options.
@@ -57,9 +75,10 @@ interface SearchApiServiceInterface {
   public function configurationFormSubmit(array $form, array &$values, array &$form_state);
 
   /**
-   * Determines whether this service class implementation supports a given
-   * feature. Features are optional extensions to Search API functionality and
-   * usually defined and used by third-party modules.
+   * Determines whether this service class supports a given feature.
+   *
+   * Features are optional extensions to Search API functionality and usually
+   * defined and used by third-party modules.
    *
    * There are currently three features defined directly in the Search API
    * project:
@@ -72,36 +91,43 @@ interface SearchApiServiceInterface {
    * @param string $feature
    *   The name of the optional feature.
    *
-   * @return boolean
+   * @return bool
    *   TRUE if this service knows and supports the specified feature. FALSE
    *   otherwise.
    */
   public function supportsFeature($feature);
 
   /**
-   * View this server's settings. Output can be HTML or a render array, a <dl>
-   * listing all relevant settings is preferred.
+   * Displays this server's settings.
+   *
+   * Output can be HTML or a render array, a <dl> listing all relevant settings
+   * is preferred.
    */
   public function viewSettings();
 
   /**
+   * Reacts to the server's creation.
+   *
    * Called once, when the server is first created. Allows it to set up its
    * necessary infrastructure.
    */
   public function postCreate();
 
   /**
-   * Notifies this server that its fields are about to be updated. The server's
-   * $original property can be used to inspect the old property values.
+   * Notifies this server that its fields are about to be updated.
+   *
+   * The server's $original property can be used to inspect the old property
+   * values.
    *
-   * @return
+   * @return bool
    *   TRUE, if the update requires reindexing of all content on the server.
    */
   public function postUpdate();
 
   /**
-   * Notifies this server that it is about to be deleted from the database and
-   * should therefore clean up, if appropriate.
+   * Notifies this server that it is about to be deleted from the database.
+   *
+   * This should execute any necessary cleanup operations.
    *
    * Note that you shouldn't call the server's save() method, or any
    * methods that might do that, from inside of this method as the server isn't
@@ -110,33 +136,39 @@ interface SearchApiServiceInterface {
   public function preDelete();
 
   /**
-   * Add a new index to this server.
+   * Adds a new index to this server.
    *
    * If the index was already added to the server, the object should treat this
    * as if removeIndex() and then addIndex() were called.
    *
    * @param SearchApiIndex $index
    *   The index to add.
+   *
+   * @throws SearchApiException
+   *   If an error occurred while adding the index.
    */
   public function addIndex(SearchApiIndex $index);
 
   /**
-   * Notify the server that the indexed field settings for the index have
-   * changed.
+   * Notifies the server that the field settings for the index have changed.
+   *
    * If any user action is necessary as a result of this, the method should
    * use drupal_set_message() to notify the user.
    *
    * @param SearchApiIndex $index
    *   The updated index.
    *
-   * @return
+   * @return bool
    *   TRUE, if this change affected the server in any way that forces it to
    *   re-index the content. FALSE otherwise.
+   *
+   * @throws SearchApiException
+   *   If an error occurred while reacting to the change of fields.
    */
   public function fieldsUpdated(SearchApiIndex $index);
 
   /**
-   * Remove an index from this server.
+   * Removes an index from this server.
    *
    * This might mean that the index has been deleted, or reassigned to a
    * different server. If you need to distinguish between these cases, inspect
@@ -144,14 +176,20 @@ interface SearchApiServiceInterface {
    *
    * If the index wasn't added to the server, the method call should be ignored.
    *
+   * Implementations of this method should also check whether $index->read_only
+   * is set, and don't delete any indexed data if it is.
+   *
    * @param $index
    *   Either an object representing the index to remove, or its machine name
    *   (if the index was completely deleted).
+   *
+   * @throws SearchApiException
+   *   If an error occurred while removing the index.
    */
   public function removeIndex($index);
 
   /**
-   * Index the specified items.
+   * Indexes the specified items.
    *
    * @param SearchApiIndex $index
    *   The search index for which items should be indexed.
@@ -182,7 +220,7 @@ interface SearchApiServiceInterface {
   public function indexItems(SearchApiIndex $index, array $items);
 
   /**
-   * Delete items from an index on this server.
+   * Deletes indexed items from this server.
    *
    * Might be either used to delete some items (given by their ids) from a
    * specified index, or all items from that index, or all items from all
@@ -195,11 +233,14 @@ interface SearchApiServiceInterface {
    * @param SearchApiIndex $index
    *   The index from which items should be deleted, or NULL if all indexes on
    *   this server should be cleared (then, $ids has to be 'all').
+   *
+   * @throws SearchApiException
+   *   If an error occurred while trying to delete the items.
    */
   public function deleteItems($ids = 'all', SearchApiIndex $index = NULL);
 
   /**
-   * Create a query object for searching on an index lying on this server.
+   * Creates a query object for searching on an index lying on this server.
    *
    * @param SearchApiIndex $index
    *   The index to search on.
@@ -234,6 +275,10 @@ interface SearchApiServiceInterface {
 
 /**
  * Abstract class with generic implementation of most service methods.
+ *
+ * For creating your own service class extending this class, you only need to
+ * implement indexItems(), deleteItems() and search() from the
+ * SearchApiServiceInterface interface.
  */
 abstract class SearchApiAbstractService implements SearchApiServiceInterface {
 
@@ -250,13 +295,9 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   protected $options = array();
 
   /**
-   * Constructor for a service class, setting the server configuration used with
-   * this service.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation sets $this->server and $this->options.
-   *
-   * @param SearchApiServer $server
-   *   The server object for this service.
    */
   public function __construct(SearchApiServer $server) {
     $this->server = $server;
@@ -264,46 +305,28 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Form callback. Might be called on an uninitialized object - in this case,
-   * the form is for configuring a newly created server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Returns an empty form by default.
-   *
-   * @return array
-   *   A form array for setting service-specific options.
    */
   public function configurationForm(array $form, array &$form_state) {
     return array();
   }
 
   /**
-   * Validation callback for the form returned by configurationForm().
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing by default.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
    */
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     return;
   }
 
   /**
-   * Submit callback for the form returned by configurationForm().
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation just ensures that additional elements in
    * $options, not present in the form, don't get lost at the update.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     if (!empty($this->options)) {
@@ -313,32 +336,16 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Determines whether this service class implementation supports a given
-   * feature. Features are optional extensions to Search API functionality and
-   * usually defined and used by third-party modules.
-   *
-   * There are currently three features defined directly in the Search API
-   * project:
-   * - "search_api_facets", by the search_api_facetapi module.
-   * - "search_api_facets_operator_or", also by the search_api_facetapi module.
-   * - "search_api_mlt", by the search_api_views module.
-   * Other contrib modules might define additional features. These should always
-   * be properly documented in the module by which they are defined.
-   *
-   * @param string $feature
-   *   The name of the optional feature.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @return boolean
-   *   TRUE if this service knows and supports the specified feature. FALSE
-   *   otherwise.
+   * The default implementation always returns FALSE.
    */
   public function supportsFeature($feature) {
     return FALSE;
   }
 
   /**
-   * View this server's settings. Output can be HTML or a render array, a <dl>
-   * listing all relevant settings is preferred.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation does a crude output as a definition list, with
    * option names taken from the configuration form.
@@ -364,8 +371,31 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Called once, when the server is first created. Allows it to set up its
-   * necessary infrastructure.
+   * Returns additional, service-specific information about this server.
+   *
+   * If a service class implements this method and supports the
+   * "search_api_service_extra" option, this method will be used to add extra
+   * information to the server's "View" tab.
+   *
+   * In the default theme implementation this data will be output in a table
+   * with two columns along with other, generic information about the server.
+   *
+   * @return array
+   *   An array of additional server information, with each piece of information
+   *   being an associative array with the following keys:
+   *   - label: The human-readable label for this data.
+   *   - info: The information, as HTML.
+   *   - status: (optional) The status associated with this information. One of
+   *     "info", "ok", "warning" or "error". Defaults to "info".
+   *
+   * @see supportsFeature()
+   */
+  public function getExtraInformation() {
+    return array();
+  }
+
+  /**
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing, by default.
    */
@@ -374,93 +404,67 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Notifies this server that its fields are about to be updated. The server's
-   * $original property can be used to inspect the old property values.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @return
-   *   TRUE, if the update requires reindexing of all content on the server.
+   * The default implementation always returns FALSE.
    */
   public function postUpdate() {
     return FALSE;
   }
 
   /**
-   * Notifies this server that it is about to be deleted from the database and
-   * should therefore clean up, if appropriate.
-   *
-   * Note that you shouldn't call the server's save() method, or any
-   * methods that might do that, from inside of this method as the server isn't
-   * present in the database anymore at this point.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * By default, deletes all indexes from this server.
    */
   public function preDelete() {
     $indexes = search_api_index_load_multiple(FALSE, array('server' => $this->server->machine_name));
     foreach ($indexes as $index) {
-      $this->removeIndex($index);
+      // removeIndex() might throw exceptions, but this method mustn't.
+      try {
+        $this->removeIndex($index);
+      }
+      catch (SearchApiException $e) {
+        $variables['%index'] = $index->name;
+        $variables['%server'] = $this->server->name;
+        watchdog_exception('search_api', $e, '%type while trying to remove index %index from deleted server %server: !message in %function (line %line of %file).', $variables);
+      }
     }
   }
 
   /**
-   * Add a new index to this server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing, by default.
-   *
-   * @param SearchApiIndex $index
-   *   The index to add.
    */
   public function addIndex(SearchApiIndex $index) {
     return;
   }
 
   /**
-   * Notify the server that the indexed field settings for the index have
-   * changed.
-   * If any user action is necessary as a result of this, the method should
-   * use drupal_set_message() to notify the user.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @param SearchApiIndex $index
-   *   The updated index.
-   *
-   * @return
-   *   TRUE, if this change affected the server in any way that forces it to
-   *   re-index the content. FALSE otherwise.
+   * The default implementation always returns FALSE.
    */
   public function fieldsUpdated(SearchApiIndex $index) {
     return FALSE;
   }
 
   /**
-   * Remove an index from this server.
-   *
-   * This might mean that the index has been deleted, or reassigned to a
-   * different server. If you need to distinguish between these cases, inspect
-   * $index->server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * By default, removes all items from that index.
-   *
-   * @param $index
-   *   Either an object representing the index to remove, or its machine name
-   *   (if the index was completely deleted).
    */
   public function removeIndex($index) {
-    $this->deleteItems('all', $index);
+    if (is_object($index) && empty($index->read_only)) {
+      $this->deleteItems('all', $index);
+    }
   }
 
   /**
-   * Create a query object for searching on an index lying on this server.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @param SearchApiIndex $index
-   *   The index to search on.
-   * @param $options
-   *   Associative array of options configuring this query. See
-   *   SearchApiQueryInterface::__construct().
-   *
-   * @return SearchApiQueryInterface
-   *   An object for searching the given index.
-   *
-   * @throws SearchApiException
-   *   If the server is currently disabled.
+   * The default implementation returns a SearchApiQuery object.
    */
   public function query(SearchApiIndex $index, $options = array()) {
     return new SearchApiQuery($index, $options);

+ 208 - 23
sites/all/modules/contrib/search/search_api/search_api.admin.css

@@ -1,44 +1,229 @@
+/**
+ * @file
+ * Styles for Search API admin pages.
+ */
 
-td.search-api-status {
+/*
+ * OVERVIEW
+ */
+
+.search-api-overview td.search-api-status {
+  text-align: center;
+}
+
+.search-api-overview td {
+  vertical-align: top;
+}
+
+/*
+ * VIEW SERVER
+ */
+
+.search-api-server-summary ul.inline {
+  margin: 0;
+}
+
+.search-api-server-summary ul.inline li {
+  padding-left: 0;
+}
+
+/*
+ * VIEW INDEX
+ */
+.search-api-limit,
+.search-api-batch-size {
   text-align: center;
 }
 
-div.search-api-edit-menu {
+.search-api-index-status .progress .filled {
+  background: #0074BD none;
+}
+
+/*
+ * DROPBUTTONS
+ *
+ * (Largely copied from D8's dropbutton.css.)
+ */
+
+/**
+ * When a dropbutton has only one option, it is simply a button.
+ */
+.dropbutton-wrapper,
+.dropbutton-wrapper div {
+  -moz-box-sizing: border-box;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+}
+
+.js .dropbutton-wrapper {
+  display: block;
+  min-height: 2em;
+  position: relative;
+}
+
+.js .dropbutton-wrapper,
+.js .dropbutton-widget {
+  max-width: 100%;
+}
+
+@media screen and (max-width: 600px) {
+  .js .dropbutton-wrapper {
+    width: 100%;
+  }
+}
+
+.js .dropbutton-widget {
   position: absolute;
-  background-color: white;
-  color: black;
-  z-index: 999;
-  border: 1px solid black;
-  -moz-border-radius: 4px;
-  -webkit-border-radius: 4px;
-  -khtml-border-radius: 4px;
-  border-radius: 4px;
 }
 
-div.search-api-edit-menu ul {
-  margin: 0 0.5em;
+/* UL styles are over-scoped in core, so this selector needs weight parity. */
+.js .dropbutton-widget .dropbutton {
+  list-style-image: none;
+  list-style-type: none;
+  margin: 0;
+  overflow: hidden;
   padding: 0;
 }
 
-div.search-api-edit-menu ul li {
-  padding: 0;
-  list-style-type: none;
+.js .dropbutton li,
+.js .dropbutton a {
   display: block;
 }
 
-div.search-api-edit-menu.collapsed {
+/**
+ * The dropbutton styling.
+ *
+ * A dropbutton is a widget that displays a list of action links as a button
+ * with a primary action. Secondary actions are hidden behind a click on a
+ * twisty arrow.
+ *
+ * The arrow is created using border on a zero-width, zero-height span.
+ * The arrow inherits the link color, but can be overridden with border colors.
+ */
+.js .dropbutton-multiple .dropbutton-widget {
+  padding-right: 2em; /* LTR */
+}
+
+.js[dir="rtl"] .dropbutton-multiple .dropbutton-widget {
+  padding-left: 2em;
+  padding-right: 0;
+}
+
+.dropbutton-multiple.open,
+.dropbutton-multiple.open .dropbutton-widget {
+  max-width: none;
+}
+
+.dropbutton-multiple.open {
+  z-index: 100;
+}
+
+.dropbutton-multiple .dropbutton .secondary-action {
   display: none;
 }
 
-.search-api-alter-add-aggregation-fields,
-.search-api-checkboxes-list {
-  max-height: 12em;
-  overflow: auto;
+.dropbutton-multiple.open .dropbutton .secondary-action {
+  display: block;
+}
+
+.dropbutton-toggle {
+  bottom: 0;
+  display: block;
+  position: absolute;
+  right: 0; /* LTR */
+  text-indent: 110%;
+  top: 0;
+  white-space: nowrap;
+  width: 2em;
+}
+
+[dir="rtl"] .dropbutton-toggle {
+  left: 0;
+  right: auto;
+}
+
+.dropbutton-toggle button {
+  background: none;
+  border: 0;
+  cursor: pointer;
+  display: block;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  width: 100%;
 }
 
-/* Workaround for http://drupal.org/node/1015798 */
-.vertical-tabs fieldset div.fieldset-wrapper fieldset legend {
+.dropbutton-arrow {
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+  border-right-color: transparent;
+  border-style: solid;
+  border-width: 0.3333em 0.3333em 0;
   display: block;
-  margin-bottom: 2em;
+  height: 0;
+  line-height: 0;
+  position: absolute;
+  right: 40%; /* 0.6667em; */
+  /* LTR */
+  top: 50%;
+  margin-top: -0.1666em;
+  width: 0;
+  overflow: hidden;
+}
+
+[dir="rtl"] .dropbutton-arrow {
+  left: 0.6667em;
+  right: auto;
+}
+
+.dropbutton-multiple.open .dropbutton-arrow {
+  border-bottom: 0.3333em solid;
+  border-top-color: transparent;
+  top: 0.6667em;
 }
 
+.js .dropbutton-widget {
+  background-color: white;
+  border: 1px solid #CCC;
+}
+
+.js .dropbutton-widget:hover {
+  border-color: #B8B8B8;
+}
+
+.dropbutton .dropbutton-action > * {
+  padding: 0.1em 0.5em;
+  white-space: nowrap;
+}
+
+.dropbutton .secondary-action {
+  border-top: 1px solid #E8E8E8;
+}
+
+.dropbutton-multiple .dropbutton {
+  border-right: 1px solid #E8E8E8; /* LTR */
+}
+
+[dir="rtl"] .dropbutton-multiple .dropbutton {
+  border-left: 1px solid #E8E8E8;
+  border-right: 0 none;
+}
+
+.dropbutton-multiple .dropbutton .dropbutton-action > * {
+  margin-right: 0.25em; /* LTR */
+}
+
+[dir="rtl"] .dropbutton-multiple .dropbutton .dropbutton-action > * {
+  margin-left: 0.25em;
+  margin-right: 0;
+}
+
+/*
+ * MISC
+ */
+
+.search-api-alter-add-aggregation-fields,
+.search-api-checkboxes-list {
+  max-height: 12em;
+  overflow: auto;
+}

File diff suppressed because it is too large
+ 709 - 400
sites/all/modules/contrib/search/search_api/search_api.admin.inc


+ 157 - 11
sites/all/modules/contrib/search/search_api/search_api.admin.js

@@ -1,7 +1,14 @@
+/**
+ * @file
+ * Javascript enhancements for the Search API admin pages.
+ */
 
-// Copied from filter.admin.js
 (function ($) {
 
+/**
+ * Allows the re-ordering of enabled data alterations and processors.
+ */
+// Copied from filter.admin.js
 Drupal.behaviors.searchApiStatus = {
   attach: function (context, settings) {
     $('.search-api-status-wrapper input.form-checkbox', context).once('search-api-status', function () {
@@ -43,19 +50,158 @@ Drupal.behaviors.searchApiStatus = {
   }
 };
 
-Drupal.behaviors.searchApiEditMenu = {
+/**
+ * Processes elements with the .dropbutton class on page load.
+ */
+Drupal.behaviors.searchApiDropButton = {
   attach: function (context, settings) {
-    $('.search-api-edit-menu-toggle', context).click(function (e) {
-      $menu = $(this).parent().find('.search-api-edit-menu');
-      if ($menu.is('.collapsed')) {
-    	$menu.removeClass('collapsed');
-      }
-      else {
-    	$menu.addClass('collapsed');
+    var $dropbuttons = $(context).find('.dropbutton-wrapper').once('dropbutton');
+    if ($dropbuttons.length) {
+      //$('.dropbutton-toggle', $dropbuttons).click(dropbuttonClickHandler);
+      // Initialize all buttons.
+      for (var i = 0, il = $dropbuttons.length; i < il; i++) {
+        DropButton.dropbuttons.push(new DropButton($dropbuttons[i], settings.dropbutton));
       }
-      return false;
-    });
+      // Adds the delegated handler that will toggle dropdowns on click.
+      $('.dropbutton-toggle', $dropbuttons).click(dropbuttonClickHandler);
+    }
   }
 };
 
+/**
+ * Delegated callback for opening and closing dropbutton secondary actions.
+ */
+function dropbuttonClickHandler(e) {
+  e.preventDefault();
+  $(e.target).closest('.dropbutton-wrapper').toggleClass('open');
+}
+
+/**
+ * A DropButton presents an HTML list as a button with a primary action.
+ *
+ * All secondary actions beyond the first in the list are presented in a
+ * dropdown list accessible through a toggle arrow associated with the button.
+ *
+ * @param {jQuery} dropbutton
+ *   A jQuery element.
+ *
+ * @param {Object} settings
+ *   A list of options including:
+ *    - {String} title: The text inside the toggle link element. This text is
+ *      hidden from visual UAs.
+ */
+function DropButton(dropbutton, settings) {
+  // Merge defaults with settings.
+  var options = $.extend({'title': Drupal.t('List additional actions')}, settings);
+  var $dropbutton = $(dropbutton);
+  this.$dropbutton = $dropbutton;
+  this.$list = $dropbutton.find('.dropbutton');
+  // Find actions and mark them.
+  this.$actions = this.$list.find('li').addClass('dropbutton-action');
+
+  // Add the special dropdown only if there are hidden actions.
+  if (this.$actions.length > 1) {
+    // Identify the first element of the collection.
+    var $primary = this.$actions.slice(0, 1);
+    // Identify the secondary actions.
+    var $secondary = this.$actions.slice(1);
+    $secondary.addClass('secondary-action');
+    // Add toggle link.
+    $primary.after(Drupal.theme('dropbuttonToggle', options));
+    // Bind mouse events.
+    this.$dropbutton
+      .addClass('dropbutton-multiple')
+      /**
+       * Adds a timeout to close the dropdown on mouseleave.
+       */
+      .bind('mouseleave.dropbutton', $.proxy(this.hoverOut, this))
+      /**
+       * Clears timeout when mouseout of the dropdown.
+       */
+      .bind('mouseenter.dropbutton', $.proxy(this.hoverIn, this))
+      /**
+       * Similar to mouseleave/mouseenter, but for keyboard navigation.
+       */
+      .bind('focusout.dropbutton', $.proxy(this.focusOut, this))
+      .bind('focusin.dropbutton', $.proxy(this.focusIn, this));
+  }
+}
+
+/**
+ * Extend the DropButton constructor.
+ */
+$.extend(DropButton, {
+  /**
+   * Store all processed DropButtons.
+   *
+   * @type {Array}
+   */
+  dropbuttons: []
+});
+
+/**
+ * Extend the DropButton prototype.
+ */
+$.extend(DropButton.prototype, {
+  /**
+   * Toggle the dropbutton open and closed.
+   *
+   * @param {Boolean} show
+   *   (optional) Force the dropbutton to open by passing true or to close by
+   *   passing false.
+   */
+  toggle: function (show) {
+    var isBool = typeof show === 'boolean';
+    show = isBool ? show : !this.$dropbutton.hasClass('open');
+    this.$dropbutton.toggleClass('open', show);
+  },
+
+  hoverIn: function () {
+    // Clear any previous timer we were using.
+    if (this.timerID) {
+      window.clearTimeout(this.timerID);
+    }
+  },
+
+  hoverOut: function () {
+    // Wait half a second before closing.
+    this.timerID = window.setTimeout($.proxy(this, 'close'), 500);
+  },
+
+  open: function () {
+    this.toggle(true);
+  },
+
+  close: function () {
+    this.toggle(false);
+  },
+
+  focusOut: function (e) {
+    this.hoverOut.call(this, e);
+  },
+
+  focusIn: function (e) {
+    this.hoverIn.call(this, e);
+  }
+});
+
+$.extend(Drupal.theme, {
+  /**
+   * A toggle is an interactive element often bound to a click handler.
+   *
+   * @param {Object} options
+   *   - {String} title: (optional) The HTML anchor title attribute and
+   *     text for the inner span element.
+   *
+   * @return {String}
+   *   A string representing a DOM fragment.
+   */
+  dropbuttonToggle: function (options) {
+    return '<li class="dropbutton-toggle"><button type="button" role="button"><span class="dropbutton-arrow"><span class="visually-hidden">' + options.title + '</span></span></button></li>';
+  }
+});
+
+// Expose constructor in the public space.
+Drupal.DropButton = DropButton;
+
 })(jQuery);

+ 91 - 15
sites/all/modules/contrib/search/search_api/search_api.api.php

@@ -15,8 +15,6 @@
  *
  * Note: The ids should be valid PHP identifiers.
  *
- * @see hook_search_api_service_info_alter()
- *
  * @return array
  *   An associative array of search service classes, keyed by a unique
  *   identifier and containing associative arrays with the following keys:
@@ -24,9 +22,12 @@
  *   - description: A translated string to be shown to administrators when
  *     selecting a service class. Should contain all peculiarities of the
  *     service class, like field type support, supported features (like facets),
- *     the "direct" parse mode and other specific things to keep in mind.
+ *     the "direct" parse mode and other specific things to keep in mind. The
+ *     text can contain HTML.
  *   - class: The service class, which has to implement the
  *     SearchApiServiceInterface interface.
+ *
+ * @see hook_search_api_service_info_alter()
  */
 function hook_search_api_service_info() {
   $services['example_some'] = array(
@@ -49,13 +50,14 @@ function hook_search_api_service_info() {
  * Alter the Search API service info.
  *
  * Modules may implement this hook to alter the information that defines Search
- * API service. All properties that are available in
- * hook_search_api_service_info() can be altered here.
- *
- * @see hook_search_api_service_info()
+ * API services. All properties that are available in
+ * hook_search_api_service_info() can be altered here, with the addition of the
+ * "module" key specifying the module that originally defined the service class.
  *
  * @param array $service_info
  *   The Search API service info array, keyed by service id.
+ *
+ * @see hook_search_api_service_info()
  */
 function hook_search_api_service_info_alter(array &$service_info) {
   foreach ($service_info as $id => $info) {
@@ -95,6 +97,9 @@ function hook_search_api_service_info_alter(array &$service_info) {
  *   - datasource controller: A class implementing the
  *     SearchApiDataSourceControllerInterface interface which will be used as
  *     the data source controller for this type.
+ *   - entity_type: (optional) If the type represents entities, the entity type.
+ *     This is used by SearchApiAbstractDataSourceController for determining the
+ *     entity type of items. Other datasource controllers might ignore this.
  *   Other, datasource-specific settings might also be placed here. These should
  *   be specified with the data source controller in question.
  *
@@ -109,6 +114,7 @@ function hook_search_api_item_type_info() {
       $types[$type] = array(
         'name' => $info['label'],
         'datasource controller' => 'SearchApiEntityDataSourceController',
+        'entity_type' => $type,
       );
     }
   }
@@ -121,7 +127,8 @@ function hook_search_api_item_type_info() {
  *
  * Modules may implement this hook to alter the information that defines an
  * item type. All properties that are available in
- * hook_search_api_item_type_info() can be altered here.
+ * hook_search_api_item_type_info() can be altered here, with the addition of
+ * the "module" key specifying the module that originally defined the type.
  *
  * @param array $infos
  *   The item type info array, keyed by type identifier.
@@ -186,6 +193,8 @@ function hook_search_api_data_type_info_alter(array &$infos) {
 }
 
 /**
+ * Define available data alterations.
+ *
  * Registers one or more callbacks that can be called at index time to add
  * additional data to the indexed items (e.g. comments or attachments to nodes),
  * alter the data in other forms or remove items from the array.
@@ -220,6 +229,21 @@ function hook_search_api_alter_callback_info() {
   return $callbacks;
 }
 
+/**
+ * Alter the available data alterations.
+ *
+ * @param array $callbacks
+ *   The callback information to be altered, keyed by callback IDs.
+ *
+ * @see hook_search_api_alter_callback_info()
+ */
+function hook_search_api_alter_callback_info_alter(array &$callbacks) {
+  if (!empty($callbacks['example_random_alter'])) {
+    $callbacks['example_random_alter']['name'] = t('Even more random alteration');
+    $callbacks['example_random_alter']['class'] = 'ExampleUltraRandomAlter';
+  }
+}
+
 /**
  * Registers one or more processors. These are classes implementing the
  * SearchApiProcessorInterface interface which can be used at index and search
@@ -255,6 +279,20 @@ function hook_search_api_processor_info() {
   return $callbacks;
 }
 
+/**
+ * Alter the available processors.
+ *
+ * @param array $processors
+ *   The processor information to be altered, keyed by processor IDs.
+ *
+ * @see hook_search_api_processor_info()
+ */
+function hook_search_api_processor_info_alter(array &$processors) {
+  if (!empty($processors['example_processor'])) {
+    $processors['example_processor']['weight'] = -20;
+  }
+}
+
 /**
  * Allows you to log or alter the items that are indexed.
  *
@@ -278,15 +316,53 @@ function hook_search_api_index_items_alter(array &$items, SearchApiIndex $index)
   example_store_indexed_entity_ids($index->item_type, array_keys($items));
 }
 
+/**
+ * Allows modules to react after items were indexed.
+ *
+ * @param SearchApiIndex $index
+ *   The used index.
+ * @param array $item_ids
+ *   An array containing the indexed items' IDs.
+ */
+function hook_search_api_items_indexed(SearchApiIndex $index, array $item_ids) {
+  if ($index->getEntityType() == 'node') {
+    // Flush page cache of the search page.
+    cache_clear_all(url('search'), 'cache_page');
+  }
+}
+
 /**
  * Lets modules alter a search query before executing it.
  *
  * @param SearchApiQueryInterface $query
- *   The SearchApiQueryInterface object representing the search query.
+ *   The search query being executed.
  */
 function hook_search_api_query_alter(SearchApiQueryInterface $query) {
-  $info = entity_get_info($index->item_type);
-  $query->condition($info['entity keys']['id'], 0, '!=');
+  // Exclude entities with ID 0. (Assume the ID field is always indexed.)
+  if ($query->getIndex()->getEntityType()) {
+    $info = entity_get_info($query->getIndex()->getEntityType());
+    $query->condition($info['entity keys']['id'], 0, '<>');
+  }
+}
+
+/**
+ * Alter the search results before they are returned.
+ *
+ * @param array $results
+ *   The results returned by the server, which may be altered. The data
+ *   structure is the same as returned by SearchApiQueryInterface::execute().
+ * @param SearchApiQueryInterface $query
+ *   The search query that was executed.
+ */
+function hook_search_api_results_alter(array &$results, SearchApiQueryInterface $query) {
+  if ($query->getOption('search id') == 'search_api_views:my_search_view:page') {
+    // Log the number of results.
+    $vars = array(
+      '@keys' => $query->getOriginalKeys(),
+      '@num' => $results['result count'],
+    );
+    watchdog('my_module', 'Search view with query "@keys" had @num results.', $vars, WATCHDOG_DEBUG);
+  }
 }
 
 /**
@@ -508,15 +584,15 @@ function hook_default_search_api_index_alter(array &$defaults) {
  * This function will be called for fields of the specific data type to convert
  * all individual values of the field to the correct format.
  *
- * @param $value
+ * @param mixed $value
  *   The raw, single value, as extracted from an entity wrapper.
- * @param $original_type
+ * @param string $original_type
  *   The original Entity API type of the value.
- * @param $type
+ * @param string $type
  *   The custom data type to which the value should be converted. Can be ignored
  *   if the callback is only used for a single data type.
  *
- * @return
+ * @return mixed|null
  *   The converted value, if a conversion could be executed. NULL otherwise.
  *
  * @see hook_search_api_data_type_info()

+ 446 - 34
sites/all/modules/contrib/search/search_api/search_api.drush.inc

@@ -22,6 +22,32 @@ function search_api_drush_command() {
     'aliases' => array('sapi-l'),
   );
 
+  $items['search-api-enable'] = array(
+    'description' => 'Enable one or all disabled search_api indexes.',
+    'examples' => array(
+      'drush searchapi-enable' => dt('Enable all disabled indexes.'),
+      'drush sapi-en' => dt('Alias to enable all disabled indexes.'),
+      'drush sapi-en 1' => dt('Enable index with the ID !id.', array('!id' => 1)),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index to enable.'),
+    ),
+    'aliases' => array('sapi-en'),
+  );
+
+  $items['search-api-disable'] = array(
+    'description' => 'Disable one or all enabled search_api indexes.',
+    'examples' => array(
+      'drush searchapi-disable' => dt('Disable all enabled indexes.'),
+      'drush sapi-dis' => dt('Alias to disable all enabled indexes.'),
+      'drush sapi-dis 1' => dt('Disable index with the ID !id.', array('!id' => 1)),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index to disable.'),
+    ),
+    'aliases' => array('sapi-dis'),
+  );
+
   $items['search-api-status'] = array(
     'description' => 'Show the status of one or all search indexes.',
     'examples' => array(
@@ -45,9 +71,10 @@ function search_api_drush_command() {
       'drush sapi-i default_node_index' => dt('Index items for the index with the machine name !name.', array('!name' => 'default_node_index')),
       'drush sapi-i 1 100' => dt("Index a maximum number of !limit items (index's cron batch size items per batch run) for the index with the ID !id.", array('!limit' => 100, '!id' => 1)),
       'drush sapi-i 1 100 10' => dt("Index a maximum number of !limit items (!batch_size items per batch run) for the index with the ID !id.", array('!limit' => 100, '!batch_size' => 10, '!id' => 1)),
+      'drush sapi-i 0 0 100' => dt("Index all items of all indexes with !batch_size items per batch run.", array('!batch_size' => 100)),
     ),
     'arguments' => array(
-      'index_id' => dt('The numeric ID or machine name of an index.'),
+      'index_id' => dt('The numeric ID or machine name of an index. Set to 0 to index all indexes. Defaults to 0 (index all).'),
       'limit' => dt("The number of items to index (index's cron batch size items per run). Set to 0 to index all items. Defaults to 0 (index all)."),
       'batch_size' => dt("The number of items to index per batch run. Set to 0 to index all items at once. Defaults to the index's cron batch size."),
     ),
@@ -68,13 +95,25 @@ function search_api_drush_command() {
     'aliases' => array('sapi-r'),
   );
 
+  $items['search-api-reindex-items'] = array(
+    'description' => 'Force re-indexing of one or more specific items.',
+    'examples' => array(
+      'drush search-api-reindex-items node 12,34,56' => dt('Schedule the nodes with ID 12, 34 and 56 for re-indexing.'),
+    ),
+    'arguments' => array(
+      'entity_type' => dt('The entity type whose items should be re-indexed.'),
+      'entities' => dt('The entities of the given entity type to be re-indexed.'),
+    ),
+    'aliases' => array('sapi-ri'),
+  );
+
   $items['search-api-clear'] = array(
     'description' => 'Clear one or all search indexes and mark them for re-indexing.',
     'examples' => array(
       'drush searchapi-clear' => dt('Clear all search indexes.'),
       'drush sapi-c' => dt('Alias to clear all search indexes.'),
-      'drush sapi-r 1' => dt('Clear the search index with the ID !id.', array('!id' => 1)),
-      'drush sapi-r default_node_index' => dt('Clear the search index with the machine name !name.', array('!name' => 'default_node_index')),
+      'drush sapi-c 1' => dt('Clear the search index with the ID !id.', array('!id' => 1)),
+      'drush sapi-c default_node_index' => dt('Clear the search index with the machine name !name.', array('!name' => 'default_node_index')),
     ),
     'arguments' => array(
       'index_id' => dt('The numeric ID or machine name of an index.'),
@@ -82,6 +121,65 @@ function search_api_drush_command() {
     'aliases' => array('sapi-c'),
   );
 
+  $items['search-api-execute-tasks'] = array(
+    'description' => 'Execute all pending tasks or all for a given server.',
+    'examples' => array(
+      'drush search-api-execute-tasks my_solr_server' => dt('Execute all pending tasks on !server', array('!server' => 'my_solr_server')),
+      'drush sapi-et my_solr_server' => dt('Execute all pending tasks on !server', array('!server' => 'my_solr_server')),
+      'drush sapi-et' => dt('Execute all pending tasks on all servers.')
+    ),
+    'arguments' => array(
+      'server_id' => dt('The numeric ID or machine name of a server to execute tasks on.'),
+    ),
+    'aliases' => array('sapi-et')
+  );
+
+  $items['search-api-set-index-server'] = array(
+    'description' => 'Set the search server used by a given index.',
+    'examples' => array(
+      'drush search-api-set-index-server default_node_index my_solr_server' => dt('Set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+      'drush sapi-sis default_node_index my_solr_server' => dt('Alias to set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index.'),
+      'server_id' => dt('The numeric ID or machine name of a server to set on the index.'),
+    ),
+    'aliases' => array('sapi-sis'),
+  );
+
+  $items['search-api-server-list'] = array(
+    'description' => 'List all search servers.',
+    'examples' => array(
+      'drush search-api-server-list' => dt('List all search servers.'),
+      'drush sapi-sl' => dt('Alias to list all search servers.'),
+    ),
+    'aliases' => array('sapi-sl'),
+  );
+
+  $items['search-api-server-enable'] = array(
+    'description' => 'Enable a search server.',
+    'examples' => array(
+      'drush search-api-server-e my_solr_server' => dt('Enable the !server search server.', array('!server' => 'my_solr_server')),
+      'drush sapi-se my_solr_server' => dt('Alias to enable the !server search server.', array('!server' => 'my_solr_server')),
+    ),
+    'arguments' => array(
+      'server_id' => dt('The numeric ID or machine name of a search server to enable.'),
+    ),
+    'aliases' => array('sapi-se'),
+  );
+
+  $items['search-api-server-disable'] = array(
+    'description' => 'Disable a search server.',
+    'examples' => array(
+      'drush search-api-server-disable' => dt('Disable the !server search server.', array('!server' => 'my_solr_server')),
+      'drush sapi-sd' => dt('Alias to disable the !server search server.', array('!server' => 'my_solr_server')),
+    ),
+    'arguments' => array(
+      'server_id' => dt('The numeric ID or machine name of a search server to disable.'),
+    ),
+    'aliases' => array('sapi-sd'),
+  );
+
   return $items;
 }
 
@@ -111,15 +209,21 @@ function drush_search_api_list() {
   foreach ($indexes as $index) {
     $type = search_api_get_item_type_info($index->item_type);
     $type = isset($type['name']) ? $type['name'] : $index->item_type;
-    $server = $index->server();
-    $server = $server ? $server->name : '(' . t('none') . ')';
+    try {
+      $server = $index->server();
+      $server = $server ? $server->name : '(' . dt('none') . ')';
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
+      $server = '(' . dt('unknown: !server', array('server' => $index->server)) . ')';
+    }
     $row = array(
       $index->id,
       $index->name,
       $index->machine_name,
       $server,
       $type,
-      $index->enabled ? t('enabled') : t('disabled'),
+      $index->enabled ? dt('enabled') : dt('disabled'),
       $index->options['cron_limit'],
     );
     $rows[] = $row;
@@ -127,6 +231,79 @@ function drush_search_api_list() {
   drush_print_table($rows);
 }
 
+/**
+ * Enable index(es).
+ *
+ * @param string|integer $index_id
+ *   The index name or id which should be enabled.
+ */
+function drush_search_api_enable($index_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  foreach ($indexes as $index) {
+    $vars = array('!index' => $index->name);
+    if (!$index->enabled) {
+      drush_log(dt("Enabling index !index and queueing items for indexing.", $vars), 'notice');
+      $success = FALSE;
+      try {
+        if ($success = search_api_index_enable($index->id)) {
+          drush_log(dt("The index !index was successfully enabled.", $vars), 'ok');
+        }
+      }
+      catch (SearchApiException $e) {
+        drush_log($e->getMessage(), 'error');
+      }
+      if (!$success) {
+        drush_log(dt("Error enabling index !index.", $vars), 'error');
+      }
+    }
+    else {
+      drush_log(dt("The index !index is already enabled.", $vars), 'error');
+    }
+  }
+}
+
+/**
+ * Disable index(es).
+ *
+ * @param string|integer $index_id
+ *   The index name or id which should be disabled.
+ */
+function drush_search_api_disable($index_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  foreach ($indexes as $index) {
+    $vars = array('!index' => $index->name);
+    if ($index->enabled) {
+      $success = FALSE;
+      try {
+        if ($success = search_api_index_disable($index->id)) {
+          drush_log(dt("The index !index was successfully disabled.", $vars), 'ok');
+        }
+      }
+      catch (SearchApiException $e) {
+        drush_log($e->getMessage(), 'error');
+      }
+      if (!$success) {
+        drush_log(dt("Error disabling index !index.", $vars), 'error');
+      }
+    }
+    else {
+      drush_log(dt("The index !index is already disabled.", $vars), 'error');
+    }
+  }
+}
+
 /**
  * Display index status.
  */
@@ -175,40 +352,77 @@ function drush_search_api_index($index_id = NULL, $limit = NULL, $batch_size = N
   if (search_api_drush_static(__FUNCTION__)) {
     return;
   }
+  $index_id = !empty($index_id) ? $index_id : NULL;
   $indexes = search_api_drush_get_index($index_id);
   if (empty($indexes)) {
     return;
   }
+
+  $process_batch = FALSE;
   foreach ($indexes as $index) {
-    // Get the number of remaing items to index.
+    if (_drush_search_api_batch_indexing_create($index, $limit, $batch_size)) {
+      $process_batch = TRUE;
+    }
+  }
+
+  if ($process_batch) {
+    drush_backend_batch_process();
+  }
+}
+
+/**
+ * Creates and sets a batch for indexing items for a particular index.
+ *
+ * @param SearchApiIndex $index
+ *   The index for which items should be indexed.
+ * @param int $limit
+ *   (optional) The maximum number of items to index, or NULL to index all
+ *   items.
+ * @param int $batch_size
+ *   (optional) The number of items to index per batch, or NULL to index all
+ *   items at once.
+ *
+ * @return bool
+ *   TRUE if batch was created, FALSE otherwise.
+ */
+function _drush_search_api_batch_indexing_create(SearchApiIndex $index, $limit = NULL, $batch_size = NULL) {
+  // Get the number of remaining items to index.
+  try {
     $datasource = $index->datasource();
-    $index_status = $datasource->getIndexStatus($index);
-    $remaining = $index_status['total'] - $index_status['indexed'];
+  }
+  catch (SearchApiException $e) {
+    drush_log($e->getMessage(), 'error');
+    return FALSE;
+  }
+  $index_status = $datasource->getIndexStatus($index);
+  $remaining = $index_status['total'] - $index_status['indexed'];
+  if ($remaining <= 0) {
+    drush_log(dt("The index !index is up to date.", array('!index' => $index->name)), 'ok');
+    return FALSE;
+  }
 
-    // Get the number of items to index per batch run.
-    if (!isset($batch_size)) {
-      $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
-    }
-    elseif ($batch_size <= 0) {
-      $batch_size = $remaining;
-    }
+  // Get the number of items to index per batch run.
+  if (!isset($batch_size)) {
+    $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
+  }
+  elseif ($batch_size <= 0) {
+    $batch_size = $remaining;
+  }
 
-    // Get the number items to index.
-    if (!isset($limit) || !is_int($limit += 0) || $limit <= 0) {
-      $limit = $remaining;
-    }
+  // Get the total number of items to index.
+  if (!isset($limit) || !is_int($limit += 0) || $limit <= 0) {
+    $limit = $remaining;
+  }
 
-    drush_log(dt("Indexing a maximum number of !limit items (!batch_size items per batch run) for the index !index.", array('!index' => $index->name, '!limit' => $limit, '!batch_size' => $batch_size)), 'ok');
+  drush_log(dt("Indexing a maximum number of !limit items (!batch_size items per batch run) for the index !index.", array('!index' => $index->name, '!limit' => $limit, '!batch_size' => $batch_size)), 'ok');
 
-    // Create the batch.
-    if (!_search_api_batch_indexing_create($index, $batch_size, $limit, $remaining, TRUE)) {
-      drush_log(dt("Couldn't create a batch, please check the batch size and limit parameters."), 'error');
-    }
-    else {
-      // Launch the batch process.
-      drush_backend_batch_process();
-    }
+  // Create the batch.
+  if (!_search_api_batch_indexing_create($index, $batch_size, $limit, $remaining, TRUE)) {
+    drush_log(dt("Couldn't create a batch, please check the batch size and limit parameters."), 'error');
+    return FALSE;
   }
+
+  return TRUE;
 }
 
 /**
@@ -259,6 +473,33 @@ function drush_search_api_reindex($index_id = NULL) {
   }
 }
 
+/**
+ * Marks the given entities as needing to be re-indexed.
+ */
+function drush_search_api_reindex_items($entity_type, $entities) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+
+  // Validate list of entity ids.
+  if (!empty($entities) && !preg_match('#^[0-9]*(,[0-9]*)*$#', $entities)) {
+    drush_log(dt('Entities should be a single numeric entity ID or a list with the numeric entity IDs separated by comma.'), 'error');
+    return;
+  }
+
+  $ids = explode(',', $entities);
+
+  if (!empty($ids)) {
+    search_api_track_item_change($entity_type, $ids);
+
+    $combined_ids = array();
+    foreach ($ids as $id) {
+      $combined_ids[] = $entity_type . '/' . $id;
+    }
+    search_api_track_item_change('multiple', $combined_ids);
+  }
+}
+
 /**
  * Clear an index.
  */
@@ -278,12 +519,82 @@ function drush_search_api_clear($index_id = NULL) {
 }
 
 /**
- * Helper function to return an index or all indexes as an array.
+ * Execute all pending tasks or all for a given server.
+ */
+function drush_search_api_execute_tasks($server_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+
+  // Attempt to load the associated server.
+  $server = NULL;
+  if ($server_id) {
+    $servers = search_api_drush_get_server($server_id);
+    if (!$servers) {
+      return;
+    }
+    $server = reset($servers);
+  }
+
+  // Process batch op with drush.
+  try {
+    search_api_execute_pending_tasks($server);
+    drush_log(dt('!server tasks have been successfully executed.', array('!server' => $server->machine_name ? $server->machine_name : 'All')), 'ok');
+  }
+  catch (SearchApiException $e) {
+    drush_log($e->getMessage(), 'error');
+  }
+}
+
+/**
+ * Set the server for a given index.
+ */
+function drush_search_api_set_index_server($index_id = NULL, $server_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  // Make sure we have parameters to work with.
+  if (empty($index_id) || empty($server_id)) {
+    drush_log(dt('You must specify both an index and server.'), 'error');
+    return;
+  }
+  // Fetch current index and server data.
+  $indexes = search_api_drush_get_index($index_id);
+  $servers = search_api_drush_get_server($server_id);
+  if (empty($indexes) || empty($servers)) {
+    // If the specified index or server can't be found, just return. An
+    // appropriate error message should have been printed already.
+    return;
+  }
+  // Set the new server on the index.
+  $success = FALSE;
+  $index = reset($indexes);
+  $server = reset($servers);
+  try {
+    $success = $index->update(array('server' => $server->machine_name));
+  }
+  catch (SearchApiException $e) {
+    drush_log($e->getMessage(), 'error');
+  }
+  if ($success === FALSE) {
+    drush_log(dt('There was an error setting index !index to use server !server.', array('!index' => $index->name, '!server' => $server->name)), 'error');
+  }
+  elseif (!$success) {
+    drush_log(dt('Index !index was already using server !server.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+  }
+  else {
+    drush_log(dt('Index !index has been set to use server !server and items have been queued for indexing.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+  }
+}
+
+/**
+ * Returns an index or all indexes as an array.
  *
- * @param $index_id
- *   (optional) The provided index id.
+ * @param string|int|null $index_id
+ *   (optional) The ID or machine name of the index to load. Defaults to
+ *   loading all available indexes.
  *
- * @return
+ * @return SearchApiIndex[]
  *   An array of indexes.
  */
 function search_api_drush_get_index($index_id = NULL) {
@@ -298,7 +609,35 @@ function search_api_drush_get_index($index_id = NULL) {
 }
 
 /**
- * Static lookup to prevent Drush 4 from running twice.
+ * Returns a server or all servers as an array.
+ *
+ * @param string|int|null $server_id
+ *   (optional) The ID or machine name of the server to load. Defaults to
+ *   loading all available servers.
+ *
+ * @return SearchApiServer[]
+ *   An array of servers.
+ */
+function search_api_drush_get_server($server_id = NULL) {
+  $ids = isset($server_id) ? array($server_id) : FALSE;
+  $servers = search_api_server_load_multiple($ids);
+  if (empty($servers)) {
+    drush_set_error(dt('Invalid server_id or no servers present.'));
+    drush_print();
+    drush_search_api_server_list();
+  }
+  return $servers;
+}
+
+/**
+ * Does a static lookup to prevent Drush 4 from running twice.
+ *
+ * @param string $function
+ *   The Drush function being called.
+ *
+ * @return bool
+ *   TRUE if the function was already called in this Drush execution, FALSE
+ *   otherwise.
  *
  * @see http://drupal.org/node/704848
  */
@@ -308,4 +647,77 @@ function search_api_drush_static($function) {
     return TRUE;
   }
   $index[$function] = TRUE;
+  return FALSE;
+}
+
+/**
+ * Lists all search servers.
+ */
+function drush_search_api_server_list() {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $servers = search_api_server_load_multiple(FALSE);
+  if (empty($servers)) {
+    drush_print(dt('There are no servers present.'));
+    return;
+  }
+  $rows[] = array(
+    dt('Machine name'),
+    dt('Name'),
+    dt('Status'),
+  );
+  foreach ($servers as $server) {
+    $row = array(
+      $server->machine_name,
+      $server->name,
+      $server->enabled ? dt('enabled') : dt('disabled'),
+    );
+    $rows[] = $row;
+  }
+  drush_print_table($rows);
+}
+
+/**
+ * Enables a search server.
+ *
+ * @param int|string $server_id
+ *   The numeric ID or machine name of the server to enable.
+ */
+function drush_search_api_server_enable($server_id = NULL) {
+  if (!isset($server_id)) {
+    drush_print(dt('Please provide a valid server id.'));
+    return;
+  }
+  $server = search_api_server_load($server_id);
+  if (empty($server)) {
+    drush_print(dt('The server was not able to load.'));
+    return;
+  }
+  else {
+    $server->update(array('enabled' => 1));
+    drush_print(dt('The server was enabled successfully.'));
+  }
+}
+
+/**
+ * Disables a search server.
+ *
+ * @param int|string $server_id
+ *   The numeric ID or machine name of the server to disable.
+ */
+function drush_search_api_server_disable($server_id = NULL) {
+  if (!isset($server_id)) {
+    drush_print(dt('Please provide a valid server id.'));
+    return;
+  }
+  $server = search_api_server_load($server_id);
+  if (empty($server)) {
+    drush_print(dt('The server was not able to load.'));
+    return;
+  }
+  else {
+    $server->update(array('enabled' => 0));
+    drush_print(dt('The server was disabled successfully.'));
+  }
 }

+ 13 - 6
sites/all/modules/contrib/search/search_api/search_api.info

@@ -1,6 +1,6 @@
 name = Search API
-description = "Provides a generic API for modules offering search capabilites."
-dependencies[] = entity
+description = "Provides a generic API for modules offering search capabilities."
+dependencies[] = entity:entity
 core = 7.x
 package = Search
 
@@ -11,28 +11,35 @@ files[] = includes/callback_add_hierarchy.inc
 files[] = includes/callback_add_url.inc
 files[] = includes/callback_add_viewed_entity.inc
 files[] = includes/callback_bundle_filter.inc
+files[] = includes/callback_comment_access.inc
 files[] = includes/callback_language_control.inc
 files[] = includes/callback_node_access.inc
 files[] = includes/callback_node_status.inc
+files[] = includes/callback_role_filter.inc
+files[] = includes/callback_user_content.inc
+files[] = includes/callback_user_status.inc
 files[] = includes/datasource.inc
 files[] = includes/datasource_entity.inc
 files[] = includes/datasource_external.inc
+files[] = includes/datasource_multiple.inc
 files[] = includes/exception.inc
 files[] = includes/index_entity.inc
 files[] = includes/processor.inc
+files[] = includes/processor_highlight.inc
 files[] = includes/processor_html_filter.inc
 files[] = includes/processor_ignore_case.inc
+files[] = includes/processor_stemmer.inc
 files[] = includes/processor_stopwords.inc
 files[] = includes/processor_tokenizer.inc
+files[] = includes/processor_transliteration.inc
 files[] = includes/query.inc
 files[] = includes/server_entity.inc
 files[] = includes/service.inc
 
 configure = admin/config/search/search_api
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by Drupal.org packaging script on 2019-03-11
+version = "7.x-1.26"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
-
+datestamp = "1552334832"

+ 318 - 7
sites/all/modules/contrib/search/search_api/search_api.install

@@ -191,9 +191,124 @@ function search_api_schema() {
     'primary key' => array('item_id', 'index_id'),
   );
 
+  $schema['search_api_item_string_id'] = array(
+    'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+    'fields' => array(
+      'item_id' => array(
+        'description' => "The item's ID.",
+        'type' => 'varchar',
+        'length' => 64,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.id this item belongs to.',
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'changed' => array(
+        'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+        'type' => 'int',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+    ),
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+
+  $schema['search_api_task'] = array(
+    'description' => 'Stores pending tasks for servers.',
+    'fields' => array(
+      'id' => array(
+        'description' => 'An integer identifying this task.',
+        'type' => 'serial',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'server_id' => array(
+        'description' => 'The {search_api_server}.machine_name for which this task should be executed.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'type' => array(
+        'description' => 'A keyword identifying the type of task that should be executed.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.machine_name to which this task pertains, if applicable for this type.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => FALSE,
+      ),
+      'data' => array(
+        'description' => 'Some data needed for the task, might be optional depending on the type.',
+        'type' => 'text',
+        'size' => 'medium',
+        'serialize' => TRUE,
+        'not null' => FALSE,
+      ),
+    ),
+    'indexes' => array(
+      'server' => array('server_id'),
+    ),
+    'primary key' => array('id'),
+  );
+
   return $schema;
 }
 
+/**
+ * Implements hook_requirements().
+ */
+function search_api_requirements($phase) {
+  $requirements = array();
+
+  if ($phase == 'runtime') {
+    // Check whether at least one server has pending tasks.
+    if (search_api_server_tasks_count()) {
+      $items = array();
+
+      $conditions = array('enabled' => TRUE);
+      foreach (search_api_server_load_multiple(FALSE, $conditions) as $server) {
+        $count = search_api_server_tasks_count($server);
+        if ($count) {
+          $args = array(
+            '@name' => $server->name,
+          );
+          $text = format_plural($count, '@name has @count pending task.', '@name has @count pending tasks.', $args);
+          $items[] = l($text, "admin/config/search/search_api/server/{$server->machine_name}/execute-tasks");
+        }
+      }
+
+      if ($items) {
+        $text = t('There are pending tasks for the following servers:');
+        $text .= theme('item_list', array(
+          'type' => 'ul',
+          'items' => $items,
+        ));
+        if (count($items) > 1) {
+          $label = t('Execute pending tasks on all servers');
+          $text .= l($label, 'admin/config/search/search_api/execute-tasks');
+        }
+        $requirements['search_api_pending_tasks'] = array(
+          'title' => t('Search API'),
+          'value' => $text,
+          'severity' => REQUIREMENT_WARNING,
+        );
+      }
+    }
+  }
+
+  return $requirements;
+}
+
 /**
  * Implements hook_install().
  *
@@ -214,6 +329,7 @@ function search_api_install() {
     'server' => NULL,
     'item_type' => 'node',
     'options' => array(
+      'index_directly' => 1,
       'cron_limit' => '50',
       'data_alter_callbacks' => array(
         'search_api_alter_node_access' => array(
@@ -291,7 +407,7 @@ function search_api_install() {
     ),
   );
   search_api_index_insert($values);
-  drupal_set_message('The Search API module was installed. A new default node index was created.');
+  drupal_set_message(t('The Search API module was installed. A new default node index was created.'));
 }
 
 /**
@@ -307,8 +423,13 @@ function search_api_enable() {
     }
   }
   foreach ($types as $type => $indexes) {
-    $controller = search_api_get_datasource_controller($type);
-    $controller->startTracking($indexes);
+    try {
+      $controller = search_api_get_datasource_controller($type);
+      $controller->startTracking($indexes);
+    }
+    catch (SearchApiException $e) {
+      watchdog_exception('search_api', $e);
+    }
   }
 }
 
@@ -321,8 +442,13 @@ function search_api_disable() {
     $types[$index->item_type][] = $index;
   }
   foreach ($types as $type => $indexes) {
-    $controller = search_api_get_datasource_controller($type);
-    $controller->stopTracking($indexes);
+    try {
+      $controller = search_api_get_datasource_controller($type);
+      $controller->stopTracking($indexes);
+    }
+    catch (SearchApiException $e) {
+      // Modules defining entity or item types might have been disabled. Ignore.
+    }
   }
 }
 
@@ -330,7 +456,6 @@ function search_api_disable() {
  * Implements hook_uninstall().
  */
 function search_api_uninstall() {
-  variable_del('search_api_tasks');
   variable_del('search_api_index_worker_callback_runtime');
 }
 
@@ -605,7 +730,7 @@ function search_api_update_7106() {
       $callbacks['search_api_alter_add_aggregation'] = $callbacks['search_api_alter_add_fulltext'];
       unset($callbacks['search_api_alter_add_fulltext']);
       if (!empty($callbacks['search_api_alter_add_aggregation']['settings']['fields'])) {
-        foreach ($callbacks['search_api_alter_add_aggregation']['settings']['fields'] as $field => &$info) {
+        foreach ($callbacks['search_api_alter_add_aggregation']['settings']['fields'] as &$info) {
           if (!isset($info['type'])) {
             $info['type'] = 'fulltext';
           }
@@ -806,3 +931,189 @@ function search_api_update_7114() {
     }
   }
 }
+
+/**
+ * Switch to indexing without the use of a cron queue.
+ */
+function search_api_update_7115() {
+  variable_del('search_api_batch_per_cron');
+  DrupalQueue::get('search_api_indexing_queue')->deleteQueue();
+  db_update('search_api_item')
+    ->fields(array(
+      'changed' => 1,
+    ))
+    ->condition('changed', 0, '<')
+    ->execute();
+}
+
+/**
+ * Transfers the tasks for disabled servers to a separate database table.
+ */
+function search_api_update_7116() {
+  // Create table.
+  $table = array(
+    'description' => 'Stores pending tasks for servers.',
+    'fields' => array(
+      'id' => array(
+        'description' => 'An integer identifying this task.',
+        'type' => 'serial',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'server_id' => array(
+        'description' => 'The {search_api_server}.machine_name for which this task should be executed.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'type' => array(
+        'description' => 'A keyword identifying the type of task that should be executed.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.machine_name to which this task pertains, if applicable for this type.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => FALSE,
+      ),
+      'data' => array(
+        'description' => 'Some data needed for the task, might be optional depending on the type.',
+        'type' => 'text',
+        'size' => 'medium',
+        'serialize' => TRUE,
+        'not null' => FALSE,
+      ),
+    ),
+    'indexes' => array(
+      'server' => array('server_id'),
+    ),
+    'primary key' => array('id'),
+  );
+  db_create_table('search_api_task', $table);
+
+  // Collect old tasks.
+  $tasks = array();
+  foreach (variable_get('search_api_tasks', array()) as $server => $indexes) {
+    foreach ($indexes as $index => $old_tasks) {
+      if (in_array('clear all', $old_tasks)) {
+        $tasks[] = array(
+          'server_id' => $server,
+          'type' => 'deleteItems',
+        );
+      }
+      if (in_array('remove', $old_tasks)) {
+        $tasks[] = array(
+          'server_id' => $server,
+          'type' => 'removeIndex',
+          'index_id' => $index,
+        );
+      }
+    }
+  }
+  variable_del('search_api_tasks');
+
+  $select = db_select('search_api_index', 'i')
+    ->fields('i', array('machine_name', 'server'));
+  $select->innerJoin('search_api_server', 's', 'i.server = s.machine_name AND s.enabled = 0');
+  $index_ids = array();
+  foreach ($select->execute() as $index) {
+    $index_ids[] = $index->machine_name;
+    $tasks[] = array(
+      'server_id' => $index->server,
+      'type' => 'removeIndex',
+      'index_id' => $index->machine_name,
+    );
+  }
+  if ($index_ids) {
+    db_update('search_api_index')
+      ->fields(array(
+        'enabled' => 0,
+        'server' => NULL,
+      ))
+      ->condition('machine_name', $index_ids)
+      ->execute();
+  }
+
+  if ($tasks) {
+    $insert = db_insert('search_api_task')
+      ->fields(array('server_id', 'type', 'index_id', 'data'));
+    foreach ($tasks as $task) {
+      $task += array(
+        'index_id' => NULL,
+        'data' => NULL,
+      );
+      $insert->values($task);
+    }
+    $insert->execute();
+  }
+}
+
+/**
+ * Checks the database for illegal {search_api_index}.server values.
+ */
+function search_api_update_7117() {
+  $servers = db_select('search_api_server', 's')
+    ->fields('s', array('machine_name'))
+    ->condition('enabled', 1);
+  $indexes = db_select('search_api_index', 'i')
+    ->fields('i', array('id'))
+    ->condition('server', $servers, 'NOT IN')
+    ->execute()
+    ->fetchCol();
+  if ($indexes) {
+    db_delete('search_api_item')
+      ->condition('index_id', $indexes)
+      ->execute();
+    db_update('search_api_index')
+      ->fields(array(
+        'server' => NULL,
+        'enabled' => 0,
+      ))
+      ->condition('id', $indexes)
+      ->execute();
+  }
+}
+
+/**
+ * Adds the {search_api_item_string_id} table for items with string IDs.
+ */
+function search_api_update_7118() {
+  // Some users have reported that the table already existed for them, for
+  // whatever reason. Therefore, just bail if the table already exists, assuming
+  // it already looks as expected.
+  if (db_table_exists('search_api_item_string_id')) {
+    return;
+  }
+
+  $table = array(
+    'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+    'fields' => array(
+      'item_id' => array(
+        'description' => "The item's ID.",
+        'type' => 'varchar',
+        'length' => 64,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.id this item belongs to.',
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'changed' => array(
+        'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+        'type' => 'int',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+    ),
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+  db_create_table('search_api_item_string_id', $table);
+}

File diff suppressed because it is too large
+ 521 - 166
sites/all/modules/contrib/search/search_api/search_api.module


+ 6 - 3
sites/all/modules/contrib/search/search_api/search_api.rules.inc

@@ -10,7 +10,7 @@
  * Implements hook_rules_action_info().
  */
 function search_api_rules_action_info() {
-  $items['search_api_index'] = array (
+  $items['search_api_index'] = array(
    'parameter' => array(
       'entity' => array(
         'type' => 'entity',
@@ -52,6 +52,9 @@ function _search_api_rules_access() {
  * Rules action for indexing an item.
  */
 function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiIndex $index = NULL, $index_immediately = TRUE) {
+  // If we do not have an index, we need to guess the item type to use.
+  // @todo Since this can only be used with entities anyways, we can just loop
+  //   over the item type information and use all types with that entity type.
   $type = $wrapper->type();
   $item_ids = array($wrapper->getIdentifier());
 
@@ -60,8 +63,8 @@ function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiI
     return;
   }
 
-
   if ($index) {
+    $type = $index->item_type;
     $indexes = array($index);
   }
   else {
@@ -77,7 +80,7 @@ function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiI
   }
   if ($index_immediately) {
     foreach ($indexes as $index) {
-      $indexed = search_api_index_specific_items_delayed($index, $item_ids);
+      search_api_index_specific_items_delayed($index, $item_ids);
     }
   }
   else {

File diff suppressed because it is too large
+ 595 - 163
sites/all/modules/contrib/search/search_api/search_api.test


+ 5 - 7
sites/all/modules/contrib/search/search_api/tests/search_api_test.info

@@ -1,18 +1,16 @@
-
-name = Search API test
+name = Search API Test
 description = "Some dummy implementations for testing the Search API."
 core = 7.x
 package = Search
 
-dependencies[] = search_api
+dependencies[] = search_api:search_api
 
 files[] = search_api_test.module
 
 hidden = TRUE
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by Drupal.org packaging script on 2019-03-11
+version = "7.x-1.26"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
-
+datestamp = "1552334832"

+ 14 - 2
sites/all/modules/contrib/search/search_api/tests/search_api_test.install

@@ -22,7 +22,7 @@ function search_api_test_schema() {
         'description' => 'The title of the item.',
         'type' => 'varchar',
         'length' => 50,
-        'not null' => TRUE,
+        'not null' => FALSE,
       ),
       'body' => array(
         'description' => 'A text belonging to the item.',
@@ -33,7 +33,19 @@ function search_api_test_schema() {
         'description' => 'A string identifying the type of item.',
         'type' => 'varchar',
         'length' => 50,
-        'not null' => TRUE,
+        'not null' => FALSE,
+      ),
+      'keywords' => array(
+        'description' => 'A comma separated list of keywords.',
+        'type' => 'varchar',
+        'length' => 200,
+        'not null' => FALSE,
+      ),
+      'prices' => array(
+        'description' => 'A comma separated list of prices.',
+        'type' => 'varchar',
+        'length' => 200,
+        'not null' => FALSE,
       ),
     ),
     'primary key' => array('id'),

+ 132 - 69
sites/all/modules/contrib/search/search_api/tests/search_api_test.module

@@ -1,5 +1,10 @@
 <?php
 
+/**
+ * @file
+ * Test functions and classes for testing the Search API.
+ */
+
 /**
  * Implements hook_menu().
  */
@@ -11,15 +16,21 @@ function search_api_test_menu() {
       'page arguments' => array('search_api_test_insert_item'),
       'access callback' => TRUE,
     ),
-    'search_api_test/%search_api_test' => array(
+    'search_api_test/view/%search_api_test' => array(
       'title' => 'View item',
       'page callback' => 'search_api_test_view',
-      'page arguments' => array(1),
+      'page arguments' => array(2),
       'access callback' => TRUE,
     ),
-    'search_api_test/query/%search_api_index' => array(
-      'title' => 'Search query',
-      'page callback' => 'search_api_test_query',
+    'search_api_test/touch/%search_api_test' => array(
+      'title' => 'Mark item as changed',
+      'page callback' => 'search_api_test_touch',
+      'page arguments' => array(2),
+      'access callback' => TRUE,
+    ),
+    'search_api_test/delete/%search_api_test' => array(
+      'title' => 'Delete items',
+      'page callback' => 'search_api_test_delete',
       'page arguments' => array(2),
       'access callback' => TRUE,
     ),
@@ -43,6 +54,12 @@ function search_api_test_insert_item(array $form, array &$form_state) {
     'type' => array(
       '#type' => 'textfield',
     ),
+    'keywords' => array(
+      '#type' => 'textfield',
+    ),
+    'prices' => array(
+      '#type' => 'textfield',
+    ),
     'submit' => array(
       '#type' => 'submit',
       '#value' => t('Save'),
@@ -55,7 +72,7 @@ function search_api_test_insert_item(array $form, array &$form_state) {
  */
 function search_api_test_insert_item_submit(array $form, array &$form_state) {
   form_state_values_clean($form_state);
-  db_insert('search_api_test')->fields($form_state['values'])->execute();
+  db_insert('search_api_test')->fields(array_filter($form_state['values']))->execute();
   module_invoke_all('entity_insert', search_api_test_load($form_state['values']['id']), 'search_api_test');
 }
 
@@ -71,59 +88,22 @@ function search_api_test_load($id) {
  * Menu callback for displaying search_api_test entities.
  */
 function search_api_test_view($entity) {
-  return array('text' => nl2br(check_plain(print_r($entity, TRUE))));
+  return nl2br(check_plain(print_r($entity, TRUE)));
 }
 
 /**
- * Menu callback for executing a search.
+ * Menu callback for marking a "search_api_test" entity as changed.
  */
-function search_api_test_query(SearchApiIndex $index, $keys = 'foo bar', $offset = 0, $limit = 10, $fields = NULL, $sort = NULL, $filters = NULL) {
-  // Slight "hack" for testing complex queries.
-  if ($keys == '|COMPLEX|') {
-    $keys = array(
-      '#conjunction' => 'AND',
-      'test',
-      array(
-        '#conjunction' => 'OR',
-        'baz',
-        'foobar',
-      ),
-      array(
-        '#conjunction' => 'AND',
-        '#negation' => TRUE,
-        'bar',
-      ),
-    );
-  }
-  $query = $index->query()
-    ->keys($keys)
-    ->range($offset, $limit);
-  if ($fields) {
-    $query->fields(explode(',', $fields));
-  }
-  if ($sort) {
-    $sort = explode(',', $sort);
-    $query->sort($sort[0], $sort[1]);
-  }
-  else {
-    $query->sort('search_api_id', 'ASC');
-  }
-  if ($filters) {
-    $filters = explode(',', $filters);
-    foreach ($filters as $filter) {
-      $filter = explode('=', $filter);
-      $query->condition($filter[0], $filter[1]);
-    }
-  }
-  $result = $query->execute();
+function search_api_test_touch($entity) {
+  module_invoke_all('entity_update', $entity, 'search_api_test');
+}
 
-  $ret = '';
-  $ret .= 'result count = ' . (int) $result['result count'] . '<br/>';
-  $ret .= 'results = (' . (empty($result['results']) ? '' : implode(', ', array_keys($result['results']))) . ')<br/>';
-  $ret .= 'warnings = (' . (empty($result['warnings']) ? '' : '"' . implode('", "', $result['warnings']) . '"') . ')<br/>';
-  $ret .= 'ignored = (' . (empty($result['ignored']) ? '' : implode(', ', $result['ignored'])) . ')<br/>';
-  $ret .= nl2br(check_plain(print_r($result['performance'], TRUE)));
-  return $ret;
+/**
+ * Menu callback for marking a "search_api_test" entity as changed.
+ */
+function search_api_test_delete($entity) {
+  db_delete('search_api_test')->condition('id', $entity->id)->execute();
+  module_invoke_all('entity_delete', $entity, 'search_api_test');
 }
 
 /**
@@ -177,6 +157,18 @@ function search_api_test_entity_property_info() {
       'description' => "The item's parent.",
       'getter callback' => 'search_api_test_parent',
     ),
+    'keywords' => array(
+      'label' => 'Keywords',
+      'type' => 'list<string>',
+      'description' => 'An optional collection of keywords describing the item.',
+      'getter callback' => 'search_api_test_list_callback',
+    ),
+    'prices' => array(
+      'label' => 'Prices',
+      'type' => 'list<decimal>',
+      'description' => 'An optional list of prices.',
+      'getter callback' => 'search_api_test_list_callback',
+    ),
   );
 
   return $info;
@@ -198,6 +190,24 @@ function search_api_test_parent($entity) {
   return search_api_test_load($entity->id - 1);
 }
 
+/**
+ * List callback.
+ */
+function search_api_test_list_callback($data, array $options, $name) {
+  if (is_array($data)) {
+    $res = is_array($data[$name]) ? $data[$name] : explode(',', $data[$name]);
+  }
+  else {
+    $res = is_array($data->$name) ? $data->$name : explode(',', $data->$name);
+  }
+  if ($name == 'prices') {
+    foreach ($res as &$x) {
+      $x = (float) $x;
+    }
+  }
+  return array_filter($res);
+}
+
 /**
  * Implements hook_search_api_service_info().
  */
@@ -215,6 +225,11 @@ function search_api_test_search_api_service_info() {
  */
 class SearchApiTestService extends SearchApiAbstractService {
 
+  /**
+   * Overrides SearchApiAbstractService::configurationForm().
+   *
+   * Returns a single text field for testing purposes.
+   */
   public function configurationForm(array $form, array &$form_state) {
     $form = array(
       'test' => array(
@@ -230,38 +245,72 @@ class SearchApiTestService extends SearchApiAbstractService {
     return $form;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function addIndex(SearchApiIndex $index) {
+    $this->checkErrorState();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fieldsUpdated(SearchApiIndex $index) {
+    $this->checkErrorState();
+    return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function removeIndex($index) {
+    $this->checkErrorState();
+    parent::removeIndex($index);
+  }
+
+  /**
+   * Implements SearchApiServiceInterface::indexItems().
+   *
+   * Indexes items by storing their IDs in the server's options.
+   *
+   * If the "search_api_test_indexing_break" variable is set, the item with
+   * that ID will not be indexed.
+   */
   public function indexItems(SearchApiIndex $index, array $items) {
-    // Refuse to index items with IDs that are multiples of 8 unless the
-    // "search_api_test_index_all" variable is set.
-    if (variable_get('search_api_test_index_all', FALSE)) {
-      return $this->index($index, array_keys($items));
-    }
-    $ret = array();
+    $this->checkErrorState();
+    // Refuse to index the item with the same ID as the
+    // "search_api_test_indexing_break" variable, if it is set.
+    $exclude = variable_get('search_api_test_indexing_break', 8);
     foreach ($items as $id => $item) {
-      if ($id % 8) {
-        $ret[] = $id;
+      if ($id == $exclude) {
+        unset($items[$id]);
       }
     }
-    return $this->index($index, $ret);
-  }
+    $ids = array_keys($items);
 
-  protected function index(SearchApiIndex $index, array $ids) {
     $this->options += array('indexes' => array());
     $this->options['indexes'] += array($index->machine_name => array());
     $this->options['indexes'][$index->machine_name] += drupal_map_assoc($ids);
-    sort($this->options['indexes'][$index->machine_name]);
+    asort($this->options['indexes'][$index->machine_name]);
     $this->server->save();
+
     return $ids;
   }
 
   /**
-   * Override so deleteItems() isn't called which would otherwise lead to the
+   * Overrides SearchApiAbstractService::preDelete().
+   *
+   * Overridden so deleteItems() isn't called which would otherwise lead to the
    * server being updated and, eventually, to a notice because there is no
    * server to be updated anymore.
    */
   public function preDelete() {}
 
+  /**
+   * {@inheritdoc}
+   */
   public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
+    $this->checkErrorState();
     if ($ids == 'all') {
       if ($index) {
         $this->options['indexes'][$index->machine_name] = array();
@@ -278,6 +327,12 @@ class SearchApiTestService extends SearchApiAbstractService {
     $this->server->save();
   }
 
+  /**
+   * Implements SearchApiServiceInterface::indexItems().
+   *
+   * Will ignore all query settings except the range, as only the item IDs are
+   * indexed.
+   */
   public function search(SearchApiQueryInterface $query) {
     $options = $query->getOptions();
     $ret = array();
@@ -309,8 +364,16 @@ class SearchApiTestService extends SearchApiAbstractService {
     return $ret;
   }
 
-  public function fieldsUpdated(SearchApiIndex $index) {
-    return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+  /**
+   * Throws an exception if the "search_api_test_error_state" variable is set.
+   *
+   * @throws SearchApiException
+   *   If the "search_api_test_error_state" variable is set.
+   */
+  protected function checkErrorState() {
+    if (variable_get('search_api_test_error_state', FALSE)) {
+      throw new SearchApiException();
+    }
   }
 
 }

+ 16 - 0
sites/all/modules/contrib/search/search_api/tests/search_api_test_2.info

@@ -0,0 +1,16 @@
+name = Search API Test Service 2
+description = "A module providing a second test search service."
+core = 7.x
+package = Search
+
+dependencies[] = search_api:search_api
+
+files[] = search_api_test_service_2.module
+
+hidden = TRUE
+
+; Information added by Drupal.org packaging script on 2019-03-11
+version = "7.x-1.26"
+core = "7.x"
+project = "search_api"
+datestamp = "1552334832"

+ 136 - 0
sites/all/modules/contrib/search/search_api/tests/search_api_test_2.module

@@ -0,0 +1,136 @@
+<?php
+
+/**
+ * @file
+ * Provides a second test service and server for testing Search API.
+ */
+
+/**
+ * Implements hook_search_api_service_info().
+ */
+function search_api_test_2_search_api_service_info() {
+  $name = 'search_api_test_service_2';
+  $services[$name] = array(
+    'name' => $name,
+    'description' => 'search_api_test_service_2 description',
+    'class' => 'SearchApiDummyService',
+  );
+  return $services;
+}
+
+/**
+ * Implements hook_default_search_api_server().
+ */
+function search_api_test_2_default_search_api_server() {
+  $id = 'test_server_2';
+  $items[$id] = entity_create('search_api_server', array(
+    'name' => 'Search API test server 2',
+    'machine_name' => $id,
+    'enabled' => 1,
+    'description' => 'A server used for testing.',
+    'class' => 'search_api_test_service_2',
+  ));
+  return $items;
+}
+
+/**
+ * Dummy service for testing.
+ */
+class SearchApiDummyService implements SearchApiServiceInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(\SearchApiServer $server) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm(array $form, array &$form_state) {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsFeature($feature) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function viewSettings() {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postCreate() {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postUpdate() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preDelete() {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addIndex(SearchApiIndex $index) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fieldsUpdated(SearchApiIndex $index) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function removeIndex($index) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function indexItems(SearchApiIndex $index, array $items) {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query(SearchApiIndex $index, $options = array()) {
+    throw new SearchApiException("The dummy service doesn't support queries");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function search(SearchApiQueryInterface $query) {
+    return array();
+  }
+}

Some files were not shown because too many files changed in this diff