Bladeren bron

first import 7.x-1.4

bachy 12 jaren geleden
commit
9cc5ba4cfa
68 gewijzigde bestanden met toevoegingen van 17899 en 0 verwijderingen
  1. 338 0
      CHANGELOG.txt
  2. 339 0
      LICENSE.txt
  3. 404 0
      README.txt
  4. 125 0
      contrib/search_api_facetapi/README.txt
  5. 209 0
      contrib/search_api_facetapi/example_service.php
  6. 242 0
      contrib/search_api_facetapi/plugins/facetapi/adapter.inc
  7. 196 0
      contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
  8. 149 0
      contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
  9. 31 0
      contrib/search_api_facetapi/search_api_facetapi.api.php
  10. 17 0
      contrib/search_api_facetapi/search_api_facetapi.info
  11. 13 0
      contrib/search_api_facetapi/search_api_facetapi.install
  12. 381 0
      contrib/search_api_facetapi/search_api_facetapi.module
  13. 71 0
      contrib/search_api_views/README.txt
  14. 262 0
      contrib/search_api_views/includes/display_facet_block.inc
  15. 122 0
      contrib/search_api_views/includes/handler_argument.inc
  16. 84 0
      contrib/search_api_views/includes/handler_argument_fulltext.inc
  17. 77 0
      contrib/search_api_views/includes/handler_argument_more_like_this.inc
  18. 17 0
      contrib/search_api_views/includes/handler_argument_text.inc
  19. 105 0
      contrib/search_api_views/includes/handler_filter.inc
  20. 30 0
      contrib/search_api_views/includes/handler_filter_boolean.inc
  21. 86 0
      contrib/search_api_views/includes/handler_filter_date.inc
  22. 131 0
      contrib/search_api_views/includes/handler_filter_fulltext.inc
  23. 55 0
      contrib/search_api_views/includes/handler_filter_language.inc
  24. 205 0
      contrib/search_api_views/includes/handler_filter_options.inc
  25. 15 0
      contrib/search_api_views/includes/handler_filter_text.inc
  26. 30 0
      contrib/search_api_views/includes/handler_sort.inc
  27. 564 0
      contrib/search_api_views/includes/query.inc
  28. 30 0
      contrib/search_api_views/search_api_views.info
  29. 97 0
      contrib/search_api_views/search_api_views.install
  30. 52 0
      contrib/search_api_views/search_api_views.module
  31. 196 0
      contrib/search_api_views/search_api_views.views.inc
  32. BIN
      disabled.png
  33. BIN
      enabled.png
  34. 220 0
      includes/callback.inc
  35. 313 0
      includes/callback_add_aggregation.inc
  36. 243 0
      includes/callback_add_hierarchy.inc
  37. 29 0
      includes/callback_add_url.inc
  38. 98 0
      includes/callback_add_viewed_entity.inc
  39. 72 0
      includes/callback_bundle_filter.inc
  40. 155 0
      includes/callback_language_control.inc
  41. 109 0
      includes/callback_node_access.inc
  42. 45 0
      includes/callback_node_status.inc
  43. 698 0
      includes/datasource.inc
  44. 206 0
      includes/datasource_entity.inc
  45. 268 0
      includes/datasource_external.inc
  46. 29 0
      includes/exception.inc
  47. 936 0
      includes/index_entity.inc
  48. 418 0
      includes/processor.inc
  49. 137 0
      includes/processor_html_filter.inc
  50. 12 0
      includes/processor_ignore_case.inc
  51. 94 0
      includes/processor_stopwords.inc
  52. 95 0
      includes/processor_tokenizer.inc
  53. 1066 0
      includes/query.inc
  54. 228 0
      includes/server_entity.inc
  55. 469 0
      includes/service.inc
  56. 44 0
      search_api.admin.css
  57. 1970 0
      search_api.admin.inc
  58. 61 0
      search_api.admin.js
  59. 535 0
      search_api.api.php
  60. 311 0
      search_api.drush.inc
  61. 38 0
      search_api.info
  62. 808 0
      search_api.install
  63. 2352 0
      search_api.module
  64. 90 0
      search_api.rules.inc
  65. 700 0
      search_api.test
  66. 18 0
      tests/search_api_test.info
  67. 43 0
      tests/search_api_test.install
  68. 316 0
      tests/search_api_test.module

+ 338 - 0
CHANGELOG.txt

@@ -0,0 +1,338 @@
+Search API 1.x, dev (xx/xx/xxxx):
+---------------------------------
+
+Search API 1.4 (01/09/2013):
+----------------------------
+- #1827272 by drunken monkey: Fixed regression introduced by #1777710.
+- #1807622 by drunken monkey: Fixed definition of the default node index.
+- #1818948 by das-peter: Fixed endless loop in
+  search_api_index_specific_items_delayed().
+- #1406808 by Haza, drunken monkey: Added support for date popup in exposed
+  filters.
+- #1823916 by aschiwi: Fixed batch_sise typos.
+
+Search API 1.3 (10/10/2012):
+----------------------------
+- Patch by mr.baileys: Fixed "enable" function doesn't use security tokens.
+- #1318904 by becw, das-peter, orakili, drunken monkey: Added improved handling
+  for NULL values in Views.
+- #1306008 by Damien Tournoud, drunken monkey: Fixed handling of negative
+  facets.
+- #1182912 by drunken monkey, sepgil: Added Rules action for indexing entities.
+- #1507882 by jsacksick: Added "Exclude unpublished nodes" data alteration.
+- #1225620 by drunken monkey: Added Batch API integration for the "Index now"
+  functionality.
+- #1777710 by dasjo: Remove dependency on $_GET['q'] for determining base paths.
+- #1715238 by jsacksick: Fixed fulltext argument handler field list is broken.
+- #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):
+----------------------------
+- #1368548 by das-peter: Do not index views results by entity id.
+- #1422750 by drunken monkey, sepgil: Fixed illegal modification of entity
+  objects.
+- #1363114 by nbucknor: Fixed inclusion of upper bound in range facets.
+- #1580780 by drunken monkey: Fixed default regexps of the Tokenizer.
+- #1468678 by znerol: Fixed erroneous use of Drupal multibyte wrapper functions.
+- #1600986 by DamienMcKenna: Fixed dependencies of exported search servers.
+- #1569874 by cpliakas: Fixed removal/adding of facets when indexed fields are
+  changed.
+- #1528436 by jsacksick, drunken monkey: Fixed handling of exportable entities.
+
+Search API 1.1 (05/23/2012):
+----------------------------
+- Fixed escaping of error messages.
+- #1330506 by drunken monkey: Removed the old Facets module.
+- #1504318 by peximo: Fixed Views pager offset.
+- #1141488 by moonray, drunken monkey: Added option to use multiple values with
+  contextual filters.
+- #1535726 by bojanz, joelpittet: Fixed arguments for
+  $service->configurationFormValidate() for empty forms.
+- #1400882 by mh86: Fixed "Index hierarchy" for "All parents".
+
+Search API 1.0 (12/15/2011):
+----------------------------
+- #1350322 by drunken monkey: Fixed regressions introduced with cron queue
+  indexing.
+- #1352292 by das-peter, drunken monkey: Use Search API specific table groups in
+  Views integration.
+- #1351524 by das-peter: Made Views result row indexing more robust.
+- #1194362 by Damien Tournoud: Fixed click sort added to non-existent Views
+  fields.
+- #1347150 by drunken monkey: Fixed fields access of Views facets block display.
+- #1345972 by Krasnyj, drunken monkey: Added handling of large item amounts to
+  trackItemInsert().
+- #1324182 by dereine, drunken monkey: Fixed indexing author when node access is
+  enabled.
+- #1215526 by cpliakas, drunken monkey: Added support for the "Bundle" facet
+  dependency plugin.
+- #1337292 by drunken monkey: Fixed facet dependency system.
+
+Search API 1.0, RC 1 (11/10/2011):
+----------------------------------
+API changes:
+- #1260834 by drunken monkey: Added a way to define custom data types.
+- #1308638 by drunken monkey: Reduce size of stored index settings.
+- #1291346 by drunken monkey: Expose SearchApiQuery::preExecute() and
+  postExecute().
+- #955088 by dereine, drunken monkey: Provide (additional) access functionality.
+- #1064884 by drunken monkey: Added support for indexing non-entities.
+
+Others:
+- #1304026 by drunken monkey: Utilize Facet API's 'include default facets' key
+  in searcher definitions.
+- #1231512 by drunken monkey: Use real Relationships instead of level magic in
+  Views integration.
+- #1260768 by drunken monkey: Move "Search pages" into its own project.
+- #1260812 by drunken monkey: Move "Database search" into its own project.
+- #1287602 by drunken monkey: Fixed „Index items immediately“ to delay indexing
+  on insert, too.
+- #1319500 by drunken monkey: Remove items after unsuccessful loads.
+- #1297958 by drunken monkey: Fixed wrong facet operator used for range facets.
+- #1305736 by drunken monkey: Fixed notice for unset Views group operator.
+- #1263214 by drunken monkey: Fixed indexing with „Index items immediately“
+  indexes old entity state.
+- #1228726 by drunken monkey, mh86: Increased size of 'options' fields in
+  database.
+- #1295144 by katbailey: Added alter hook for Facet API search keys.
+- #1294828 by drunken monkey: Fixed accidental presence of good OOP coding
+  standards in Views integration.
+- #1291376 by drunken monkey: Expose
+  SearchApiFacetapiAdapter::getCurrentSearch().
+- #1198764 by morningtime, drunken monkey: Fixed handling of Views filter
+  groups.
+- #1286500 by drunken monkey: Fixed „Search IDs” setting for facets not saved.
+- #1278780 by dereine, drunken monkey: Fixed status field requirement for node
+  access.
+- #1182614 by katbailey, cpliakas, drunken monkey, thegreat, das-peter: Added
+  Facet API integration.
+- #1278592 by das-peter: Fixed default view mode for non-entites or entities
+  without view modes.
+- #1251674 by Nick_vh: Fixed handling of empty fulltext keys in Views.
+- #1145306 by Nick_vh, drunken monkey: Fixed handling of multiple filters in the
+  database service class.
+- #1264164 by das-peter: Fixed the definition of the external data source
+  controller's trackItemChange() method.
+- #1262362 by drunken monkey: Fixed error handling for orphaned facets.
+- #1233426 by atlea: Fixed dirty and queued items don't get removed from the
+  tracking table when deleted.
+- #1258240 by drunken monkey: Fixed more overlooked entity type assumptions.
+- #1213698 by drunken monkey: Added a data alteration for indexing complete
+  hierarchies.
+- #1252208 by tedfordgif: Fixed superfluous query chars in active facet links.
+- #1224564 by drunken monkey: Added user language as a filter in Views.
+- #1242614 by jsacksick: Fixed division by zero in drush_search_api_status().
+- #1250168 by drunken monkey: Fixed deleted items aren't removed from servers.
+- #1236642 by jsacksick, drunken monkey: Fixed stale static cache of
+  search_api_get_item_type_info().
+- #1237348 by drunken monkey: Added a "Language control" data alteration.
+- #1214846 by drunken monkey, Kender: Fixed overlong table names when DB prefix
+  is used.
+- #1232478 by Damien Tournoud, drunken monkey: Fixed update of field type
+  settings for DB backend and index.
+- #1229772 by drunken monkey: Fixed order in which items are indexed.
+- #946624 by drunken monkey: Adapted to use a cron queue for indexing.
+- #1217702 by Amitaibu, drunken monkey: Added documentation on facet URLs.
+- #1214862 by drunken monkey: Added bundle-specific fields for related entities.
+- #1204964 by klausi: Fixed default index status is not overridden on saving.
+- #1191442 by drunken monkey: Fixed facets block view showing only tid.
+- #1161532 by drunken monkey: Fixed discerning between delete and revert in
+  hook_*_delete().
+
+Search API 1.0, Beta 10 (06/20/2011):
+-------------------------------------
+API changes:
+- #1068342 by drunken monkey: Added a 'fields to run on' option for processors.
+
+Others:
+- #1190086 by drunken monkey: Fixed crash in hook_entity_insert().
+- #1190324 by drunken monkey: Adapted to API change in Entity API.
+- #1168684 by drunken monkey: Added improved tokenizer defaults for English.
+- #1163096 by drunken monkey: Fixed cached types for DB servers aren't correctly
+  updated.
+- #1133864 by agentrickard, awolfey, greg.1.anderson, drunken monkey: Added
+  Drush integration.
+
+Search API 1.0, Beta 9 (06/06/2011):
+------------------------------------
+API changes:
+- #1089758 by becw, drunken monkey: Updated Views field handlers to utilize new
+  features.
+- #1150260 by drunken monkey: Added a way to let processors and data alterations
+  decide on which indexes they can run.
+- #1138992 by becw, drunken monkey: Added read-only indexes.
+
+Others:
+- #1179990 by j0rd: Fixed facet blocks don't correctly respect the "unlimited"
+  setting.
+- #1138650 by klausi, Amitaibu, drunken monkey: Fixed PHP strict warnings.
+- #1111852 by miiimooo, drunken monkey: Added a 'More like this' feature.
+- #1171360 by jbguerraz, drunken monkey: Added possibility to restrict the
+  options to display in an exposed options filter.
+- #1161676 by awolfey, drunken monkey: Added Stopwords processor.
+- #1166514 by drunken monkey: Fixed parseKeys() to handle incomplete quoting.
+- #1161966 by JoeMcGuire: Added Search API Spellcheck support for Pages.
+- #1118416 by drunken monkey: Added option to index entities instantly after
+  they are saved.
+- #1153298 by JoeMcGuire, drunken monkey: Added option getter and setter to
+  Views query handler.
+- #1147466 by awolfey: Added excerpt Views field.
+- #1152432 by morningtime: Fixed strict warnings in render() functions.
+- #1144400 by drunken monkey: Fixed use of entity_exportable_schema_fields() in
+  hook_schema().
+- #1141206 by drunken monkey: Fixed "quantity" variable for Search page pager.
+- #1117074 by drunken monkey: Fixed handling of overlong tokens by DB backend.
+- #1124548 by drunken monkey: Fixed syntax error in search_api.admin.inc.
+- #1134296 by klausi: Fixed check for NULL in SearchApiDbService->convert().
+- #1123604 by drunken monkey, fago: Added generalized "aggregation" data
+  alteration.
+- #1129226 by drunken monkey: Fixed incorrect handling of facets deactivated for
+  some search IDs.
+- #1086492 by drunken monkey: Fixed inadequate warnings when using the "Facets
+  block" display with wrong base table.
+- #1109308 by drunken monkey : Added option to choose between display of field
+  or facet name in "Current search" block.
+- #1120850 by drunken monkey, fangel: Fixed type of related entities in nested
+  lists.
+
+Search API 1.0, Beta 8 (04/02/2011):
+------------------------------------
+API changes:
+- #1012878 by drunken monkey: Added a way to index an entity directly.
+- #1109130 by drunken monkey: Added better structure for Views field rendering.
+
+Others:
+- #1018384 by drunken monkey: Fixed Views field names to not contain colons.
+- #1105704 by drunken monkey: Fixed exposed sorts always sort on 'top' sort.
+- #1104056 by drunken monkey: Added "Current search" support for non-facet
+  filters.
+- #1103814 by drunken monkey: Fixed missing argument for extractFields().
+- #1081084 by drunken monkey: Fixed notices in add_fulltext_field alteration.
+- #1091074 by drunken monkey, ygerasimov: Added machine names to "related
+  entities" list.
+- #1066278 by ygerasimov, drunken monkey: Removed
+  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):
+------------------------------------
+- #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):
+------------------------------------
+API changes:
+- #1075810 by drunken monkey: Added API function for marking entities as dirty.
+- #1043456 by drunken monkey: Added form validation/submission for plugins.
+- #1048032 by drunken monkey: Added a hook for altering the indexed items.
+
+Others:
+- #1068334 by drunken monkey: Added a data alteration for indexing the viewed
+  entity.
+- #1080376 by drunken monkey: Fixed "Current search" block field names.
+- #1076170 by drunken monkey: Added a Views display plugin for facet blocks.
+- #1064518 by drunken monkey: Added support for entity-based Views handlers.
+- #1080004 by drunken monkey: Fixed confusing "Current search" block layout.
+- #1071894 by drunken monkey: Fixed incorrect handling of boolean facets.
+- #1078590 by fago: Added check to skip default index creation when installed
+  via installation profile.
+- #1018366 by drunken monkey: Added option to hide active facet links.
+- #1058410 by drunken monkey: Added overhauled display of search results.
+- #1013632 by drunken monkey: Added facet support for the database backend.
+- #1069184: "Current search" block passes query parameters wrongly.
+- #1038016 by fago: Error upon indexing inaccessible list properties.
+- #1005532: Adaption to Entity API change (new optionsList() parameter).
+- #1057224 by TimLeytens: Problem with entity_uri('file').
+- #1051286: Show type/boost options only for indexed fields.
+- #1049978: Provide a "More" link for facets.
+- #1039250: Improve facet block titles.
+- #1043492: Problems with default (exported) entities.
+- #1037916 by fago: Updates must not call API functions.
+- #1032708 by larskleiner: Notice: Undefined variable: blocks.
+- #1032404 by larskleiner: Notice: Undefined index: fields.
+- #1032032 by pillarsdotnet: search_api_enable() aborts with a database error
+  on install.
+- #1026496: status doesn't get set properly when creating entities.
+- #1027992 by TimLeytens: Filter indexed items based on bundle.
+- #1024194 by TimLeytens: Provide a search block for each page.
+- #1028042: Change {search_api_item}.index_id back to an INT.
+- #1021664: Paged views results empty when adding facet.
+- #872912: Write tests.
+- #1013018: Make the "Fulltext field" data alteration more useful.
+- #1024514: Error when preprocessing muli-valued fulltext fields.
+- #1020372: CSS classes for facets.
+
+Search API 1.0, Beta 5 (01/05/2011):
+------------------------------------
+API changes:
+- #917998: Enhance data alterations by making them objects.
+- #991632: Incorporate newly available entity hooks.
+- #963062: Make facets exportable.
+
+Others:
+- #1018544: includes/entity.inc mentioned in a few places.
+- #1011458: Move entity and processor classes into individual files.
+- #1012478: HTML in node bodies is escaped in Views.
+- #1014548: Add newly required database fields for entities.
+- #915174: Remove unnecessary files[] declarations from .info files.
+- #988780: Merge of entity modules.
+- #997852: Service config oddities.
+- #994948: "Add index" results in blank page.
+- #993470: Unnecessary warning when no valid keys or filters are given.
+- #986412: Notice: Undefined index: value in theme_search_api_page_result().
+- #987928: EntityDBExtendable::__sleep() is gone.
+- #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):
+------------------------------------
+API changes:
+- #976876: Move Solr module into its own project.
+- #962582: Cross-entity searches (API addition).
+- #939482 by fago: Fix exportables.
+- #939092: Several API changes regarding service class methods.
+- #939414: Enhanced service class descriptions. [soft API change]
+- #939464: Documented Entity API's module and status properties.
+- #939092: Changed private members to protected in all classes.
+- #936360: Make servers and indexes exportable.
+
+Others:
+- #966512: "Time ago" option for Views date fields (+bug fix for missing value).
+- #965318: Lots of notices if entities are missing in Views.
+- #961210: Hide error messages.
+- #963756: Array to string conversion error.
+- #961276: Some random bugs.
+- #961122: Exportability UI fixes.
+- #913858: Fix adding properties that are lists of entities.
+- #961210: Don't hide error messages.
+- #961122: Display configuration status when viewing entities.
+- #889286: EntityAPIController::load() produces WSoD sometimes.
+- #958378 by marvil07: "Clear index" is broken
+- #955892: Typo in search_api_solr.install.
+- #951830: "List of language IDs" context suspicious.
+- #939414: Rename "data-alter callbacks" to "data alterations".
+- #939460: Views integration troubles.
+- #945754: Fix server and index machine name inputs.
+- #943578: Duplicate fields on service creation.
+- #709892: Invoke hook_entity_delete() on entity deletions.
+- #939414: Set fields provided by data-alter callbacks to "indexed" by default.
+- #939414: Provide a default node index upon installation.
+- #939822 by fago: Support fields.
+- #939442: Bad data type defaults [string for fields with options].
+- #939482: Override export() to work with "magic" __get fields.
+- #939442: Bad data type defaults.
+- #939414: Improved descriptions for processors.
+- #939414: Removed the "Call hook" data alter callback.
+- #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):
+------------------------------------
+- API mostly stable.
+- Five contrib modules exist:
+  - search_api_db
+  - search_api_solr
+  - search_api_page
+  - search_api_views
+  - search_api_facets

+ 339 - 0
LICENSE.txt

@@ -0,0 +1,339 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.

+ 404 - 0
README.txt

@@ -0,0 +1,404 @@
+Search API
+----------
+
+This module provides a framework for easily creating searches on any entity
+known to Drupal, using any kind of search engine. For site administrators, it is
+a great alternative to other search solutions, since it already incorporates
+facetting support and the ability to use the Views module for displaying search
+results, filters, etc. Also, with the Apache Solr integration [1], a
+high-performance search engine is available for use with the Search API.
+
+If you need help with the module, please post to the project's issue queue [2].
+
+[1] http://drupal.org/project/search_api_solr
+[2] http://drupal.org/project/issues/search_api
+
+
+Content:
+ - Glossary
+ - Information for users
+ - Information for developers
+ - Included components
+
+
+Glossary
+--------
+
+Terms as used in this module.
+
+- Service class:
+  A type of search engine, e.g. using the database, Apache Solr,
+  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.
+- 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
+  still need to be indexed (or re-indexed, if they were updated). Needs to lie
+  on a server in order to be really used (although configuration is independent
+  of a server).
+- Item type:
+  A type of data which can be indexed (i.e., for which indexes can be created).
+  Most entity types (like Content, User, Taxonomy term, etc.) are available, but
+  possibly also other types provided by contrib modules.
+- Entity:
+  One object of data, usually stored in the database. Might for example
+  be a node, a user or a file.
+- Field:
+  A defined property of an entity, like a node's title or a user's mail address.
+  All fields have defined datatypes. However, for indexing purposes the user
+  might choose to index a property under a different data type than defined.
+- Data type:
+  Determines how a field is indexed. While "Fulltext" fields can be completely
+  searched for keywords, other fields can only be used for filtering. They will
+  also be converted to fit their respective value ranges.
+  How types other than "Fulltext" are handled depends on the service class used.
+  Its documentation should state how the type-selection affect the indexed
+  content. However, service classes will always be able to handle all data
+  types, it is just possible that the type doesn't affect the indexing at all
+  (apart from "Fulltext vs. the rest").
+- Boost:
+  Number determining how important a certain field is, when searching for
+  fulltext keywords. The higher the value is, the more important is the field.
+  E.g., when the node title has a boost of 5.0 and the node body a boost of 1.0,
+  keywords found in the title will increase the score as much as five keywords
+  found in the body. Of course, this has only an effect when the score is used
+  (for sorting or other purposes). It has no effect on other parts of the search
+  result.
+- Data alteration:
+  A component that is used when indexing data. It can add additional fields to
+  the indexed entity or prevent certain entities from being indexed. Fields
+  added by callbacks have to be enabled on the "Fields" page to be of any use,
+  but this is done by default.
+- Processor:
+  An object that is used for preprocessing indexed data as well as search
+  queries, and for postprocessing search results. Usually only work on fulltext
+  fields to control how content is indexed and searched. E.g., processors can be
+  used to make searches case-insensitive, to filter markup out of indexed
+  content, etc.
+
+
+Information for users
+---------------------
+
+IMPORTANT: Access checks
+  In general, the Search API doesn't contain any access checks for search
+  results. It is your responsibility to ensure that only accessible search
+  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
+  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
+  checks will automatically be executed at search time, showing only those
+  results that a user can view. Some search types (e.g., search views) also
+  provide the option to disable these access checks for individual searches.
+  Please note, however, that these access checks use the indexed data, while
+  usually the current data is displayed to users. Therefore, users might still
+  see inappropriate content as long as items aren't indexed in their latest
+  state. If you can't allow this for your site, please use the index's "Index
+  immediately" feature (explained below) or possibly custom solutions for
+  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).
+
+- Creating a server
+  (Configuration > Search API > Add server)
+
+The most basic thing you have to create is a search server for indexing content.
+Go to Configuration > Search API in the administration pages and select
+"Add server". Name and description are usually only shown to administrators and
+can be used to differentiate between several servers, or to explain a server's
+use to other administrators (for larger sites). Disabling a server makes it
+unusable for indexing and searching and can e.g. be used if the underlying
+search engine is temporarily unavailable.
+The "service class" is the most important option here, since it lets you select
+which backend the search server will use. This cannot be changed after the
+server is created.
+Depending on the selected service class, further, service-specific settings will
+be available. For details on those settings, consult the respective service's
+documentation.
+
+- Creating an index
+  (Configuration > Search API > Add index)
+
+For adding a search index, choose "Add index" on the Search API administration
+page. Name, description and "enabled" status serve the exact same purpose as
+for servers.
+The most important option in this form is the indexed entity type. Every index
+contains data on only a single type of entities, e.g. nodes, users or taxonomy
+terms. This is therefore the only option that cannot be changed afterwards.
+The server on which the index lies determines where the data will actually be
+indexed. It doesn't affect any other settings of the index and can later be
+changed with the only drawback being that the index' content will have to be
+indexed again. You can also select a server that is at the moment disabled, or
+choose to let the index lie on no server at all, for the time being. Note,
+however, that you can only create enabled indexes on an enabled server. Also,
+disabling a server will disable all indexes that lie on it.
+The "Index items immediately" option specifies that you want items to be
+directly re-indexed after being changed, instead of waiting for the next cron
+run. Use this if it is important that users see no stale data in searches, and
+only when your setup enables relatively fast indexing.
+Lastly, the "Cron batch size" option allows you to set whether items will be
+indexed when cron runs (as long as the index is enabled), and how many items
+will be indexed in a single batch. The best value for this setting depends on
+how time-consuming indexing is for your setup, which in turn depends mostly on
+the server used and the enabled data alterations. You should set it to a number
+of items which can easily be indexed in 10 seconds' time. Items can also be
+indexed manually, or directly when they are changed, so even if this is set to
+0, the index can still be used.
+
+- Indexed fields
+  (Configuration > Search API > [Index name] > Fields)
+
+Here you can select which of the entities' fields will be indexed, and how.
+Fields added by (enabled) data alterations will be available here, too.
+Without selecting fields to index, the index will be useless and also won't be
+available for searches. Select the "Fulltext" data type for fields which you
+want search for keywords, and other data types when you want to use the field
+for filtering (e.g., as facets). The "Item language" field will always be
+indexed as it contains important information for processors and hooks.
+You can also add fields of related entities here, via the "Add related fields"
+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)
+
+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
+further explanations).
+Data alterations usually only add one or more fields to the entity and their
+order is mostly irrelevant.
+The order of processors, however, often is important. Read the processors'
+descriptions or consult their documentation for determining how to use them most
+effectively.
+
+- Index status
+  (Configuration > Search API > [Index name] > Status)
+
+On this page you can view how much of the entities are already indexed and also
+control indexing. With the "Index now" button (displayed only when there are
+still unindexed items) you can directly index a certain number of "dirty" items
+(i.e., items not yet indexed in their current state). Setting "-1" as the number
+will index all of those items, similar to the cron batch size setting.
+When you change settings that could affect indexing, and the index is not
+automatically marked for re-indexing, you can do this manually with the
+"Re-index content" button. All items in the index will be marked as dirty and be
+re-indexed when subsequently indexing items (either manually or via cron runs).
+Until all content is re-indexed, the old data will still show up in searches.
+This is different with the "Clear index" button. All items will be marked as
+dirty and additionally all data will be removed from the index. Therefore,
+searches won't show any results until items are re-indexed, after clearing an
+index. Use this only if completely wrong data has been indexed. It is also done
+automatically when the index scheme or server settings change too drastically to
+keep on using the old data.
+
+- Hidden settings
+
+search_api_index_worker_callback_runtime:
+  By changing this variable, you can determine the time (in seconds) the Search
+  API will spend indexing (for all indexes combined) in each cron run. The
+  default is 15 seconds.
+
+
+Information for developers
+--------------------------
+
+ | NOTE:
+ | For modules providing new entities: In order for your entities to become
+ | searchable with the Search API, your module will need to implement
+ | hook_entity_property_info() in addition to the normal hook_entity_info().
+ | hook_entity_property_info() is documented in the entity module.
+ | For making certain non-entities searchable, see "Item type" below.
+ | 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].
+ |
+ | [1] 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
+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.
+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
+with doc comments for a developer to find the right methods to override or
+implement.
+
+- Service class
+  Interface: SearchApiServiceInterface
+  Base class: SearchApiAbstractService
+  Hook: hook_search_api_service_info()
+
+The service classes are the heart of the API, since they allow data to be
+indexed on different search servers. Since these are quite some work to get
+right, you should probably make sure a service class for a specific search
+engine doesn't exist already before programming it yourself.
+When your module supplies a service class, please make sure to provide
+documentation (at least a README.txt) that clearly states the datatypes it
+supports (and in what manner), how a direct query (a query where the keys are
+a single string, instead of an array) is parsed and possible limitations of the
+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.
+
+- Query class
+  Interface: SearchApiQueryInterface
+  Base class: SearchApiQuery
+
+You can also override the query class' behaviour for your service class. You
+can, for example, change key parsing behaviour, add additional parse modes
+specific to your service, or override methods so the information is stored more
+suitable for your service.
+For the query class to become available (other than through manual creation),
+you need a custom service class where you override the query() method to return
+an instance of your query class.
+
+- Item type
+  Interface: SearchApiDataSourceControllerInterface
+  Base class: SearchApiAbstractDataSourceController
+  Hook: hook_search_api_item_type_info()
+
+If you want to index some data which is not defined as an entity, you can
+specify it as a new item type here. For defining a new item type, you have to
+create a data source controller for the type and track new, changed and deleted
+items of the type by calling the search_api_track_item_*() functions.
+An instance of the data source controller class will then be used by indexes
+when handling items of your newly-defined type.
+
+If you want to make external data that is indexed on some search server
+available to the Search API, there is a handy base class for your data source
+controller (SearchApiExternalDataSourceController in
+includes/datasource_external.inc) which you can extend. For a minimal use case,
+you will then only have to define the available fields that can be retrieved by
+the server.
+
+- Data type
+  Hook: hook_search_api_data_type_info()
+
+You can specify new data types for indexing fields. These new types can then be
+selected on indexes' „Fields“ tabs. You just have to implement the hook,
+returning some information on your data type, and specify in your module's
+documentation the format of your data type and how it should be used.
+
+For a custom data type to have an effect, in most cases the server's service
+class has to support that data type. A service class can advertize its support
+of a data type by declaring support for the "search_api_data_type_TYPE" feature
+in its supportsFeature() method. If this support isn't declared, a fallback data
+type is automatically used instead of the custom one.
+
+If a field is indexed with a custom data type, its entry in the index's options
+array will have the selected type in "real_type", while "type" contains the
+fallback type (which is always one of the default data types, as returned by
+search_api_default_field_types().
+
+- Data-alter callbacks
+  Interface: SearchApiAlterCallbackInterface
+  Base class: SearchApiAbstractAlterCallback
+  Hook: hook_search_api_alter_callback_info()
+
+Data alter callbacks can be used to change the field data of indexed items, or
+to prevent certain items from being indexed. They are only used when indexing,
+or when selecting the fields to index. For adding additional information to
+search results, you have to use a processor.
+Data-alter callbacks are called "data alterations" in the UI.
+
+- Processors
+  Interface: SearchApiProcessorInterface
+  Base class: SearchApiAbstractProcessor
+  Hook: hook_search_api_processor_info()
+
+Processors are used for altering the data when indexing or searching. The exact
+specifications are available in the interface's doc comments. Just note that the
+processor description should clearly state assumptions or restrictions on input
+types (e.g. only tokenized text), item language, etc. and explain concisely what
+effect it will have on searches.
+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
+    Provides a field with the URL for displaying the entity.
+  * Aggregated fields
+    Offers the ability to add additional fields to the entity, containing the
+    data from one or more other fields. Use this, e.g., to have a single field
+    containing all data that should be searchable, or to make the text from a
+    string field, like a taxonomy term, also fulltext-searchable.
+    The type of aggregation can be selected from a set of values: you can, e.g.,
+    collect the text data of all contained fields, or add them up, count their
+    values, etc.
+  * Bundle filter
+    Enables the admin to prevent entities from being indexed based on their
+    bundle (content type for nodes, vocabulary for taxonomy terms, etc.).
+  * Complete entity view
+    Adds a field containing the whole HTML content of the entity as it is viewed
+    on the site. The view mode used can be selected.
+    Note, however, that this might not work for entities of all types. All core
+    entities except files are supported, though.
+  * Index hierarchy
+    Allows to index a hierarchical field along with all its parents. Most
+    importantly, this can be used to index taxonomy term references along with
+    all parent terms. This way, when an item, e.g., has the term "New York", it
+    will also be matched when filtering for "USA" or "North America".
+
+- Processors
+
+  * Ignore case
+    Makes all fulltext searches (and, optionally, also filters on string values)
+    case-insensitive. Some servers might do this automatically, for others this
+    should probably always be activated.
+  * HTML filter
+    Strips HTML tags from fulltext fields and decodes HTML entities. If you are
+    indexing HTML content (like node bodies) and the search server doesn't
+    handle HTML on its own, this should be activated to avoid indexing HTML
+    tags, as well as to give e.g. terms appearing in a heading a higher boost.
+  * Tokenizer
+    This processor allows you to specify how indexed fulltext content is split
+    into seperate tokens – which characters are ignored and which treated as
+    white-space that seperates words.
+  * Stopwords
+    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.
+
+- 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
+    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

+ 125 - 0
contrib/search_api_facetapi/README.txt

@@ -0,0 +1,125 @@
+Search facets
+-------------
+
+This module allows you to create facetted searches for any search executed via
+the Search API, no matter if executed by a search page, a view or any other
+module. The only thing you'll need is a search service class that supports the
+"search_api_facets" feature. Currently, the "Database search" and "Solr search"
+modules supports this.
+
+This module is built on the Facet API [1], which is needed for this module to
+work.
+
+[1] http://drupal.org/project/facetapi
+
+
+Information for site builders
+-----------------------------
+
+For creating a facetted search, you first need a search. Create or find some
+page displaying Search API search results, either via a search page, a view or
+by any other means. Now go to the configuration page for the index on which
+this search is executed.
+If the index lies on a server supporting facets (and if this module is enabled),
+you'll notice a "Facets" tab. Click it and it will take you to the index' facet
+configuration page. You'll see a table containing all indexed fields and options
+for enabling and configuring facets for them.
+For a detailed explanation of the available options, please refer to the Facet
+API documentation.
+
+- Creating facets via the URL
+
+Facets can be added to a search (for which facets are activated) by passing
+appropriate GET parameters in the URL. Assuming you have an indexed field with
+the machine name "field_price", you can filter on it in the following ways:
+
+- Filter for a specific value. For finding only results that have a price of
+  exactly 100, pass the following $options to url() or l():
+
+  $options['query']['f'][] = 'field_price:100';
+
+  Or manually append the following GET parameter to a URL:
+
+  ?f[0]=field_price:100
+
+- Search for values in a specified range. The following example will only return
+  items that have a price greater than or equal to 100 and lower than 500.
+
+  Code: $options['query']['f'][] = 'field_price:[100 TO 500]';
+  URL:  ?f[0]=field_price%3A%5B100%20TO%20500%5D
+
+- Search for values above a value. The next example will find results which have
+  a price greater than or equal to 100. The asterisk (*) stands for "unlimited",
+  meaning that there is no upper limit. Filtering for values lower than a
+  certain value works equivalently.
+
+  Code: $options['query']['f'][] = 'field_price:[100 TO *]';
+  URL:  ?f[0]=field_price%3A%5B100%20TO%20%2A%5D
+
+- Search for missing values. This example will filter out all items which have
+  any value at all in the price field, and will therefore only list items on
+  which this field was omitted. (This naturally only makes sense for fields
+  that aren't required.)
+
+  Code: $options['query']['f'][] = 'field_price:!';
+  URL:  ?f[0]=field_price%3A%21
+
+- Search for present values. The following example will only return items which
+  have the price field set (regardless of the actual value). You can see that it
+  is actually just a range filter with unlimited lower and upper bound.
+
+  Code: $options['query']['f'][] = 'field_price:[* TO *]';
+  URL:  ?f[0]=field_price%3A%5B%2A%20TO%20%2A%5D
+
+Note: When filtering a field whose machine name contains a colon (e.g.,
+"author:roles"), you'll have to additionally URL-encode the field name in these
+filter values:
+  Code: $options['query']['f'][] = rawurlencode('author:roles') . ':100';
+  URL:  ?f[0]=author%253Aroles%3A100
+
+- Issues
+
+If you find any bugs or shortcomings while using this module, please file an
+issue in the project's issue queue [1], using the "Facets" component.
+
+[1] http://drupal.org/project/issues/search_api
+
+
+Information for developers
+--------------------------
+
+- Features
+
+If you are the developer of a SearchApiServiceInterface implementation and want
+to support facets with your service class, too, you'll have to support the
+"search_api_facets" feature. You can find details about the necessary additions
+to your class in the example_servive.php file. In short, you'll just, when
+executing a query, have to return facet terms and counts according to the
+query's "search_api_facets" option, if present.
+In order for the module to be able to tell that your server supports facets,
+you will also have to change your service's supportsFeature() method to
+something like the following:
+  public function supportsFeature($feature) {
+    return $feature == 'search_api_facets';
+  }
+
+There is also a second feature defined by this module, namely
+"search_api_facets_operator_or", for supporting "OR" facets. The requirements
+for this feature are also explained in the example_servive.php file.
+
+- Query option
+
+The facets created follow the "search_api_base_path" option on the search query.
+If set, this path will be used as the base path from which facet links will be
+created. This can be used to show facets on pages without searches – e.g., as a
+landing page.
+
+- Hidden variable
+
+The module uses one hidden variable, "search_api_facets_search_ids", to keep
+track of the search IDs of searches executed for a given index. It is only
+updated when a facet is displayed for the respective search, so isn't really a
+reliable measure for this.
+In any case, if you e.g. did some test searches and now don't want them to show
+up in the block configuration forever after, just clear the variable:
+  variable_del("search_api_facets_search_ids")

+ 209 - 0
contrib/search_api_facetapi/example_service.php

@@ -0,0 +1,209 @@
+<?php
+
+/**
+ * @file
+ * Example implementation for a service class which supports facets.
+ */
+
+/**
+ * Example class explaining how facets can be supported by a service class.
+ *
+ * This class defines the "search_api_facets" and
+ * "search_api_facets_operator_or" features. Read the method documentation and
+ * inline comments in search() to learn how they can be supported by a service
+ * class.
+ */
+abstract class SearchApiFacetapiExampleService extends SearchApiAbstractService {
+
+  /**
+   * 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.
+   *
+   * If the service class supports facets, it should return TRUE if called with
+   * the feature name "search_api_facets". If it also supports "OR" facets, it
+   * should also return TRUE if called with "search_api_facets_operator_or".
+   *
+   * @param string $feature
+   *   The name of the optional feature.
+   *
+   * @return boolean
+   *   TRUE if this service knows and supports the specified feature. FALSE
+   *   otherwise.
+   */
+  public function supportsFeature($feature) {
+    $supported = array(
+      'search_api_facets' => TRUE,
+      'search_api_facets_operator_or' => TRUE,
+    );
+    return isset($supported[$feature]);
+  }
+
+  /**
+   * Executes a search on the server represented by this object.
+   *
+   * If the service class supports facets, it should check for an additional
+   * option on the query object:
+   * - search_api_facets: An array of facets to return along with the results
+   *   for this query. The array is keyed by an arbitrary string which should
+   *   serve as the facet's unique identifier for this search. The values are
+   *   arrays with the following keys:
+   *   - field: The field to construct facets for.
+   *   - limit: The maximum number of facet terms to return. 0 or an empty
+   *     value means no limit.
+   *   - min_count: The minimum number of results a facet value has to have in
+   *     order to be returned.
+   *   - missing: If TRUE, a facet for all items with no value for this field
+   *     should be returned (if it conforms to limit and min_count).
+   *   - operator: (optional) If the service supports "OR" facets and this key
+   *     contains the string "or", the returned facets should be "OR" facets. If
+   *     the server doesn't support "OR" facets, this key can be ignored.
+   *
+   * The basic principle of facets is explained quite well in the
+   * @link http://en.wikipedia.org/wiki/Faceted_search Wikipedia article on
+   * "Faceted search" @endlink. Basically, you should return for each field
+   * filter values which would yield some results when used with the search.
+   * E.g., if you return for a field $field the term $term with $count results,
+   * the given $query along with
+   *   $query->condition($field, $term)
+   * should yield exactly (or about) $count results.
+   *
+   * For "OR" facets, all existing filters on the facetted field should be
+   * ignored for computing the facets.
+   *
+   * @param $query
+   *   The SearchApiQueryInterface object to execute.
+   *
+   * @return array
+   *   An associative array containing the search results, as required by
+   *   SearchApiQueryInterface::execute().
+   *   In addition, if the "search_api_facets" option is present on the query,
+   *   the results should contain an array of facets in the "search_api_facets"
+   *   key, as specified by the option. The facets array should be keyed by the
+   *   facets' unique identifiers, and contain a numeric array of facet terms,
+   *   sorted descending by result count. A term is represented by an array with
+   *   the following keys:
+   *   - count: Number of results for this term.
+   *   - filter: The filter to apply when selecting this facet term. A filter is
+   *     a string of one of the following forms:
+   *     - "VALUE": Filter by the literal value VALUE (always include the
+   *       quotes, not only for strings).
+   *     - [VALUE1 VALUE2]: Filter for a value between VALUE1 and VALUE2. Use
+   *       parantheses for excluding the border values and square brackets for
+   *       including them. An asterisk (*) can be used as a wildcard. E.g.,
+   *       (* 0) or [* 0) would be a filter for all negative values.
+   *     - !: Filter for items without a value for this field (i.e., the
+   *       "missing" facet).
+   *
+   * @throws SearchApiException
+   *   If an error prevented the search from completing.
+   */
+  public function search(SearchApiQueryInterface $query) {
+    // We assume here that we have an AI search which understands English
+    // commands.
+
+    // First, create the normal search query, without facets.
+    $search = new SuperCoolAiSearch($query->getIndex());
+    $search->cmd('create basic search for the following query', $query);
+    $ret = $search->cmd('return search results in Search API format');
+
+    // Then, let's see if we should return any facets.
+    if ($facets = $query->getOption('search_api_facets')) {
+      // For the facets, we need all results, not only those in the specified
+      // range.
+      $results = $search->cmd('return unlimited search results as a set');
+      foreach ($facets as $id => $facet) {
+        $field = $facet['field'];
+        $limit = empty($facet['limit']) ? 'all' : $facet['limit'];
+        $min_count = $facet['min_count'];
+        $missing = $facet['missing'];
+        $or = isset($facet['operator']) && $facet['operator'] == 'or';
+
+        // If this is an "OR" facet, existing filters on the field should be
+        // ignored for computing the facets.
+        // You can ignore this if your service class doesn't support the
+        // "search_api_facets_operator_or" feature.
+        if ($or) {
+          // We have to execute another query (in the case of this hypothetical
+          // search backend, at least) to get the right result set to facet.
+          $tmp_search = new SuperCoolAiSearch($query->getIndex());
+          $tmp_search->cmd('create basic search for the following query', $query);
+          $tmp_search->cmd("remove all conditions for field $field");
+          $tmp_results = $tmp_search->cmd('return unlimited search results as a set');
+        }
+        else {
+          // Otherwise, we can just use the normal results.
+          $tmp_results = $results;
+        }
+
+        $filters = array();
+        if ($search->cmd("$field is a date or numeric field")) {
+          // For date, integer or float fields, range facets are more useful.
+          $ranges = $search->cmd("list $limit ranges of field $field in the following set", $tmp_results);
+          foreach ($ranges as $range) {
+            if ($range->getCount() >= $min_count) {
+              // Get the lower and upper bound of the range. * means unlimited.
+              $lower = $range->getLowerBound();
+              $lower = ($lower == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $lower;
+              $upper = $range->getUpperBound();
+              $upper = ($upper == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $upper;
+              // Then, see whether the bounds are included in the range. These
+              // can be specified independently for the lower and upper bound.
+              // Parentheses are used for exclusive bounds, square brackets are
+              // used for inclusive bounds.
+              $lowChar = $range->isLowerBoundInclusive() ? '[' : '(';
+              $upChar = $range->isUpperBoundInclusive() ? ']' : ')';
+              // Create the filter, which separates the bounds with a single
+              // space.
+              $filter = "$lowChar$lower $upper$upChar";
+              $filters[$filter] = $range->getCount();
+            }
+          }
+        }
+        else {
+          // Otherwise, we use normal single-valued facets.
+          $terms = $search->cmd("list $limit values of field $field in the following set", $tmp_results);
+          foreach ($terms as $term) {
+            if ($term->getCount() >= $min_count) {
+              // For single-valued terms, we just need to wrap them in quotes.
+              $filter = '"' . $term->getValue() . '"';
+              $filters[$filter] = $term->getCount();
+            }
+          }
+        }
+
+        // If we should also return a "missing" facet, compute that as the
+        // number of results without a value for the facet field.
+        if ($missing) {
+          $count = $search->cmd("return number of results without field $field in the following set", $tmp_results);
+          if ($count >= $min_count) {
+            $filters['!'] = $count;
+          }
+        }
+
+        // Sort the facets descending by result count.
+        arsort($filters);
+
+        // With the "missing" facet, we might have too many facet terms (unless
+        // $limit was empty and is therefore now set to "all"). If this is the
+        // case, remove those with the lowest number of results.
+        while (is_numeric($limit) && count($filters) > $limit) {
+          array_pop($filters);
+        }
+
+        // Now add the facet terms to the return value, as specified in the doc
+        // comment for this method.
+        foreach ($filters as $filter => $count) {
+          $ret['search_api_facets'][$id][] = array(
+            'count' => $count,
+            'filter' => $filter,
+          );
+        }
+      }
+    }
+
+    // Return the results, which now also includes the facet information.
+    return $ret;
+  }
+
+}

+ 242 - 0
contrib/search_api_facetapi/plugins/facetapi/adapter.inc

@@ -0,0 +1,242 @@
+<?php
+
+/**
+ * @file
+ * Classes used by the Facet API module.
+ */
+
+/**
+ * Facet API adapter for the Search API module.
+ */
+class SearchApiFacetapiAdapter extends FacetapiAdapter {
+
+  /**
+   * Cached value for the current search for this searcher, if any.
+   *
+   * @see getCurrentSearch()
+   *
+   * @var array
+   */
+  protected $current_search;
+
+  /**
+   * The active facet fields for the current search.
+   *
+   * @var array
+   */
+  protected $fields = array();
+
+  /**
+   * Returns the path to the admin settings for a given realm.
+   *
+   * @param $realm_name
+   *   The name of the realm.
+   *
+   * @return
+   *   The path to the admin settings.
+   */
+  public function getPath($realm_name) {
+    $base_path = 'admin/config/search/search_api';
+    $index_id = $this->info['instance'];
+    return $base_path . '/index/' . $index_id . '/facets/' . $realm_name;
+  }
+
+  /**
+   * Overrides FacetapiAdapter::getSearchPath().
+   */
+  public function getSearchPath() {
+    $search = $this->getCurrentSearch();
+    if ($search && $search[0]->getOption('search_api_base_path')) {
+      return $search[0]->getOption('search_api_base_path');
+    }
+    return $_GET['q'];
+  }
+
+  /**
+   * Allows the backend to initialize its query object before adding the facet filters.
+   *
+   * @param mixed $query
+   *   The backend's native object.
+   */
+  public function initActiveFilters($query) {
+    $search_id = $query->getOption('search id');
+    $index_id = $this->info['instance'];
+    $facets = facetapi_get_enabled_facets($this->info['name']);
+    $this->fields = array();
+
+    // We statically store the current search per facet so that we can correctly
+    // assign it when building the facets. See the build() method in the query
+    // type plugin classes.
+    $active = &drupal_static('search_api_facetapi_active_facets', array());
+    foreach ($facets as $facet) {
+      $options = $this->getFacet($facet)->getSettings()->settings;
+      // The 'default_true' option is a choice between "show on all but the
+      // selected searches" (TRUE) and "show for only the selected searches".
+      $default_true = isset($options['default_true']) ? $options['default_true'] : TRUE;
+      // The 'facet_search_ids' option is the list of selected searches that
+      // will either be excluded or for which the facet will exclusively be
+      // displayed.
+      $facet_search_ids = isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array();
+
+      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.
+        }
+      }
+      elseif ($default_true) {
+        continue; // The 'facet_search_ids' in the settings are to be excluded.
+      }
+      $active[$facet['name']] = $search_id;
+      $this->fields[$facet['name']] = array(
+        'field'             => $facet['field'],
+        'limit'             => $options['hard_limit'],
+        'operator'          => $options['operator'],
+        'min_count'         => $options['facet_mincount'],
+        'missing'           => $options['facet_missing'],
+      );
+    }
+  }
+
+  /**
+   * 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']];
+    }
+  }
+
+  /**
+   * Returns a boolean flagging whether $this->_searcher executed a search.
+   */
+  public function searchExecuted() {
+    return (bool) $this->getCurrentSearch();
+  }
+
+  /**
+   * Helper method for getting a current search for this searcher.
+   *
+   * @return array
+   *   The first matching current search, in the form specified by
+   *   search_api_current_search(). Or NULL, if no match was found.
+   */
+  public function getCurrentSearch() {
+    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
+      // cannot match it up to the correct "current search".
+      // 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;
+        if ($query->getIndex()->machine_name == $index_id) {
+          $this->current_search = $search;
+        }
+      }
+    }
+    return $this->current_search ? $this->current_search : NULL;
+  }
+
+  /**
+   * Returns a boolean flagging whether facets in a realm shoud be displayed.
+   *
+   * Useful, for example, for suppressing sidebar blocks in some cases.
+   *
+   * @return
+   *   A boolean flagging whether to display a given realm.
+   */
+  public function suppressOutput($realm_name) {
+    // Not sure under what circumstances the output will need to be suppressed?
+    return FALSE;
+  }
+
+  /**
+   * Returns the search keys.
+   */
+  public function getSearchKeys() {
+    $search = $this->getCurrentSearch();
+    $keys = $search[0]->getOriginalKeys();
+    if (is_array($keys)) {
+      // This will happen nearly never when displaying the search keys to the
+      // user, so go with a simple work-around.
+      // If someone complains, we can easily add a method for printing them
+      // 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;
+  }
+
+  /**
+   * Returns the number of total results found for the current search.
+   */
+  public function getResultCount() {
+    $search = $this->getCurrentSearch();
+    // Each search is an array with the query as the first element and the results
+    // array as the second.
+    if (isset($search[1])) {
+      return $search[1]['result count'];
+    }
+    return 0;
+  }
+
+  /**
+   * Allows for backend specific overrides to the settings form.
+   */
+  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());
+    $search_ids = isset($search_ids[$this->info['instance']]) ? $search_ids[$this->info['instance']] : array();
+    if (count($search_ids) > 1) {
+      $form['global']['default_true'] = array(
+        '#type' => 'select',
+        '#title' => t('Display for searches'),
+        '#options' => array(
+          TRUE => t('For all except the selected'),
+          FALSE => t('Only for the selected'),
+        ),
+        '#default_value' => isset($options['default_true']) ? $options['default_true'] : TRUE,
+      );
+      $form['global']['facet_search_ids'] = array(
+        '#type' => 'select',
+        '#title' => t('Search IDs'),
+        '#options' => $search_ids,
+        '#size' => min(4, count($search_ids)),
+        '#multiple' => TRUE,
+        '#default_value' => isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array(),
+      );
+    }
+    else {
+      $form['global']['default_true'] = array(
+        '#type' => 'value',
+        '#value' => TRUE,
+      );
+      $form['global']['facet_search_ids'] = array(
+        '#type' => 'value',
+        '#value' => array(),
+      );
+    }
+  }
+}

+ 196 - 0
contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc

@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * @file
+ * Date query type plugin for the Search API adapter.
+ */
+
+/**
+ * Plugin for "date" query types.
+ */
+class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQueryTypeInterface {
+
+  /**
+   * Loads the include file containing the date API functions.
+   */
+  public function __construct(FacetapiAdapter $adapter, array $facet) {
+    module_load_include('date.inc', 'facetapi');
+    parent::__construct($adapter, $facet);
+  }
+
+  /**
+   * Returns the query type associated with the plugin.
+   *
+   * @return string
+   *   The query type.
+   */
+  static public function getType() {
+    return 'date';
+  }
+
+  /**
+   * Adds the filter to the query object.
+   *
+   * @param $query
+   *   An object containing the query in the backend's native API.
+   */
+  public function execute($query) {
+    // Return terms for this facet.
+    $this->adapter->addFacet($this->facet, $query);
+    // Change limit to "unlimited" (-1).
+    $options = &$query->getOptions();
+    if (!empty($options['search_api_facets'][$this->facet['name']])) {
+      $options['search_api_facets'][$this->facet['name']]['limit'] = -1;
+    }
+
+    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);
+    }
+  }
+
+  /**
+   * Replacement callback for replacing ISO dates with timestamps.
+   */
+  public function replaceDateString($matches) {
+    return strtotime($matches[0]);
+  }
+
+  /**
+   * Initializes the facet's build array.
+   *
+   * @return array
+   *   The initialized render array.
+   */
+  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']])) {
+      return array();
+    }
+    $search_id = $search_ids[$facet['name']];
+    $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']];
+      foreach ($values as $value) {
+        if ($value['count']) {
+          $filter = $value['filter'];
+          // We only process single values further. The "missing" filter and
+          // range filters will be passed on unchanged.
+          if ($filter == '!') {
+            $build[$filter]['#count'] = $value['count'];
+          }
+          elseif ($filter[0] == '"') {
+            $filter = substr($value['filter'], 1, -1);
+            if ($filter) {
+              $raw_values[$filter] = $value['count'];
+            }
+          }
+          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'];
+          }
+        }
+      }
+    }
+
+    // Gets active facets, starts building hierarchy.
+    $parent = $gap = NULL;
+    foreach ($this->adapter->getActiveItems($this->facet) 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 '))) {
+        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);
+
+      // If there is a previous item, there is a parent, uses a reference so the
+      // arrays are populated when they are updated.
+      if (NULL !== $parent) {
+        $build[$parent]['#item_children'][$value] = &$build[$value];
+        $build[$value]['#item_parents'][$parent] = $parent;
+      }
+
+      // Stores the last value iterated over.
+      $parent = $value;
+    }
+    if (empty($raw_values)) {
+      return $build;
+    }
+    ksort($raw_values);
+
+    // Mind the gap! Calculates gap from min and max timestamps.
+    $timestamps = array_keys($raw_values);
+    if (NULL === $parent) {
+      if (count($raw_values) > 1) {
+        $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps));
+      }
+      else {
+        $gap = FACETAPI_DATE_HOUR;
+      }
+    }
+
+    // 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.
+    foreach ($raw_values as $value => $count) {
+      $new_value = '[' . $dates[$value] . ' TO ' . $range_end[$dates[$value]] . ']';
+      if (!isset($build[$new_value])) {
+        $build[$new_value] = array('#count' => $count);
+      }
+      else {
+        $build[$new_value]['#count'] += $count;
+      }
+
+      // Adds parent information if not already set.
+      if (NULL !== $parent && $parent != $new_value) {
+        $build[$parent]['#item_children'][$new_value] = &$build[$new_value];
+        $build[$new_value]['#item_parents'][$parent] = $parent;
+      }
+    }
+
+    return $build;
+  }
+}

+ 149 - 0
contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc

@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file
+ * Term query type plugin for the Apache Solr adapter.
+ */
+
+/**
+ * Plugin for "term" query types.
+ */
+class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTypeInterface {
+
+  /**
+   * Returns the query type associated with the plugin.
+   *
+   * @return string
+   *   The query type.
+   */
+  static public function getType() {
+    return 'term';
+  }
+
+  /**
+   * Adds the filter to the query object.
+   *
+   * @param SearchApiQueryInterface $query
+   *   An object containing the query in the backend's native API.
+   */
+  public function execute($query) {
+    // 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'];
+
+    // Add 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');
+    }
+    else {
+      // Otherwise we set the conditions directly on the query.
+      $facet_filter = $query;
+    }
+
+    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);
+    }
+  }
+
+  /**
+   * 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
+    // comparison expressions.
+    $filter = (string) $filter;
+    if ($filter == '!') {
+      $query_filter->condition($field, NULL);
+    }
+    elseif ($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, '<>');
+      }
+      else {
+        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
+          // bound) and therefore have to make sure that we have an AND
+          // conjunction for those.
+          if ($upper != '*' && !($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
+            $original_query_filter = $query_filter;
+            $query_filter = new SearchApiQueryFilter('AND');
+          }
+          $query_filter->condition($field, $lower, '>=');
+        }
+        if ($upper != '*') {
+          $query_filter->condition($field, $upper, '<=');
+        }
+      }
+    }
+    else {
+      $query_filter->condition($field, $filter);
+    }
+    if (isset($original_query_filter)) {
+      $original_query_filter->filter($query_filter);
+    }
+  }
+
+  /**
+   * Initializes the facet's build array.
+   *
+   * @return array
+   *   The initialized render array.
+   */
+  public function build() {
+    $facet = $this->adapter->getFacet($this->facet);
+    // The current search per facet is stored in a static variable (during
+    // 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']])) {
+      return array();
+    }
+    $search_id = $search_ids[$facet['name']];
+    $search = 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']];
+      foreach ($values as $value) {
+        $filter = $value['filter'];
+        // As Facet API isn't really suited for our native facet filter
+        // representations, convert the format here. (The missing facet can
+        // stay the same.)
+        if ($filter[0] == '"') {
+          $filter = substr($filter, 1, -1);
+        }
+        elseif ($filter != '!') {
+          // This is a range filter.
+          $filter = substr($filter, 1, -1);
+          $pos = strpos($filter, ' ');
+          if ($pos !== FALSE) {
+            $filter = '[' . substr($filter, 0, $pos) . ' TO ' . substr($filter, $pos + 1) . ']';
+          }
+        }
+        $build[$filter] = array(
+          '#count' => $value['count'],
+        );
+      }
+    }
+    return $build;
+  }
+
+}

+ 31 - 0
contrib/search_api_facetapi/search_api_facetapi.api.php

@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Search facets module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Lets modules alter the search keys that are returned to FacetAPI and used
+ * in the current search block and breadcrumb trail.
+ *
+ * @param string $keys
+ *   The string representing the user's current search query.
+ * @param SearchApiQuery $query
+ *   The SearchApiQuery object for the current search.
+ */
+function hook_search_api_facetapi_keys_alter(&$keys, $query) {
+  if ($keys == '[' . t('all items') . ']') {
+    // Change $keys to something else, perhaps based on filters in the query
+    // object.
+  }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */

+ 17 - 0
contrib/search_api_facetapi/search_api_facetapi.info

@@ -0,0 +1,17 @@
+name = Search facets
+description = "Integrate the Search API with the Facet API to provide facetted searches."
+dependencies[] = search_api
+dependencies[] = facetapi
+core = 7.x
+package = Search
+
+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"
+core = "7.x"
+project = "search_api"
+datestamp = "1357726719"
+

+ 13 - 0
contrib/search_api_facetapi/search_api_facetapi.install

@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the Search facets module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function search_api_facetapi_uninstall() {
+  variable_del('search_api_facets_search_ids');
+}

+ 381 - 0
contrib/search_api_facetapi/search_api_facetapi.module

@@ -0,0 +1,381 @@
+<?php
+
+/**
+ * @file
+ * Integrates the Search API with the Facet API.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function search_api_facetapi_menu() {
+  // We need to handle our own menu paths for facets because we need a facet
+  // configuration page per index.
+  $first = TRUE;
+  foreach (facetapi_get_realm_info() as $realm_name => $realm) {
+    if ($first) {
+      $first = FALSE;
+      $items['admin/config/search/search_api/index/%search_api_index/facets'] = array(
+        'title'            => 'Facets',
+        'page callback'    => 'search_api_facetapi_settings',
+        'page arguments'   =>  array($realm_name, 5),
+        'weight'           => -1,
+        'access arguments' => array('administer search_api'),
+        'type'             => MENU_LOCAL_TASK,
+        'context'          => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+      );
+      $items['admin/config/search/search_api/index/%search_api_index/facets/' . $realm_name] = array(
+        'title'            => $realm['label'],
+        'type'             => MENU_DEFAULT_LOCAL_TASK,
+        'weight'           => $realm['weight'],
+      );
+    }
+    else {
+      $items['admin/config/search/search_api/index/%search_api_index/facets/' . $realm_name] = array(
+        'title'            => $realm['label'],
+        'page callback'    => 'search_api_facetapi_settings',
+        'page arguments'   => array($realm_name, 5),
+        'access arguments' => array('administer search_api'),
+        'type'             => MENU_LOCAL_TASK,
+        'context'          => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+        'weight'           => $realm['weight'],
+      );
+    }
+  }
+
+  return $items;
+}
+
+/**
+ * Implements hook_facetapi_searcher_info().
+ */
+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')) {
+      $searcher_name = 'search_api@' . $index->machine_name;
+      $info[$searcher_name] = array(
+        'label' => t('Search service: @name', array('@name' => $index->name)),
+        'adapter' => 'search_api',
+        'instance' => $index->machine_name,
+        'types' => array($index->item_type),
+        'path' => '',
+        'supports facet missing' => TRUE,
+        'supports facet mincount' => TRUE,
+        'include default facets' => FALSE,
+      );
+    }
+  }
+  return $info;
+}
+
+/**
+ * Implements hook_facetapi_facet_info().
+ */
+function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
+  $facet_info = array();
+  if ('search_api' == $searcher_info['adapter']) {
+    $index = search_api_index_load($searcher_info['instance']);
+    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'])) {
+        $bundle_key = $entity_info['bundle keys']['bundle'];
+      }
+
+      // Some type-specific settings. Allowing to set some additional callbacks
+      // (and other settings) in the map options allows for easier overriding by
+      // other modules.
+      $type_settings = array(
+        'taxonomy_term' => array(
+          'hierarchy callback' => 'facetapi_get_taxonomy_hierarchy',
+        ),
+        'date' => array(
+          'query type' => 'date',
+          'map options' => array(
+            'map callback' => 'facetapi_map_date',
+          ),
+        ),
+      );
+
+      // Iterate through the indexed fields to set the facetapi settings for
+      // each one.
+      foreach ($index->getFields() as $key => $field) {
+        $field['key'] = $key;
+        // Determine which, if any, of the field type-specific options will be
+        // used for this field.
+        $type = isset($field['entity_type']) ? $field['entity_type'] : $field['type'];
+        $type_settings += array($type => array());
+
+        $facet_info[$key] = $type_settings[$type] + array(
+          'label' => $field['name'],
+          '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'),
+          ),
+          'dependency plugins' => array('role'),
+          'facet missing allowed' => TRUE,
+          'facet mincount allowed' => TRUE,
+          'map callback' => 'search_api_facetapi_facet_map_callback',
+          'map options' => array(),
+          'field type' => $type,
+        );
+        if ($type === 'date') {
+          $facet_info[$key]['description'] .= ' ' . t('(Caution: This may perform very poorly for large result sets.)');
+        }
+        $facet_info[$key]['map options'] += array(
+          'field' => $field,
+          'index id' => $index->machine_name,
+          'value callback' => '_search_api_facetapi_facet_create_label',
+        );
+        // Find out whether this property is a Field API field.
+        if (strpos($key, ':') === FALSE) {
+          if (isset($wrapper->$key)) {
+            $property_info = $wrapper->$key->info();
+            if (!empty($property_info['field'])) {
+              $facet_info[$key]['field api name'] = $key;
+            }
+          }
+        }
+
+        // Add bundle information, if applicable.
+        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;
+          }
+          else {
+            // Add "bundle" as possible dependency plugin.
+            $facet_info[$key]['dependency plugins'][] = 'bundle';
+          }
+        }
+      }
+    }
+  }
+  return $facet_info;
+}
+
+/**
+ * Implements hook_facetapi_adapters().
+ */
+function search_api_facetapi_facetapi_adapters() {
+  return array(
+    'search_api' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiAdapter',
+      ),
+    ),
+  );
+}
+
+/**
+ * Implements hook_facetapi_query_types().
+ */
+function search_api_facetapi_facetapi_query_types() {
+  return array(
+    'search_api_term' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiTerm',
+        'adapter' => 'search_api',
+      ),
+    ),
+    'search_api_date' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiDate',
+        'adapter' => 'search_api',
+      ),
+    ),
+  );
+}
+
+/**
+ * Implements hook_search_api_query_alter().
+ *
+ * Adds Facet API support to the query.
+ */
+function search_api_facetapi_search_api_query_alter($query) {
+  $index = $query->getIndex();
+  if ($index->server()->supportsFeature('search_api_facets')) {
+    // This is the main point of communication between the facet system and the
+    // search back-end - it makes the query respond to active facets.
+    $searcher = 'search_api@' . $index->machine_name;
+    $adapter = facetapi_adapter_load($searcher);
+    if ($adapter) {
+      $adapter->addActiveFilters($query);
+    }
+  }
+}
+
+/**
+ * Menu callback for the facet settings page.
+ */
+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')) {
+    return array('#markup' => t('This index uses a server that does not support facet functionality.'));
+  }
+  $searcher_name = 'search_api@' . $index->machine_name;
+  module_load_include('inc', 'facetapi', 'facetapi.admin');
+  return drupal_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name);
+}
+
+/**
+ * Map callback for all search_api facet fields.
+ *
+ * @param array $values
+ *   The values to map.
+ * @param array $options
+ *   An associative array containing:
+ *   - 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 filter values to their labels.
+ */
+function search_api_facetapi_facet_map_callback(array $values, array $options = array()) {
+  $map = array();
+  // See if we have an additional map callback.
+  if (isset($options['map callback']) && is_callable($options['map callback'])) {
+    $map = call_user_func($options['map callback'], $values, $options);
+  }
+
+  // Then look at all unmapped values and save information for them.
+  $mappable_values = array();
+  $ranges = array();
+  foreach ($values as $value) {
+    $value = (string) $value;
+    if (isset($map[$value])) {
+      continue;
+    }
+    if ($value == '!') {
+      // The "missing" filter is usually always the same, but we allow an easy
+      // override via the "missing label" map option.
+      $map['!'] = isset($options['missing label']) ? $options['missing label'] : '(' . t('none') . ')';
+      continue;
+    }
+    $length = strlen($value);
+    if ($length > 5 && $value[0] == '[' && $value[$length - 1] == ']' && ($pos = strpos($value, ' TO '))) {
+      // This is a range filter.
+      $lower = trim(substr($value, 1, $pos));
+      $upper = trim(substr($value, $pos + 4, -1));
+      if ($lower != '*') {
+        $mappable_values[$lower] = TRUE;
+      }
+      if ($upper != '*') {
+        $mappable_values[$upper] = TRUE;
+      }
+      $ranges[$value] = array(
+        'lower' => $lower,
+        'upper' => $upper,
+      );
+    }
+    else {
+      // A normal, single-value filter.
+      $mappable_values[$value] = TRUE;
+    }
+  }
+
+  if ($mappable_values) {
+    $map += call_user_func($options['value callback'], array_keys($mappable_values), $options);
+  }
+
+  foreach ($ranges as $value => $range) {
+    $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');
+    }
+    elseif ($lower == '*') {
+      $map[$value] = "< $upper";
+    }
+    elseif ($upper == '*') {
+      $map[$value] = "> $lower";
+    }
+    else {
+      $map[$value] = "$lower – $upper";
+    }
+  }
+
+  return $map;
+}
+
+/**
+ * Creates a human-readable label for single facet filter values.
+ */
+function _search_api_facetapi_facet_create_label(array $values, array $options) {
+  $field = $options['field'];
+  // 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) {
+        $map[$id] = $label;
+      }
+    }
+    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();
+  foreach (explode(':', $field['key']) as $part) {
+    if (!isset($wrapper->$part)) {
+      $wrapper = NULL;
+      break;
+    }
+    $wrapper = $wrapper->$part;
+    while (($info = $wrapper->info()) && search_api_is_list_type($info['type'])) {
+      $wrapper = $wrapper[0];
+    }
+  }
+  if ($wrapper && ($options = $wrapper->optionsList('view'))) {
+    return $options;
+  }
+  // As a "last resort" we try to create a label based on the field type.
+  $map = array();
+  foreach ($values as $value) {
+    switch ($field['type']) {
+      case 'boolean':
+        $map[$value] = $value ? t('true') : t('false');
+        break;
+      case 'date':
+        $v = is_numeric($value) ? $value : strtotime($value);
+        $map[$value] = format_date($v, 'short');
+        break;
+      case 'duration':
+        $map[$value] = format_interval($value);
+        break;
+    }
+  }
+  return $map;
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function search_api_facetapi_form_search_api_admin_index_fields_alter(&$form, &$form_state) {
+  $form['#submit'][] = 'search_api_facetapi_search_api_admin_index_fields_submit';
+}
+
+/**
+ * Form submission handler for search_api_admin_index_fields().
+ */
+function search_api_facetapi_search_api_admin_index_fields_submit($form, &$form_state) {
+  // Clears this searcher's cached facet definitions.
+  $cid = 'facetapi:facet_info:search_api@' . $form_state['index']->machine_name . ':';
+  cache_clear_all($cid, 'cache', TRUE);
+}

+ 71 - 0
contrib/search_api_views/README.txt

@@ -0,0 +1,71 @@
+Search API Views integration
+----------------------------
+
+This module integrates the Search API with the popular Views module [1],
+allowing users to create views with filters, arguments, sorts and fields based
+on any search index.
+
+[1] http://drupal.org/project/views
+
+"More like this" feature
+------------------------
+This module defines the "More like this" feature (feature key: "search_api_mlt")
+that search service classes can implement. With a server supporting this, you
+can use the „More like this“ contextual filter to display a list of items
+related to a given item (usually, nodes similar to the node currently viewed).
+
+For developers:
+A service class that wants to support this feature has to check for a
+"search_api_mlt" option in the search() method. When present, it will be an
+array containing two keys:
+- id: The entity ID of the item to which related items should be searched.
+- fields: An array of indexed fields to use for testing the similarity of items.
+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.
+
+"Facets block" display
+----------------------
+Most features should be clear to users of Views. However, the module also
+provides a new display type, "Facets block", that might need some explanation.
+This display type is only available, if the „Search facets“ module is also
+enabled.
+
+The basic use of the block is to provide a list of links to the most popular
+filter terms (i.e., the ones with the most results) for a certain category. For
+example, you could provide a block listing the most popular authors, or taxonomy
+terms, linking to searches for those, to provide some kind of landing page.
+
+Please note that, due to limitations in Views, this display mode is shown for
+views of all base tables, even though it only works for views based on Search
+API indexes. For views of other base tables, this will just print an error
+message.
+The display will also always ignore the view's "Style" setting, selected fields
+and sorts, etc.
+
+To use the display, specify the base path of the search you want to link to
+(this enables you to also link to searches that aren't based on Views) and the
+facet field to use (any indexed field can be used here, there needn't be a facet
+defined for it). You'll then have the block available in the blocks
+administration and can enable and move it at leisure.
+Note, however, that the facet in question has to be enabled for the search page
+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.
+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.
+
+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.
+
+FAQ: Why „*Indexed* Node“?
+--------------------------
+The group name used for the search result itself (in fields, filters, etc.) is
+prefixed with „Indexed“ in order to be distinguishable from fields on referenced
+nodes (or other entities). The data displayed normally still comes from the
+entity, not from the search index.

+ 262 - 0
contrib/search_api_views/includes/display_facet_block.inc

@@ -0,0 +1,262 @@
+<?php
+
+/**
+ * @file
+ * Display plugin for displaying the search facets in a block.
+ */
+
+/**
+ * Plugin class for displaying search facets in a block.
+ */
+class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
+  public function displays_exposed() {
+    return FALSE;
+  }
+  public function uses_exposed() {
+    return FALSE;
+  }
+
+  public function option_definition() {
+    $options = parent::option_definition();
+
+    $options['linked_path'] = array('default' => '');
+    $options['facet_field'] = '';
+    $options['hide_block'] = FALSE;
+
+    return $options;
+  }
+
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+
+    if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
+      return;
+    }
+
+    switch ($form_state['section']) {
+      case 'linked_path':
+        $form['#title'] .= t('Search page path');
+        $form['linked_path'] = array(
+          '#type' => 'textfield',
+          '#description' => t('The menu path to which search facets will link. Leave empty to use the current path.'),
+          '#default_value' => $this->get_option('linked_path'),
+        );
+        break;
+      case 'facet_field':
+        $form['facet_field'] = array(
+          '#type' => 'select',
+          '#title' => t('Facet field'),
+          '#options' => $this->getFieldOptions(),
+          '#default_value' => $this->get_option('facet_field'),
+        );
+        break;
+      case 'use_more':
+        $form['use_more']['#description'] = t('This will add a more link to the bottom of this view, which will link to the base path for the facet links.');
+        $form['use_more_always'] = array(
+          '#type' => 'value',
+          '#value' => $this->get_option('use_more_always'),
+        );
+        break;
+      case 'hide_block':
+        $form['hide_block'] = array(
+          '#type' => 'checkbox',
+          '#title' => t('Hide block'),
+          '#description' => t('Hide this block, but still execute the search. ' .
+              'Can be used to show native Facet API facet blocks linking to the search page specified above.'),
+          '#default_value' => $this->get_option('hide_block'),
+        );
+        break;
+    }
+  }
+
+  public function options_validate(&$form, &$form_state) {
+    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.'));
+    }
+  }
+
+  public function options_submit(&$form, &$form_state) {
+    parent::options_submit($form, $form_state);
+
+    switch ($form_state['section']) {
+      case 'linked_path':
+        $this->set_option('linked_path', $form_state['values']['linked_path']);
+        break;
+      case 'facet_field':
+        $this->set_option('facet_field', $form_state['values']['facet_field']);
+        break;
+      case 'hide_block':
+        $this->set_option('hide_block', $form_state['values']['hide_block']);
+        break;
+    }
+  }
+
+  public function options_summary(&$categories, &$options) {
+    parent::options_summary($categories, $options);
+
+    $options['linked_path'] = array(
+      'category' => 'block',
+      'title' => t('Search page path'),
+      'value' => $this->get_option('linked_path') ? $this->get_option('linked_path') : t('Use current path'),
+    );
+    $field_options = $this->getFieldOptions();
+    $options['facet_field'] = array(
+      'category' => 'block',
+      'title' => t('Facet field'),
+      'value' => $this->get_option('facet_field') ? $field_options[$this->get_option('facet_field')] : t('None'),
+    );
+    $options['hide_block'] = array(
+      'category' => 'block',
+      'title' => t('Hide block'),
+      'value' => $this->get_option('hide_block') ? t('Yes') : t('No'),
+    );
+  }
+
+  protected $field_options = NULL;
+
+  protected function getFieldOptions() {
+    if (!isset($this->field_options)) {
+      $index_id = substr($this->view->base_table, 17);
+      if (!($index_id && ($index = search_api_index_load($index_id)))) {
+        $table = views_fetch_data($this->view->base_table);
+        $table = empty($table['table']['base']['title']) ? $this->view->base_table : $table['table']['base']['title'];
+        throw new SearchApiException(t('The "Facets block" display cannot be used with a view for @basetable. ' .
+            'Please only use this display with base tables representing search indexes.',
+            array('@basetable' => $table)));
+      }
+      $this->field_options = array();
+      if (!empty($index->options['fields'])) {
+        foreach ($index->getFields() as $key => $field) {
+          $this->field_options[$key] = $field['name'];
+        }
+      }
+    }
+    return $this->field_options;
+  }
+
+  /**
+   * Render the 'more' link
+   */
+  public function render_more_link() {
+    if ($this->use_more()) {
+      $path = $this->get_option('linked_path');
+      $theme = views_theme_functions('views_more', $this->view, $this->display);
+      $path = check_url(url($path, array()));
+
+      return array(
+        '#theme' => $theme,
+        '#more_url' => $path,
+        '#link_text' => check_plain($this->use_more_text()),
+      );
+    }
+  }
+
+  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;
+    }
+    $facet_field = $this->get_option('facet_field');
+    if (!$facet_field) {
+      return NULL;
+    }
+
+    $base_path = $this->get_option('linked_path');
+    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')) {
+      // If we hide the block, we don't need this extra facet.
+      $query_options['search_api_facets']['search_api_views_facets_block'] = array(
+        'field' => $facet_field,
+        'limit' => $limit,
+        'missing' => FALSE,
+        '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);
+
+    $this->view->execute();
+
+    if ($this->get_option('hide_block')) {
+      return NULL;
+    }
+
+    $results = $this->view->query->getSearchApiResults();
+
+    if (empty($results['search_api_facets']['search_api_views_facets_block'])) {
+      return NULL;
+    }
+    $terms = $results['search_api_facets']['search_api_views_facets_block'];
+
+    $filters = array();
+    foreach ($terms as $term) {
+      $filter = $term['filter'];
+      if ($filter[0] == '"') {
+        $filter = substr($filter, 1, -1);
+      }
+      elseif ($filter != '!') {
+        // This is a range filter.
+        $filter = substr($filter, 1, -1);
+        $pos = strpos($filter, ' ');
+        if ($pos !== FALSE) {
+          $filter = '[' . substr($filter, 0, $pos) . ' TO ' . substr($filter, $pos + 1) . ']';
+        }
+      }
+      $filters[$term['filter']] = $filter;
+    }
+
+    $index = $this->view->query->getIndex();
+    $options['field'] = $index->options['fields'][$facet_field];
+    $options['field']['key'] = $facet_field;
+    $options['index id'] = $index->machine_name;
+    $options['value callback'] = '_search_api_facetapi_facet_create_label';
+    $map = search_api_facetapi_facet_map_callback($filters, $options);
+
+    $facets = array();
+    $prefix = rawurlencode($facet_field) . ':';
+    foreach ($terms as $term) {
+      $name = $filter = $filters[$term['filter']];
+      if (isset($map[$filter])) {
+        $name = $map[$filter];
+      }
+      $query['f'][0] = $prefix . $filter;
+
+      // Initializes variables passed to theme hook.
+      $variables = array(
+        'text' => $name,
+        'path' => $base_path,
+        'count' => $term['count'],
+        'options' => array(
+          'attributes' => array('class' => 'facetapi-inactive'),
+          'html' => FALSE,
+          'query' => $query,
+        ),
+      );
+
+      // Themes the link, adds row to facets.
+      $facets[] = array(
+        'class' => array('leaf'),
+        'data' => theme('facetapi_link_inactive', $variables),
+      );
+    }
+
+    if (!$facets) {
+      return NULL;
+    }
+
+    $info['content']['facets'] = array(
+      '#theme'  => 'item_list',
+      '#items'  => $facets,
+    );
+    $info['content']['more'] = $this->render_more_link();
+    $info['subject'] = filter_xss_admin($this->view->get_title());
+    return $info;
+  }
+
+}

+ 122 - 0
contrib/search_api_views/includes/handler_argument.inc

@@ -0,0 +1,122 @@
+<?php
+
+/**
+ * Views argument handler class for handling all non-fulltext types.
+ */
+class SearchApiViewsHandlerArgument extends views_handler_argument {
+
+  /**
+   * The associated views query object.
+   *
+   * @var SearchApiViewsQuery
+   */
+  public $query;
+
+  /**
+   * Determine if the argument can generate a breadcrumb
+   *
+   * @return boolean
+   */
+  // @todo Change and implement set_breadcrumb()?
+  public function uses_breadcrumb() {
+    return FALSE;
+  }
+
+  /**
+   * Provide a list of default behaviors for this argument if the argument
+   * is not present.
+   *
+   * Override this method to provide additional (or fewer) default behaviors.
+   */
+  public function default_actions($which = NULL) {
+    $defaults = array(
+      'ignore' => array(
+        'title' => t('Display all values'),
+        'method' => 'default_ignore',
+        'breadcrumb' => TRUE, // generate a breadcrumb to here
+      ),
+      'not found' => array(
+        'title' => t('Hide view / Page not found (404)'),
+        'method' => 'default_not_found',
+        'hard fail' => TRUE, // This is a hard fail condition
+      ),
+      'empty' => array(
+        'title' => t('Display empty text'),
+        'method' => 'default_empty',
+        'breadcrumb' => TRUE, // generate a breadcrumb to here
+      ),
+      'default' => array(
+        'title' => t('Provide default argument'),
+        'method' => 'default_default',
+        'form method' => 'default_argument_form',
+        'has default argument' => TRUE,
+        'default only' => TRUE, // this can only be used for missing argument, not validation failure
+      ),
+    );
+
+    if ($which) {
+      return isset($defaults[$which]) ? $defaults[$which] : NULL;
+    }
+    return $defaults;
+  }
+
+  public function option_definition() {
+    $options = parent::option_definition();
+
+    $options['break_phrase'] = array('default' => FALSE);
+
+    return $options;
+  }
+
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+
+    // Allow passing multiple values.
+    $form['break_phrase'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Allow multiple values'),
+      '#description' => t('If selected, users can enter multiple values in the form of 1+2+3 (for OR) or 1,2,3 (for AND).'),
+      '#default_value' => $this->options['break_phrase'],
+      '#fieldset' => 'more',
+    );
+  }
+
+  /**
+   * Set up the query for this 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 (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, '=');
+        }
+        $this->query->filter($filter);
+      }
+    }
+    else {
+      $this->query->condition($this->real_field, reset($this->value));
+    }
+  }
+
+  /**
+   * 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));
+  }
+
+}

+ 84 - 0
contrib/search_api_views/includes/handler_argument_fulltext.inc

@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * Views argument handler class for handling fulltext fields.
+ */
+class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumentText {
+
+  /**
+   * Specify the options this filter uses.
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    $options['fields'] = array('default' => array());
+    return $options;
+  }
+
+  /**
+   * Extend the options form a bit.
+   */
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+
+    $fields = $this->getFulltextFields();
+    if (!empty($fields)) {
+      $form['fields'] = array(
+        '#type' => 'select',
+        '#title' => t('Searched fields'),
+        '#description' => t('Select the fields that will be searched. If no fields are selected, all available fulltext fields will be searched.'),
+        '#options' => $fields,
+        '#size' => min(4, count($fields)),
+        '#multiple' => TRUE,
+        '#default_value' => $this->options['fields'],
+      );
+    }
+    else {
+      $form['fields'] = array(
+        '#type' => 'value',
+        '#value' => array(),
+      );
+    }
+  }
+
+  /**
+   * Set up the query for this argument.
+   *
+   * The argument sent may be found at $this->argument.
+   */
+  public function query($group_by = FALSE) {
+    if ($this->options['fields']) {
+      $this->query->fields($this->options['fields']);
+    }
+
+    $old = $this->query->getOriginalKeys();
+    $this->query->keys($this->argument);
+    if ($old) {
+      $keys = &$this->query->getKeys();
+      if (is_array($keys)) {
+        $keys[] = $old;
+      }
+      elseif (is_array($old)) {
+        // We don't support such nonsense.
+      }
+      else {
+        $keys = "($old) ($keys)";
+      }
+    }
+  }
+
+  /**
+   * Helper method to get an option list of all available fulltext fields.
+   */
+  protected function getFulltextFields() {
+    $ret = array();
+    $index = search_api_index_load(substr($this->table, 17));
+    if (!empty($index->options['fields'])) {
+      $fields = $index->getFields();
+      foreach ($index->getFulltextFields() as $field) {
+        $ret[$field] = $fields[$field]['name'];
+      }
+    }
+    return $ret;
+  }
+
+}

+ 77 - 0
contrib/search_api_views/includes/handler_argument_more_like_this.inc

@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * Views argument handler providing a list of related items for search servers
+ * supporting the "search_api_mlt" feature.
+ */
+class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArgument {
+
+  /**
+   * Specify the options this filter uses.
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    unset($options['break_phrase']);
+    $options['fields'] = array('default' => array());
+    return $options;
+  }
+
+  /**
+   * Extend the options form a bit.
+   */
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+    unset($form['break_phrase']);
+
+    $index = search_api_index_load(substr($this->table, 17));
+    if (!empty($index->options['fields'])) {
+      $fields = array();
+      foreach ($index->getFields() as $key => $field) {
+        $fields[$key] = $field['name'];
+      }
+    }
+    if (!empty($fields)) {
+      $form['fields'] = array(
+        '#type' => 'select',
+        '#title' => t('Fields for Similarity'),
+        '#description' => t('Select the fields that will be used for finding similar content. If no fields are selected, all available fields will be used.'),
+        '#options' => $fields,
+        '#size' => min(8, count($fields)),
+        '#multiple' => TRUE,
+        '#default_value' => $this->options['fields'],
+      );
+    }
+    else {
+      $form['fields'] = array(
+        '#type' => 'value',
+        '#value' => array(),
+      );
+    }
+  }
+
+  /**
+   * Set up the query for this argument.
+   *
+   * 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;
+      }
+    }
+    $mlt = array(
+      'id' => $this->argument,
+      'fields' => $fields,
+    );
+    $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
+  }
+}

+ 17 - 0
contrib/search_api_views/includes/handler_argument_text.inc

@@ -0,0 +1,17 @@
+<?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));
+  }
+
+}

+ 105 - 0
contrib/search_api_views/includes/handler_filter.inc

@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * Views filter handler base class for handling all "normal" cases.
+ */
+class SearchApiViewsHandlerFilter extends views_handler_filter {
+
+  /**
+   * The value to filter for.
+   *
+   * @var mixed
+   */
+  public $value;
+
+  /**
+   * The operator used for filtering.
+   *
+   * @var string
+   */
+  public $operator;
+
+  /**
+   * The associated views query object.
+   *
+   * @var SearchApiViewsQuery
+   */
+  public $query;
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array(
+      '<' => t('Is smaller than'),
+      '<=' => t('Is smaller than or equal to'),
+      '=' => t('Is equal to'),
+      '<>' => t('Is not equal to'),
+      '>=' => t('Is greater than or equal to'),
+      '>' => t('Is greater than'),
+      'empty' => t('Is empty'),
+      'not empty' => t('Is not empty'),
+    );
+  }
+
+  /**
+   * 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;
+    }
+    $form['value'] = array(
+      '#type' => 'textfield',
+      '#title' => empty($form_state['exposed']) ? t('Value') : '',
+      '#size' => 30,
+      '#default_value' => isset($this->value) ? $this->value : '',
+    );
+
+    // 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'),
+    );
+  }
+
+  /**
+   * Display the filter on the administrative summary
+   */
+  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');
+    }
+
+    return check_plain((string) $this->operator) . ' ' . check_plain((string) $this->value);
+  }
+
+  /**
+   * Add this filter to the query.
+   */
+  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']);
+    }
+    else {
+      while (is_array($this->value)) {
+        $this->value = $this->value ? reset($this->value) : NULL;
+      }
+      if (strlen($this->value) > 0) {
+        $this->query->condition($this->real_field, $this->value, $this->operator, $this->options['group']);
+      }
+    }
+  }
+
+}

+ 30 - 0
contrib/search_api_views/includes/handler_filter_boolean.inc

@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Views filter handler class for handling fulltext fields.
+ */
+class SearchApiViewsHandlerFilterBoolean extends SearchApiViewsHandlerFilter {
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array();
+  }
+
+  /**
+   * 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;
+    }
+    $form['value'] = array(
+      '#type' => 'select',
+      '#title' => empty($form_state['exposed']) ? t('Value') : '',
+      '#options' => array(1 => t('True'), 0 => t('False')),
+      '#default_value' => isset($this->value) ? $this->value : '',
+    );
+  }
+
+}

+ 86 - 0
contrib/search_api_views/includes/handler_filter_date.inc

@@ -0,0 +1,86 @@
+<?php
+
+/**
+ * Views filter handler base class for handling all "normal" cases.
+ */
+class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
+
+  /**
+   * Add a "widget type" option.
+   */
+  public function option_definition() {
+    return parent::option_definition() + array(
+      'widget_type' => array('default' => 'default'),
+    );
+  }
+
+  /**
+   * If the date popup module is enabled, provide the extra option setting.
+   */
+  public function has_extra_options() {
+    if (module_exists('date_popup')) {
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Add extra options if we allow the date popup widget.
+   */
+  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');
+      $form['widget_type'] = array(
+        '#type' => 'radios',
+        '#title' => t('Date selection form element'),
+        '#default_value' => $this->options['widget_type'],
+        '#options' => $widget_options,
+      );
+    }
+  }
+
+  /**
+   * Provide a form for setting the filter value.
+   */
+  public function value_form(&$form, &$form_state) {
+    parent::value_form($form, $form_state);
+
+    // 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')) {
+      $form['value']['#type'] = 'date_popup';
+      $form['value']['#date_format'] = 'm/d/Y';
+      unset($form['value']['#description']);
+    }
+    elseif (empty($form_state['exposed'])) {
+      $form['value']['#description'] = t('A date in any format understood by <a href="@doc-link">PHP</a>. For example, "@date1" or "@date2".', array(
+        '@doc-link' => 'http://php.net/manual/en/function.strtotime.php',
+        '@date1' => format_date(REQUEST_TIME, 'custom', 'Y-m-d H:i:s'),
+        '@date2' => 'now + 1 day',
+      ));
+    }
+  }
+
+  /**
+   * Add this filter to the query.
+   */
+  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']);
+    }
+    else {
+      while (is_array($this->value)) {
+        $this->value = $this->value ? reset($this->value) : NULL;
+      }
+      $v = is_numeric($this->value) ? $this->value : strtotime($this->value, REQUEST_TIME);
+      if ($v !== FALSE) {
+        $this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
+      }
+    }
+  }
+
+}

+ 131 - 0
contrib/search_api_views/includes/handler_filter_fulltext.inc

@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * Views filter handler class for handling fulltext fields.
+ */
+class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterText {
+
+  /**
+   * Specify the options this filter uses.
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+
+    $options['mode'] = array('default' => 'keys');
+    $options['fields'] = array('default' => array());
+
+    return $options;
+  }
+
+  /**
+   * Extend the options form a bit.
+   */
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+
+    $form['mode'] = array(
+      '#title' => t('Use as'),
+      '#type' => 'radios',
+      '#options' => array(
+        'keys' => t('Search keys – multiple words will be split and the filter will influence relevance.'),
+        'filter' => t("Search filter – use as a single phrase that restricts the result set but doesn't influence relevance."),
+      ),
+      '#default_value' => $this->options['mode'],
+    );
+
+    $fields = $this->getFulltextFields();
+    if (!empty($fields)) {
+      $form['fields'] = array(
+        '#type' => 'select',
+        '#title' => t('Searched fields'),
+        '#description' => t('Select the fields that will be searched. If no fields are selected, all available fulltext fields will be searched.'),
+        '#options' => $fields,
+        '#size' => min(4, count($fields)),
+        '#multiple' => TRUE,
+        '#default_value' => $this->options['fields'],
+      );
+    }
+    else {
+      $form['fields'] = array(
+        '#type' => 'value',
+        '#value' => array(),
+      );
+    }
+    if (isset($form['expose'])) {
+      $form['expose']['#weight'] = -5;
+    }
+  }
+
+  /**
+   * Add this filter to the query.
+   */
+  public function query() {
+    while (is_array($this->value)) {
+      $this->value = $this->value ? reset($this->value) : '';
+    }
+    // Catch empty strings entered by the user, but not "0".
+    if ($this->value === '') {
+      return;
+    }
+    $fields = $this->options['fields'];
+    $fields = $fields ? $fields : array_keys($this->getFulltextFields());
+
+    // If something already specifically set different fields, we silently fall
+    // back to mere filtering.
+    $filter = $this->options['mode'] == 'filter';
+    if (!$filter) {
+      $old = $this->query->getFields();
+      $filter = $old && (array_diff($old, $fields) || array_diff($fields, $old));
+    }
+
+    if ($filter) {
+      $filter = $this->query->createFilter('OR');
+      foreach ($fields as $field) {
+        $filter->condition($field, $this->value, $this->operator);
+      }
+      $this->query->filter($filter);
+      return;
+    }
+
+    $this->query->fields($fields);
+    $old = $this->query->getOriginalKeys();
+    $this->query->keys($this->value);
+    if ($this->operator != '=') {
+      $keys = &$this->query->getKeys();
+      if (is_array($keys)) {
+        $keys['#negation'] = TRUE;
+      }
+      else {
+        // We can't know how negation is expressed in the server's syntax.
+      }
+    }
+    if ($old) {
+      $keys = &$this->query->getKeys();
+      if (is_array($keys)) {
+        $keys[] = $old;
+      }
+      elseif (is_array($old)) {
+        // We don't support such nonsense.
+      }
+      else {
+        $keys = "($old) ($keys)";
+      }
+    }
+  }
+
+  /**
+   * Helper method to get an option list of all available fulltext fields.
+   */
+  protected function getFulltextFields() {
+    $fields = array();
+    $index = search_api_index_load(substr($this->table, 17));
+    if (!empty($index->options['fields'])) {
+      $f = $index->getFields();
+      foreach ($index->getFulltextFields() as $name) {
+        $fields[$name] = $f[$name]['name'];
+      }
+    }
+    return $fields;
+  }
+
+}

+ 55 - 0
contrib/search_api_views/includes/handler_filter_language.inc

@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @file
+ *   Contains the SearchApiViewsHandlerFilterLanguage class.
+ */
+
+/**
+ * Views filter handler class for handling the special "Item language" field.
+ *
+ * Definition items:
+ * - options: An array of possible values for this field.
+ */
+class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOptions {
+
+  /**
+   * Provide a form for setting options.
+   */
+  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;
+  }
+
+  /**
+   * Add this filter to the query.
+   */
+  public function query() {
+    global $language_content;
+    foreach ($this->value as $i => $v) {
+      if ($v == 'current') {
+        $this->value[$i] = $language_content->language;
+      }
+      elseif ($v == 'default') {
+        $this->value[$i] = language_default('language');
+      }
+    }
+    parent::query();
+  }
+
+}

+ 205 - 0
contrib/search_api_views/includes/handler_filter_options.inc

@@ -0,0 +1,205 @@
+<?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.
+ */
+class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
+
+  protected $value_form_type = 'checkboxes';
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array(
+      '=' => t('Is one of'),
+      '<>' => t('Is not one of'),
+      'empty' => t('Is empty'),
+      'not empty' => t('Is not empty'),
+    );
+  }
+
+  /**
+   * Set "reduce" option to FALSE by default.
+   */
+  public function expose_options() {
+    parent::expose_options();
+    $this->options['expose']['reduce'] = FALSE;
+  }
+
+  /**
+   * Add the "reduce" option to the exposed form.
+   */
+  public function expose_form(&$form, &$form_state) {
+    parent::expose_form($form, $form_state);
+    $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' => !empty($this->options['expose']['reduce']),
+    );
+  }
+
+  /**
+   * Define "reduce" option.
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    $options['expose']['contains']['reduce'] = array('default' => FALSE);
+    return $options;
+  }
+
+  /**
+   * 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;
+      }
+    }
+    return $options;
+  }
+
+  /**
+   * Save set checkboxes.
+   */
+  public function value_submit($form, &$form_state) {
+    // Drupal's FAPI system automatically puts '0' in for any checkbox that
+    // was not set, and the key to the checkbox if it is set.
+    // Unfortunately, this means that if the key to that checkbox is 0,
+    // we are unable to tell if that checkbox was set or not.
+
+    // Luckily, the '#value' on the checkboxes form actually contains
+    // *only* a list of checkboxes that were set, and we can use that
+    // instead.
+
+    $form_state['values']['options']['value'] = $form['value']['#value'];
+  }
+
+  /**
+   * Provide a form for setting options.
+   */
+  public function value_form(&$form, &$form_state) {
+    $options = array();
+    if (!empty($this->options['expose']['reduce']) && !empty($form_state['exposed'])) {
+      $options += $this->reduce_value_options($form_state);
+    }
+    else {
+      $options += $this->definition['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(),
+    );
+
+    // Hide the value box if 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'),
+    );
+  }
+
+  /**
+   * Provides a summary of this filter's value for the admin UI.
+   */
+  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 (!is_array($this->value)) {
+      return;
+    }
+
+    $operator_options = $this->operator_options();
+    $operator = $operator_options[$this->operator];
+    $values = '';
+
+    // Remove every element which is not known.
+    foreach ($this->value as $i => $value) {
+      if (!isset($this->definition['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');
+    }
+    elseif (count($this->value) == 1) {
+      // 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)]);
+    }
+    else {
+      foreach ($this->value as $value) {
+        if ($values !== '') {
+          $values .= ', ';
+        }
+        if (drupal_strlen($values) > 20) {
+          $values .= '…';
+          break;
+        }
+        $values .= check_plain($this->definition['options'][$value]);
+      }
+    }
+
+    return $operator . (($values !== '') ? ' ' . $values : '');
+  }
+
+  /**
+   * Add this filter to the query.
+   */
+  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']);
+    }
+    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']);
+      }
+      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']);
+          }
+        }
+      }
+    }
+  }
+
+}

+ 15 - 0
contrib/search_api_views/includes/handler_filter_text.inc

@@ -0,0 +1,15 @@
+<?php
+
+/**
+ * Views filter handler class for handling fulltext fields.
+ */
+class SearchApiViewsHandlerFilterText extends SearchApiViewsHandlerFilter {
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array('=' => t('contains'), '<>' => t("doesn't contain"));
+  }
+
+}

+ 30 - 0
contrib/search_api_views/includes/handler_sort.inc

@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Class for sorting results according to a specified field.
+ */
+class SearchApiViewsHandlerSort extends views_handler_sort {
+
+  /**
+   * The associated views query object.
+   *
+   * @var SearchApiViewsQuery
+   */
+  public $query;
+
+  /**
+   * Called to add the sort to a query.
+   */
+  public function query() {
+    // When there are exposed sorts, the "exposed form" plugin will set
+    // $query->orderby to an empty array. Therefore, if that property is set,
+    // we here remove all previous sorts.
+    if (isset($this->query->orderby)) {
+      unset($this->query->orderby);
+      $sort = &$this->query->getSort();
+      $sort = array();
+    }
+    $this->query->sort($this->real_field, $this->options['order']);
+  }
+
+}

+ 564 - 0
contrib/search_api_views/includes/query.inc

@@ -0,0 +1,564 @@
+<?php
+
+/**
+ * Views query class using a Search API index as the data source.
+ */
+class SearchApiViewsQuery extends views_plugin_query {
+
+  /**
+   * Number of results to display.
+   *
+   * @var int
+   */
+  protected $limit;
+
+  /**
+   * Offset of first displayed result.
+   *
+   * @var int
+   */
+  protected $offset;
+
+  /**
+   * The index this view accesses.
+   *
+   * @var SearchApiIndex
+   */
+  protected $index;
+
+  /**
+   * The query that will be executed.
+   *
+   * @var SearchApiQueryInterface
+   */
+  protected $query;
+
+  /**
+   * The results returned by the query, after it was executed.
+   *
+   * @var array
+   */
+  protected $search_api_results = array();
+
+  /**
+   * Array of all encountered errors.
+   *
+   * Each of these is fatal, meaning that a non-empty $errors property will
+   * result in an empty result being returned.
+   *
+   * @var array
+   */
+  protected $errors;
+
+  /**
+   * The names of all fields whose value is required by a handler.
+   *
+   * The format follows the same as Search API field identifiers (parent:child).
+   *
+   * @var array
+   */
+  protected $fields;
+
+  /**
+   * The query's sub-filters representing the different Views filter groups.
+   *
+   * @var array
+   */
+  protected $filters = array();
+
+  /**
+   * The conjunction with which multiple filter groups are combined.
+   *
+   * @var string
+   */
+  public $group_operator = 'AND';
+
+  /**
+   * Create the basic query object and fill with default values.
+   */
+  public function init($base_table, $base_field, $options) {
+    try {
+      $this->errors = array();
+      parent::init($base_table, $base_field, $options);
+      $this->fields = array();
+      if (substr($base_table, 0, 17) == 'search_api_index_') {
+        $id = substr($base_table, 17);
+        $this->index = search_api_index_load($id);
+        $this->query = $this->index->query(array(
+          'parse mode' => 'terms',
+        ));
+      }
+    }
+    catch (Exception $e) {
+      $this->errors[] = $e->getMessage();
+    }
+  }
+
+  /**
+   * Add a field that should be retrieved from the results by this view.
+   *
+   * @param $field
+   *   The field's identifier, as used by the Search API. E.g., "title" for a
+   *   node's title, "author:name" for a node's author's name.
+   *
+   * @return SearchApiViewsQuery
+   *   The called object.
+   */
+  public function addField($field) {
+    $this->fields[$field] = TRUE;
+    return $field;
+  }
+
+  /**
+   * Add a sort to the query.
+   *
+   * @param $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
+   *   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);
+  }
+
+  /**
+   * Defines the options used by this query plugin.
+   *
+   * Adds an option to bypass access checks.
+   */
+  public function option_definition() {
+    return parent::option_definition() + array(
+      'search_api_bypass_access' => array(
+        'default' => FALSE,
+      ),
+    );
+  }
+
+  /**
+   * Add settings for the UI.
+   *
+   * Adds an option for bypassing access checks.
+   */
+  public function options_form(&$form, &$form_state) {
+    parent::options_form($form, $form_state);
+
+    $form['search_api_bypass_access'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Bypass access checks'),
+      '#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'],
+    );
+  }
+
+  /**
+   * Builds the necessary info to execute the query.
+   */
+  public function build(&$view) {
+    $this->view = $view;
+
+    // Setup the nested filter structure for this query.
+    if (!empty($this->where)) {
+      // If the different groups are combined with the OR operator, we have to
+      // add a new OR filter to the query to which the filters for the groups
+      // will be added.
+      if ($this->group_operator === 'OR') {
+        $base = $this->query->createFilter('OR');
+        $this->query->filter($base);
+      }
+      else {
+        $base = $this->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'])) {
+          // 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']);
+          if (!empty($group['conditions'])) {
+            foreach ($group['conditions'] as $condition) {
+              list($field, $value, $operator) = $condition;
+              $filter->condition($field, $value, $operator);
+            }
+          }
+          if (!empty($group['filters'])) {
+            foreach ($group['filters'] as $nested_filter) {
+              $filter->filter($nested_filter);
+            }
+          }
+          // If no group was given, the filters were already set on the query.
+          if ($group_id !== '') {
+            $base->filter($filter);
+          }
+        }
+      }
+    }
+
+    // Initialize the pager and let it modify the query to add limits.
+    $view->init_pager();
+    $this->pager->query();
+
+    // 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);
+    }
+  }
+
+  /**
+   * Executes the query and fills the associated view object with according
+   * values.
+   *
+   * Values to set: $view->result, $view->total_rows, $view->execute_time,
+   * $view->pager['current_page'].
+   */
+  public function execute(&$view) {
+    if ($this->errors) {
+      if (error_displayable()) {
+        foreach ($this->errors as $msg) {
+          drupal_set_message(check_plain($msg), 'error');
+        }
+      }
+      $view->result = array();
+      $view->total_rows = 0;
+      $view->execute_time = 0;
+      return;
+    }
+
+    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);
+      }
+
+      // 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'];
+      }
+      $this->pager->update_page_info();
+      $view->result = array();
+      if (!empty($results['results'])) {
+        $this->addResults($results['results'], $view);
+      }
+      // We shouldn't use $results['performance']['complete'] here, since
+      // extracting the results probably takes considerable time as well.
+      $view->execute_time = microtime(TRUE) - $start;
+    }
+    catch (Exception $e) {
+      $this->errors[] = $e->getMessage();
+      // Recursion to get the same error behaviour as above.
+      return $this->execute($view);
+    }
+  }
+
+  /**
+   * Helper function for adding results to a view in the format expected by the
+   * view.
+   */
+  protected function addResults(array $results, $view) {
+    $rows = array();
+    $missing = array();
+    $items = array();
+
+    // First off, we try to gather as much field values as possible without
+    // loading any items.
+    foreach ($results as $id => $result) {
+      $row = array();
+
+      // Include the loaded item for this result row, if present, or the item
+      // ID.
+      if (!empty($result['entity'])) {
+        $row['entity'] = $result['entity'];
+      }
+      else {
+        $row['entity'] = $id;
+      }
+
+      $row['_entity_properties']['search_api_relevance'] = $result['score'];
+      $row['_entity_properties']['search_api_excerpt'] = empty($result['excerpt']) ? '' : $result['excerpt'];
+
+      // Gather any fields from the search results.
+      if (!empty($result['fields'])) {
+        $row['_entity_properties'] += $result['fields'];
+      }
+
+      // Check whether we need to extract any properties from the result item.
+      $missing_fields = array_diff_key($this->fields, $row);
+      if ($missing_fields) {
+        $missing[$id] = $missing_fields;
+        if (is_object($row['entity'])) {
+          $items[$id] = $row['entity'];
+        }
+        else {
+          $ids[] = $id;
+        }
+      }
+
+      // Save the row values for adding them to the Views result afterwards.
+      $rows[$id] = (object) $row;
+    }
+
+    // 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;
+      }
+    }
+
+    // Finally, add all rows to the Views result set.
+    $view->result = array_values($rows);
+  }
+
+  /**
+   * Helper function for extracting all necessary fields from a result item.
+   *
+   * Usually, this method isn't needed anymore as the properties are now
+   * extracted by the field handlers themselves.
+   */
+  protected function extractFields(EntityMetadataWrapper $wrapper, array $all_fields) {
+    $fields = array();
+    foreach ($all_fields as $key => $true) {
+      $fields[$key]['type'] = 'string';
+    }
+    $fields = search_api_extract_fields($wrapper, $fields, array('sanitized' => TRUE));
+    $ret = array();
+    foreach ($all_fields as $key => $true) {
+      $ret[$key] = isset($fields[$key]['value']) ? $fields[$key]['value'] : '';
+    }
+    return $ret;
+  }
+
+  /**
+   * Returns the according entity objects for the given query results.
+   *
+   * This is necessary to support generic entity handlers and plugins with this
+   * query backend.
+   *
+   * If the current query isn't based on an entity type, the method will return
+   * an empty array.
+   */
+  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) {
+      try {
+        $return[$id] = $wrapper->value();
+      }
+      catch (EntityMetadataWrapperException $e) {
+        // Ignore.
+      }
+    }
+    return array($type, $return);
+  }
+
+  /**
+   * Returns the according metadata wrappers for the given query results.
+   *
+   * This is necessary to support generic entity handlers and plugins with this
+   * query backend.
+   */
+  public function get_result_wrappers($results, $relationship = NULL, $field = NULL) {
+    $is_entity = (boolean) entity_get_info($this->index->item_type);
+    $wrappers = array();
+    $load_entities = array();
+    foreach ($results as $row_index => $row) {
+      if ($is_entity && 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;
+        }
+
+        $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);
+      }
+    }
+
+    // Apply the relationship, if necessary.
+    $type = $this->index->item_type;
+    $selector_suffix = '';
+    if ($field && ($pos = strrpos($field, ':'))) {
+      $selector_suffix = substr($field, 0, $pos);
+    }
+    if ($selector_suffix || ($relationship && !empty($this->view->relationship[$relationship]))) {
+      // Use EntityFieldHandlerHelper to compute the correct data selector for
+      // the relationship.
+      $handler = (object) array(
+        'view' => $this->view,
+        'relationship' => $relationship,
+        'real_field' => '',
+      );
+      $selector = EntityFieldHandlerHelper::construct_property_selector($handler);
+      $selector .= ($selector ? ':' : '') . $selector_suffix;
+      list($type, $wrappers) = EntityFieldHandlerHelper::extract_property_multiple($wrappers, $selector);
+    }
+
+    return array($type, $wrappers);
+  }
+
+  /**
+   * API function for accessing the raw Search API query object.
+   *
+   * @return SearchApiQueryInterface
+   *   The search query object used internally by this handler.
+   */
+  public function getSearchApiQuery() {
+    return $this->query;
+  }
+
+  /**
+   * API function for accessing the raw Search API results.
+   *
+   * @return array
+   *   An associative array containing the search results, as specified by
+   *   SearchApiQueryInterface::execute().
+   */
+  public function getSearchApiResults() {
+    return $this->search_api_results;
+  }
+
+  //
+  // Query interface methods (proxy to $this->query)
+  //
+
+  public function createFilter($conjunction = 'AND') {
+    if (!$this->errors) {
+      return $this->query->createFilter($conjunction);
+    }
+  }
+
+  public function keys($keys = NULL) {
+    if (!$this->errors) {
+      $this->query->keys($keys);
+    }
+    return $this;
+  }
+
+  public function fields(array $fields) {
+    if (!$this->errors) {
+      $this->query->fields($fields);
+    }
+    return $this;
+  }
+
+  /**
+   * Adds a nested filter to the search query object.
+   *
+   * If $group is given, the filter is added to the relevant filter group
+   * instead.
+   */
+  public function filter(SearchApiQueryFilterInterface $filter, $group = NULL) {
+    if (!$this->errors) {
+      $this->where[$group]['filters'][] = $filter;
+    }
+    return $this;
+  }
+
+  /**
+   * Set a condition on the search query object.
+   *
+   * If $group is given, the condition is added to the relevant filter group
+   * instead.
+   */
+  public function condition($field, $value, $operator = '=', $group = NULL) {
+    if (!$this->errors) {
+      $this->where[$group]['conditions'][] = array($field, $value, $operator);
+    }
+    return $this;
+  }
+
+  public function sort($field, $order = 'ASC') {
+    if (!$this->errors) {
+      $this->query->sort($field, $order);
+    }
+    return $this;
+  }
+
+  public function range($offset = NULL, $limit = NULL) {
+    if (!$this->errors) {
+      $this->query->range($offset, $limit);
+    }
+    return $this;
+  }
+
+  public function getIndex() {
+    return $this->index;
+  }
+
+  public function &getKeys() {
+    if (!$this->errors) {
+      return $this->query->getKeys();
+    }
+    $ret = NULL;
+    return $ret;
+  }
+
+  public function getOriginalKeys() {
+    if (!$this->errors) {
+      return $this->query->getOriginalKeys();
+    }
+  }
+
+  public function &getFields() {
+    if (!$this->errors) {
+      return $this->query->getFields();
+    }
+    $ret = NULL;
+    return $ret;
+  }
+
+  public function getFilter() {
+    if (!$this->errors) {
+      return $this->query->getFilter();
+    }
+  }
+
+  public function &getSort() {
+    if (!$this->errors) {
+      return $this->query->getSort();
+    }
+    $ret = NULL;
+    return $ret;
+  }
+
+  public function getOption($name) {
+    if (!$this->errors) {
+      return $this->query->getOption($name);
+    }
+  }
+
+  public function setOption($name, $value) {
+    if (!$this->errors) {
+      return $this->query->setOption($name, $value);
+    }
+  }
+
+  public function &getOptions() {
+    if (!$this->errors) {
+      return $this->query->getOptions();
+    }
+    $ret = NULL;
+    return $ret;
+  }
+
+}

+ 30 - 0
contrib/search_api_views/search_api_views.info

@@ -0,0 +1,30 @@
+
+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
+core = 7.x
+package = Search
+
+; Views handlers
+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_filter.inc
+files[] = includes/handler_filter_boolean.inc
+files[] = includes/handler_filter_date.inc
+files[] = includes/handler_filter_fulltext.inc
+files[] = includes/handler_filter_language.inc
+files[] = includes/handler_filter_options.inc
+files[] = includes/handler_filter_text.inc
+files[] = includes/handler_sort.inc
+files[] = includes/query.inc
+
+; Information added by drupal.org packaging script on 2013-01-09
+version = "7.x-1.4"
+core = "7.x"
+project = "search_api"
+datestamp = "1357726719"
+

+ 97 - 0
contrib/search_api_views/search_api_views.install

@@ -0,0 +1,97 @@
+<?php
+/**
+ * @file
+ * Install, update and uninstall functions for the search_api_views module.
+ */
+
+/**
+ * Updates all Search API views to use the new, specification-compliant identifiers.
+ */
+function search_api_views_update_7101() {
+  $tables = views_fetch_data();
+  // Contains arrays with real fields mapped to field IDs for each table.
+  $table_fields = array();
+  foreach ($tables as $key => $table) {
+    if (substr($key, 0, 17) != 'search_api_index_') {
+      continue;
+    }
+    foreach ($table as $field => $info) {
+      if (isset($info['real field']) && $field != $info['real field']) {
+        $table_fields[$key][$info['real field']] = $field;
+      }
+    }
+  }
+  if (!$table_fields) {
+    return;
+  }
+  foreach (views_get_all_views() as $name => $view) {
+    if (empty($view->base_table) || empty($table_fields[$view->base_table])) {
+      continue;
+    }
+    $change = FALSE;
+    $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) {
+        $options = &$display->display_options;
+        if (isset($options['style_options']['grouping'])) {
+          $change |= _search_api_views_update_7101_helper($options['style_options']['grouping'], $fields);
+        }
+        if (isset($options['style_options']['columns'])) {
+          $change |= _search_api_views_update_7101_helper($options['style_options']['columns'], $fields);
+        }
+        if (isset($options['style_options']['info'])) {
+          $change |= _search_api_views_update_7101_helper($options['style_options']['info'], $fields);
+        }
+        if (isset($options['arguments'])) {
+          $change |= _search_api_views_update_7101_helper($options['arguments'], $fields);
+        }
+        if (isset($options['fields'])) {
+          $change |= _search_api_views_update_7101_helper($options['fields'], $fields);
+        }
+        if (isset($options['filters'])) {
+          $change |= _search_api_views_update_7101_helper($options['filters'], $fields);
+        }
+        if (isset($options['sorts'])) {
+          $change |= _search_api_views_update_7101_helper($options['sorts'], $fields);
+        }
+      }
+    }
+    if ($change) {
+      $view->save();
+    }
+  }
+}
+
+/**
+ * Helper function for replacing field identifiers.
+ *
+ * @return
+ *   TRUE iff the identifier was changed.
+ */
+function _search_api_views_update_7101_helper(&$field, array $fields) {
+  if (is_array($field)) {
+    $change = FALSE;
+    $new_field = array();
+    foreach ($field as $k => $v) {
+      $new_k = $k;
+      $change |= _search_api_views_update_7101_helper($new_k, $fields);
+      $change |= _search_api_views_update_7101_helper($v, $fields);
+      $new_field[$new_k] = $v;
+    }
+    $field = $new_field;
+    return $change;
+  }
+  if (isset($fields[$field])) {
+    $field = $fields[$field];
+    return TRUE;
+  }
+  return FALSE;
+}
+
+/**
+ * Delete the now unnecessary "search_api_views_max_fields_depth" variable.
+ */
+function search_api_views_update_7102() {
+  variable_del('search_api_views_max_fields_depth');
+}

+ 52 - 0
contrib/search_api_views/search_api_views.module

@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * Implements hook_views_api().
+ */
+function search_api_views_views_api() {
+  return array(
+    'api' => '3.0-alpha1',
+  );
+}
+
+/**
+ * Implements hook_search_api_index_insert().
+ */
+function search_api_views_search_api_index_insert(SearchApiIndex $index) {
+  // Make the new index available for views.
+  views_invalidate_cache();
+}
+
+/**
+ * Implements hook_search_api_index_update().
+ */
+function search_api_views_search_api_index_update(SearchApiIndex $index) {
+  if (!$index->enabled && $index->original->enabled) {
+    _search_api_views_index_unavailable($index);
+  }
+}
+
+/**
+ * Implements hook_search_api_index_delete().
+ */
+function search_api_views_search_api_index_delete(SearchApiIndex $index) {
+  _search_api_views_index_unavailable($index);
+}
+
+/**
+ * Function for reacting to a disabled or deleted search index.
+ */
+function _search_api_views_index_unavailable(SearchApiIndex $index) {
+  $names = array();
+  $table = 'search_api_index_' . $index->machine_name;
+  foreach (views_get_all_views() as $name => $view) {
+    if (empty($view->disabled) && $view->base_table == $table) {
+      $names[] = $name;
+      // @todo: if ($index_deleted) $view->delete()?
+    }
+  }
+  if ($names) {
+    views_invalidate_cache();
+    drupal_set_message(t('The following views were using the index %name: @views. You should disable or delete them.', array('%name' => $index->name, '@views' => implode(', ', $names))), 'warning');
+  }
+}

+ 196 - 0
contrib/search_api_views/search_api_views.views.inc

@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * 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;
+      $table = &$data[$key];
+      $type_info = search_api_get_item_type_info($index->item_type);
+      $table['table']['group'] = t('Indexed @entity_type', array('@entity_type' => $type_info['name']));
+      $table['table']['base'] = array(
+        'field' => 'search_api_id',
+        'index' => $index->machine_name,
+        'title' => $index->name,
+        '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,
+        );
+      }
+
+      $wrapper = $index->entityWrapper(NULL, TRUE);
+
+      // Add field handlers and relationships provided by the Entity API.
+      foreach ($wrapper as $key => $property) {
+        $info = $property->info();
+        if ($info) {
+          entity_views_field_definition($key, $info, $table);
+        }
+      }
+
+      // Add handlers for all indexed fields.
+      foreach ($index->getFields() as $key => $field) {
+        $tmp = $wrapper;
+        $group = '';
+        $name = '';
+        $parts = explode(':', $key);
+        foreach ($parts as $i => $part) {
+          if (!isset($tmp->$part)) {
+            continue 2;
+          }
+          $tmp = $tmp->$part;
+          $info = $tmp->info();
+          $group = ($group ? $group . ' » ' . $name : ($name ? $name : ''));
+          $name = $info['label'];
+          if ($i < count($parts) - 1) {
+            // Unwrap lists.
+            $level = search_api_list_nesting_level($info['type']);
+            for ($j = 0; $j < $level; ++$j) {
+              $tmp = $tmp[0];
+            }
+          }
+        }
+        $id = _entity_views_field_identifier($key, $table);
+        if ($group) {
+          // @todo Entity type label instead of $group?
+          $table[$id]['group'] = $group;
+          $name = t('@field (indexed)', array('@field' => $name));
+        }
+        $table[$id]['title'] = $name;
+        $table[$id]['help'] = empty($info['description']) ? t('(No information available)') : $info['description'];
+        $table[$id]['type'] = $field['type'];
+        if ($id != $key) {
+          $table[$id]['real field'] = $key;
+        }
+        _search_api_views_add_handlers($key, $field, $tmp, $table);
+      }
+
+      // Special handlers
+      $table['search_api_language']['filter']['handler'] = 'SearchApiViewsHandlerFilterLanguage';
+
+      $table['search_api_id']['title'] = t('Entity ID');
+      $table['search_api_id']['help'] = t("The entity's ID.");
+      $table['search_api_id']['sort']['handler'] = 'SearchApiViewsHandlerSort';
+
+      $table['search_api_relevance']['group'] = t('Search');
+      $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']['handler'] = 'entity_views_handler_field_numeric';
+      $table['search_api_relevance']['field']['click sortable'] = TRUE;
+      $table['search_api_relevance']['sort']['handler'] = 'SearchApiViewsHandlerSort';
+
+      $table['search_api_excerpt']['group'] = t('Search');
+      $table['search_api_excerpt']['title'] = t('Excerpt');
+      $table['search_api_excerpt']['help'] = t('The search result excerpted to show found search terms.');
+      $table['search_api_excerpt']['field']['type'] = 'text';
+      $table['search_api_excerpt']['field']['handler'] = 'entity_views_handler_field_text';
+
+      $table['search_api_views_fulltext']['group'] = t('Search');
+      $table['search_api_views_fulltext']['title'] = t('Fulltext search');
+      $table['search_api_views_fulltext']['help'] = t('Search several or all fulltext fields at once.');
+      $table['search_api_views_fulltext']['filter']['handler'] = 'SearchApiViewsHandlerFilterFulltext';
+      $table['search_api_views_fulltext']['argument']['handler'] = 'SearchApiViewsHandlerArgumentFulltext';
+
+      $table['search_api_views_more_like_this']['group'] = t('Search');
+      $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';
+    }
+    return $data;
+  }
+  catch (Exception $e) {
+    watchdog_exception('search_api_views', $e);
+  }
+}
+
+/**
+ * Helper function that returns an array of handler definitions to add to a
+ * views field definition.
+ */
+function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper $wrapper, array &$table) {
+  $type = $field['type'];
+  $inner_type = search_api_extract_inner_type($type);
+
+  if (strpos($id, ':')) {
+    entity_views_field_definition($id, $wrapper->info(), $table);
+  }
+  $id = _entity_views_field_identifier($id, $table);
+  $table += array($id => array());
+
+  if ($inner_type == 'text') {
+    $table[$id] += array(
+      'argument' => array(
+        'handler' => 'SearchApiViewsHandlerArgumentText',
+      ),
+      'filter' => array(
+        'handler' => 'SearchApiViewsHandlerFilterText',
+      ),
+    );
+    return;
+  }
+
+  if ($options = $wrapper->optionsList('view')) {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
+    $table[$id]['filter']['options'] = $options;
+  }
+  elseif ($inner_type == 'boolean') {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterBoolean';
+  }
+  elseif ($inner_type == 'date') {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterDate';
+  }
+  else {
+    $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter';
+  }
+
+  $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+
+  // We can only sort according to single-valued fields.
+  if ($type == $inner_type) {
+    $table[$id]['sort']['handler'] = 'SearchApiViewsHandlerSort';
+    if (isset($table[$id]['field'])) {
+      $table[$id]['field']['click sortable'] = TRUE;
+    }
+  }
+}
+
+/**
+ * Implements hook_views_plugins().
+ */
+function search_api_views_views_plugins() {
+  $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'
+      ),
+    ),
+  );
+
+  if (module_exists('search_api_facetapi')) {
+    $ret['display']['search_api_views_facets_block'] = array(
+      'title' => t('Facets block'),
+      'help' => t('Display facets for this search as a block anywhere on the site.'),
+      'handler' => 'SearchApiViewsFacetsBlockDisplay',
+      'uses hook block' => TRUE,
+      'use ajax' => FALSE,
+      'use pager' => FALSE,
+      'use more' => TRUE,
+      'accept attachments' => TRUE,
+      'admin' => t('Facets block'),
+    );
+  }
+
+  return $ret;
+}

BIN
disabled.png


BIN
enabled.png


+ 220 - 0
includes/callback.inc

@@ -0,0 +1,220 @@
+<?php
+
+/**
+ * Interface representing a Search API data-alter callback.
+ */
+interface SearchApiAlterCallbackInterface {
+
+  /**
+   * 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.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array());
+
+  /**
+   * 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.
+   *
+   * @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);
+
+  /**
+   * Display a form for configuring this callback.
+   *
+   * @return array
+   *   A form array for configuring this callback, or FALSE if no configuration
+   *   is possible.
+   */
+  public function configurationForm();
+
+  /**
+   * 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.
+   */
+  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.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state);
+
+  /**
+   * 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.
+   */
+  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.
+   *
+   * @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();
+
+}
+
+/**
+ * Abstract base class for data-alter callbacks, implementing 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.
+ */
+abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackInterface {
+
+  /**
+   * The index whose items will be altered.
+   *
+   * @var SearchApiIndex
+   */
+  protected $index;
+
+  /**
+   * The configuration options for this callback, if it has any.
+   *
+   * @var array
+   */
+  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.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    $this->index = $index;
+    $this->options = $options;
+  }
+
+  /**
+   * 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.
+   *
+   * 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.
+   */
+  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.
+   */
+  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.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $this->options = $values;
+    return $values;
+  }
+
+  /**
+   * 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).
+   */
+  public function propertyInfo() {
+    return array();
+  }
+
+}

+ 313 - 0
includes/callback_add_aggregation.inc

@@ -0,0 +1,313 @@
+<?php
+
+/**
+ * Search API data alteration callback that adds an URL field for all items.
+ */
+class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
+
+  public function configurationForm() {
+    $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+
+    $fields = $this->index->getFields(FALSE);
+    $field_options = array();
+    foreach ($fields as $name => $field) {
+      $field_options[$name] = $field['name'];
+    }
+    $additional = empty($this->options['fields']) ? array() : $this->options['fields'];
+
+    $types = $this->getTypes();
+    $type_descriptions = $this->getTypes('description');
+    $tmp = array();
+    foreach ($types as $type => $name) {
+      $tmp[$type] = array(
+        '#type' => 'item',
+        '#description' => $type_descriptions[$type],
+      );
+    }
+    $type_descriptions = $tmp;
+
+    $form['#id'] = 'edit-callbacks-search-api-alter-add-aggregation-settings';
+    $form['description'] = array(
+      '#markup' => t('<p>This data alteration lets you define additional fields that will be added to this index. ' .
+        'Each of these new fields will be an aggregation of one or more existing fields.</p>' .
+        '<p>To add a new aggregated field, click the "Add new field" button and then fill out the form.</p>' .
+        '<p>To remove a previously defined field, click the "Remove field" button.</p>' .
+        '<p>You can also change the names or contained fields of existing aggregated fields.</p>'),
+    );
+    $form['fields']['#prefix'] = '<div id="search-api-alter-add-aggregation-field-settings">';
+    $form['fields']['#suffix'] = '</div>';
+    if (isset($this->changes)) {
+      $form['fields']['#prefix'] .= '<div class="messages warning">All changes in the form will not be saved until the <em>Save configuration</em> button at the form bottom is clicked.</div>';
+    }
+    foreach ($additional as $name => $field) {
+      $form['fields'][$name] = array(
+        '#type' => 'fieldset',
+        '#title' => $field['name'] ? $field['name'] : t('New field'),
+        '#collapsible' => TRUE,
+        '#collapsed' => (boolean) $field['name'],
+      );
+      $form['fields'][$name]['name'] = array(
+        '#type' => 'textfield',
+        '#title' => t('New field name'),
+        '#default_value' => $field['name'],
+        '#required' => TRUE,
+      );
+      $form['fields'][$name]['type'] = array(
+        '#type' => 'select',
+        '#title' => t('Aggregation type'),
+        '#options' => $types,
+        '#default_value' => $field['type'],
+        '#required' => TRUE,
+      );
+      $form['fields'][$name]['type_descriptions'] = $type_descriptions;
+      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]['fields'] = 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(
+          '#type' => 'submit',
+          '#value' => t('Remove field'),
+          '#submit' => array('_search_api_add_aggregation_field_submit'),
+          '#limit_validation_errors' => array(),
+          '#name' => 'search_api_add_aggregation_remove_' . $name,
+          '#ajax' => array(
+            'callback' => '_search_api_add_aggregation_field_ajax',
+            'wrapper' => 'search-api-alter-add-aggregation-field-settings',
+          ),
+        ),
+      );
+    }
+    $form['actions']['#type'] = 'actions';
+    $form['actions']['add_field'] = array(
+      '#type' => 'submit',
+      '#value' => t('Add new field'),
+      '#submit' => array('_search_api_add_aggregation_field_submit'),
+      '#limit_validation_errors' => array(),
+      '#ajax' => array(
+        'callback' => '_search_api_add_aggregation_field_ajax',
+        'wrapper' => 'search-api-alter-add-aggregation-field-settings',
+      ),
+    );
+    return $form;
+  }
+
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    unset($values['actions']);
+    if (empty($values['fields'])) {
+      return;
+    }
+    foreach ($values['fields'] as $name => $field) {
+      $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
+      unset($values['fields'][$name]['actions']);
+      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.'));
+      }
+    }
+  }
+
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    if (empty($values['fields'])) {
+      return array();
+    }
+    $index_fields = $this->index->getFields(FALSE);
+    foreach ($values['fields'] as $name => $field) {
+      if (!$field['name']) {
+        unset($values['fields'][$name]);
+      }
+      else {
+        $values['fields'][$name]['description'] = $this->fieldDescription($field, $index_fields);
+      }
+    }
+    $this->options = $values;
+    return $values;
+  }
+
+  public function alterItems(array &$items) {
+    if (!$items) {
+      return;
+    }
+    if (isset($this->options['fields'])) {
+      $types = $this->getTypes('type');
+      foreach ($items as $item) {
+        $wrapper = $this->index->entityWrapper($item);
+        foreach ($this->options['fields'] as $name => $field) {
+          if ($field['name']) {
+            $required_fields = array();
+            foreach ($field['fields'] as $f) {
+              if (!isset($required_fields[$f])) {
+                $required_fields[$f]['type'] = $types[$field['type']];
+              }
+            }
+            $fields = search_api_extract_fields($wrapper, $required_fields);
+            $values = array();
+            foreach ($fields as $f) {
+              if (isset($f['value'])) {
+                $values[] = $f['value'];
+              }
+            }
+            $values = $this->flattenArray($values);
+
+            $this->reductionType = $field['type'];
+            $item->$name = array_reduce($values, array($this, 'reduce'), NULL);
+            if ($field['type'] == 'count' && !$item->$name) {
+              $item->$name = 0;
+            }
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Helper method for reducing an array to a single value.
+   */
+  public function reduce($a, $b) {
+    switch ($this->reductionType) {
+      case 'fulltext':
+        return isset($a) ? $a . "\n\n" . $b : $b;
+      case 'sum':
+        return $a + $b;
+      case 'count':
+        return $a + 1;
+      case 'max':
+        return isset($a) ? max($a, $b) : $b;
+      case 'min':
+        return isset($a) ? min($a, $b) : $b;
+      case 'first':
+        return isset($a) ? $a : $b;
+    }
+  }
+
+  /**
+   * Helper method for flattening a multi-dimensional array.
+   */
+  protected function flattenArray(array $data) {
+    $ret = array();
+    foreach ($data as $item) {
+      if (!isset($item)) {
+        continue;
+      }
+      if (is_scalar($item)) {
+        $ret[] = $item;
+      }
+      else {
+        $ret = array_merge($ret, $this->flattenArray($item));
+      }
+    }
+    return $ret;
+  }
+
+  public function propertyInfo() {
+    $types = $this->getTypes('type');
+    $ret = array();
+    if (isset($this->options['fields'])) {
+      foreach ($this->options['fields'] as $name => $field) {
+        $ret[$name] = array(
+          'label' => $field['name'],
+          'description' => empty($field['description']) ? '' : $field['description'],
+          'type' => $types[$field['type']],
+        );
+      }
+    }
+    return $ret;
+  }
+
+  /**
+   * Helper method for creating a field description.
+   */
+  protected function fieldDescription(array $field, array $index_fields) {
+    $fields = array();
+    foreach ($field['fields'] as $f) {
+      $fields[] = isset($index_fields[$f]) ? $index_fields[$f]['name'] : $f;
+    }
+    $type = $this->getTypes();
+    $type = $type[$field['type']];
+    return t('A @type aggregation of the following fields: @fields.', array('@type' => $type, '@fields' => implode(', ', $fields)));
+  }
+
+  /**
+   * 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".
+   *
+   */
+  protected function getTypes($info = 'name') {
+    switch ($info) {
+      case 'name':
+        return array(
+          'fulltext' => t('Fulltext'),
+          'sum' => t('Sum'),
+          'count' => t('Count'),
+          'max' => t('Maximum'),
+          'min' => t('Minimum'),
+          'first' => t('First'),
+        );
+      case 'type':
+        return array(
+          'fulltext' => 'text',
+          'sum' => 'integer',
+          'count' => 'integer',
+          'max' => 'integer',
+          'min' => 'integer',
+          'first' => 'string',
+        );
+      case 'description':
+        return array(
+          'fulltext' => t('The Fulltext aggregation concatenates the text data of all contained fields.'),
+          'sum' => t('The Sum aggregation adds the values of all contained fields numerically.'),
+          'count' => t('The Count aggregation takes the total number of contained field values as the aggregated field value.'),
+          '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.'),
+        );
+    }
+  }
+
+  /**
+   * Submit helper callback for buttons in the callback's configuration form.
+   */
+  public function formButtonSubmit(array $form, array &$form_state) {
+    $button_name = $form_state['triggering_element']['#name'];
+    if ($button_name == 'op') {
+      for ($i = 1; isset($this->options['fields']['search_api_aggregation_' . $i]); ++$i) {
+      }
+      $this->options['fields']['search_api_aggregation_' . $i] = array(
+        'name' => '',
+        'type' => 'fulltext',
+        'fields' => array(),
+      );
+    }
+    else {
+      $field = substr($button_name, 34);
+      unset($this->options['fields'][$field]);
+    }
+    $form_state['rebuild'] = TRUE;
+    $this->changes = TRUE;
+  }
+
+}
+
+/**
+ * Submit function for buttons in the callback's configuration form.
+ */
+function _search_api_add_aggregation_field_submit(array $form, array &$form_state) {
+  $form_state['callbacks']['search_api_alter_add_aggregation']->formButtonSubmit($form, $form_state);
+}
+
+/**
+ * AJAX submit function for buttons in the callback's configuration form.
+ */
+function _search_api_add_aggregation_field_ajax(array $form, array &$form_state) {
+  return $form['callbacks']['settings']['search_api_alter_add_aggregation']['fields'];
+}

+ 243 - 0
includes/callback_add_hierarchy.inc

@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * Search API data alteration callback that adds an URL field for all items.
+ */
+class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Cached value for the hierarchical field options.
+   *
+   * @var array
+   *
+   * @see getHierarchicalFields()
+   */
+  protected $field_options;
+
+  /**
+   * Enable this data alteration only if any hierarchical fields are available.
+   *
+   * @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 (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.
+   */
+  public function configurationForm() {
+    $options = $this->getHierarchicalFields();
+    $this->options += array('fields' => array());
+    $form['fields'] = array(
+      '#title' => t('Hierarchical fields'),
+      '#description' => t('Select the fields which should be supplemented with their ancestors. ' .
+          'Each field is listed along with its children of the same type. ' .
+          'When selecting several child properties of a field, all those properties will be recursively added to that field. ' .
+          'Please note that you should de-select all fields before disabling this data alteration.'),
+      '#type' => 'select',
+      '#multiple' => TRUE,
+      '#size' => min(6, count($options, COUNT_RECURSIVE)),
+      '#options' => $options,
+      '#default_value' => $this->options['fields'],
+    );
+
+    return $form;
+  }
+
+  /**
+   * 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.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    // Change the saved type of fields in the index, if necessary.
+    if (!empty($this->index->options['fields'])) {
+      $fields = &$this->index->options['fields'];
+      $previous = drupal_map_assoc($this->options['fields']);
+      foreach ($values['fields'] as $field) {
+        list($key, $prop) = explode(':', $field);
+        if (empty($previous[$field]) && isset($fields[$key]['type'])) {
+          $fields[$key]['type'] = 'list<' . search_api_extract_inner_type($fields[$key]['type']) . '>';
+          $change = TRUE;
+        }
+      }
+      $new = drupal_map_assoc($values['fields']);
+      foreach ($previous as $field) {
+        list($key, $prop) = explode(':', $field);
+        if (empty($new[$field]) && isset($fields[$key]['type'])) {
+          $w = $this->index->entityWrapper(NULL, FALSE);
+          if (isset($w->$key)) {
+            $type = $w->$key->type();
+            $inner = search_api_extract_inner_type($fields[$key]['type']);
+            $fields[$key]['type'] = search_api_nest_type($inner, $type);
+            $change = TRUE;
+          }
+        }
+      }
+      if (isset($change)) {
+        $this->index->save();
+      }
+    }
+
+    return parent::configurationFormSubmit($form, $values, $form_state);
+  }
+
+  /**
+   * 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.
+   */
+  public function alterItems(array &$items) {
+    if (empty($this->options['fields'])) {
+      return array();
+    }
+    foreach ($items as $item) {
+      $wrapper = $this->index->entityWrapper($item, FALSE);
+
+      $values = array();
+      foreach ($this->options['fields'] as $field) {
+        list($key, $prop) = explode(':', $field);
+        if (!isset($wrapper->$key)) {
+          continue;
+        }
+        $child = $wrapper->$key;
+
+        $values += array($key => array());
+        $this->extractHierarchy($child, $prop, $values[$key]);
+      }
+      foreach ($values as $key => $value) {
+        $item->$key = $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).
+   */
+  public function propertyInfo() {
+    if (empty($this->options['fields'])) {
+      return array();
+    }
+
+    $ret = array();
+    $wrapper = $this->index->entityWrapper(NULL, FALSE);
+    foreach ($this->options['fields'] as $field) {
+      list($key, $prop) = explode(':', $field);
+      if (!isset($wrapper->$key)) {
+        continue;
+      }
+      $child = $wrapper->$key;
+      while (search_api_is_list_type($child->type())) {
+        $child = $child[0];
+      }
+      if (!isset($child->$prop)) {
+        continue;
+      }
+      if (!isset($ret[$key])) {
+        $ret[$key] = $child->info();
+        $type = search_api_extract_inner_type($ret[$key]['type']);
+        $ret[$key]['type'] = "list<$type>";
+        $ret[$key]['getter callback'] = 'entity_property_verbatim_get';
+        // The return value of info() has some additional internal values set,
+        // which we have to unset for the use here.
+        unset($ret[$key]['name'], $ret[$key]['parent'], $ret[$key]['langcode'], $ret[$key]['clear'],
+            $ret[$key]['property info alter'], $ret[$key]['property defaults']);
+      }
+      if (isset($ret[$key]['bundle'])) {
+        $info = $child->$prop->info();
+        if (empty($info['bundle']) || $ret[$key]['bundle'] != $info['bundle']) {
+          unset($ret[$key]['bundle']);
+        }
+      }
+    }
+    return $ret;
+  }
+
+  /**
+   * Helper method for finding all hierarchical fields of an index's type.
+   *
+   * @return array
+   *   An array containing all hierarchical fields of the index, structured as
+   *   an options array grouped by primary field.
+   */
+  protected function getHierarchicalFields() {
+    if (!isset($this->field_options)) {
+      $this->field_options = array();
+      $wrapper = $this->index->entityWrapper(NULL, FALSE);
+      // Only entities can be indexed in hierarchies, as other properties don't
+      // have IDs that we can extract and store.
+      $entity_info = entity_get_info();
+      foreach ($wrapper as $key1 => $child) {
+        while (search_api_is_list_type($child->type())) {
+          $child = $child[0];
+        }
+        $info = $child->info();
+        $type = $child->type();
+        if (empty($entity_info[$type])) {
+          continue;
+        }
+        foreach ($child as $key2 => $prop) {
+          if (search_api_extract_inner_type($prop->type()) == $type) {
+            $prop_info = $prop->info();
+            $this->field_options[$info['label']]["$key1:$key2"] = $prop_info['label'];
+          }
+        }
+      }
+    }
+    return $this->field_options;
+  }
+
+  /**
+   * Extracts a hierarchy from a metadata wrapper by modifying $values.
+   */
+  public function extractHierarchy(EntityMetadataWrapper $wrapper, $property, array &$values) {
+    if (search_api_is_list_type($wrapper->type())) {
+      foreach ($wrapper as $w) {
+        $this->extractHierarchy($w, $property, $values);
+      }
+      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);
+      }
+    }
+  }
+
+}

+ 29 - 0
includes/callback_add_url.inc

@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * 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) {
+      $url = $this->index->datasource()->getItemUrl($item);
+      if (!$url) {
+        $item->search_api_url = NULL;
+        continue;
+      }
+      $item->search_api_url = url($url['path'], array('absolute' => TRUE) + $url['options']);
+    }
+  }
+
+  public function propertyInfo() {
+    return array(
+      'search_api_url' => array(
+        'label' => t('URI'),
+        'description' => t('An URI where the item can be accessed.'),
+        'type' => 'uri',
+      ),
+    );
+  }
+
+}

+ 98 - 0
includes/callback_add_viewed_entity.inc

@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * Search API data alteration callback that adds an URL field for all items.
+ */
+class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Only support indexes containing entities.
+   *
+   * @see SearchApiAlterCallbackInterface::supportsIndex()
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return (bool) entity_get_info($index->item_type);
+  }
+
+  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'];
+    }
+    $this->options += array('mode' => reset($view_modes));
+    if (count($view_modes) > 1) {
+      $form['mode'] = array(
+        '#type' => 'select',
+        '#title' => t('View mode'),
+        '#options' => $view_modes,
+        '#default_value' => $this->options['mode'],
+      );
+    }
+    else {
+      $form['mode'] = array(
+        '#type' => 'value',
+        '#value' => $this->options['mode'],
+      );
+      if ($view_modes) {
+        $form['note'] = array(
+          '#markup' => '<p>' . t('Entities of type %type have only a single view mode. ' .
+              'Therefore, no selection needs to be made.', array('%type' => $info['label'])) . '</p>',
+        );
+      }
+      else {
+        $form['note'] = array(
+          '#markup' => '<p>' . t('Entities of type %type have no defined view modes. ' .
+              'This might either mean that they are always displayed the same way, or that they cannot be processed by this alteration at all. ' .
+              'Please consider this when using this alteration.', array('%type' => $info['label'])) . '</p>',
+        );
+      }
+    }
+    return $form;
+  }
+
+  public function alterItems(array &$items) {
+    // Prevent session information from being saved while indexing.
+    drupal_save_session(FALSE);
+
+    // Force the current user to anonymous to prevent access bypass in search
+    // indexes.
+    $original_user = $GLOBALS['user'];
+    $GLOBALS['user'] = drupal_anonymous_user();
+
+    $type = $this->index->item_type;
+    $mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
+    foreach ($items as $id => &$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);
+        $text = render($render);
+        if (!$text) {
+          $item->search_api_viewed = NULL;
+          continue;
+        }
+        $item->search_api_viewed = $text;
+      }
+      catch (Exception $e) {
+        $item->search_api_viewed = NULL;
+      }
+    }
+
+    // Restore the user.
+    $GLOBALS['user'] = $original_user;
+    drupal_save_session(TRUE);
+  }
+
+  public function propertyInfo() {
+    return array(
+      'search_api_viewed' => array(
+        'label' => t('Entity HTML output'),
+        'description' => t('The whole HTML content of the entity when viewed.'),
+        'type' => 'text',
+      ),
+    );
+  }
+
+}

+ 72 - 0
includes/callback_bundle_filter.inc

@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * Search API data alteration callback that filters out items based on their
+ * bundle.
+ */
+class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
+
+  public function supportsIndex(SearchApiIndex $index) {
+    return ($info = entity_get_info($index->item_type)) && self::hasBundles($info);
+  }
+
+  public function alterItems(array &$items) {
+    $info = entity_get_info($this->index->item_type);
+    if (self::hasBundles($info) && isset($this->options['bundles'])) {
+      $bundles = array_flip($this->options['bundles']);
+      $default = (bool) $this->options['default'];
+      $bundle_prop = $info['entity keys']['bundle'];
+      foreach ($items as $id => $item) {
+        if (isset($bundles[$item->$bundle_prop]) == $default) {
+          unset($items[$id]);
+        }
+      }
+    }
+  }
+
+  public function configurationForm() {
+    $info = entity_get_info($this->index->item_type);
+    if (self::hasBundles($info)) {
+      $options = array();
+      foreach ($info['bundles'] as $bundle => $bundle_info) {
+        $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+      }
+      $form = array(
+        'default' => array(
+          '#type' => 'radios',
+          '#title' => t('Which items 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 bundles'),
+            0 => t('Only those from the selected bundles'),
+          ),
+        ),
+        'bundles' => array(
+          '#type' => 'select',
+          '#title' => t('Bundles'),
+          '#default_value' => isset($this->options['bundles']) ? $this->options['bundles'] : array(),
+          '#options' => $options,
+          '#size' => min(4, count($options)),
+          '#multiple' => TRUE,
+        ),
+      );
+    }
+    else {
+      $form = array(
+        'forbidden' => array(
+          '#markup' => '<p>' . t("Items indexed by this index don't have bundles and therefore cannot be filtered here.") . '</p>',
+        ),
+      );
+    }
+    return $form;
+  }
+
+  /**
+   * Helper method for figuring out if the entities with the given entity info
+   * can be filtered by bundle.
+   */
+  protected static function hasBundles(array $entity_info) {
+    return !empty($entity_info['entity keys']['bundle']) && !empty($entity_info['bundles']);
+  }
+
+}

+ 155 - 0
includes/callback_language_control.inc

@@ -0,0 +1,155 @@
+<?php
+
+/**
+ * Search API data alteration callback that filters out items based on their
+ * bundle.
+ */
+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.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    $options += array(
+      'lang_field' => '',
+      'languages' => array(),
+    );
+    parent::__construct($index, $options);
+  }
+
+  /**
+   * Check whether this data-alter callback is applicable for a certain index.
+   *
+   * 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) {
+    return drupal_multilingual();
+  }
+
+  /**
+   * Display a form for configuring this data alteration.
+   *
+   * @return array
+   *   A form array for configuring this data alteration.
+   */
+  public function configurationForm() {
+    $form = array();
+
+    $wrapper = $this->index->entityWrapper();
+    $fields[''] = t('- Use default -');
+    foreach ($wrapper as $key => $property) {
+      if ($key == 'search_api_language') {
+        continue;
+      }
+      $type = $property->type();
+      // Only single-valued string properties make sense here. Also, nested
+      // properties probably don't make sense.
+      if ($type == 'text' || $type == 'token') {
+        $info = $property->info();
+        $fields[$key] = $info['label'];
+      }
+    }
+
+    if (count($fields) > 1) {
+      $form['lang_field'] = array(
+        '#type' => 'select',
+        '#title' => t('Language field'),
+        '#description' => t("Select the field which should be used to determine an item's language."),
+        '#options' => $fields,
+        '#default_value' => $this->options['lang_field'],
+      );
+    }
+
+    $languages[LANGUAGE_NONE] = t('Language neutral');
+    $list = language_list('enabled') + array(array(), array());
+    foreach (array($list[1], $list[0]) as $list) {
+      foreach ($list as $lang) {
+        $name = t($lang->name);
+        $native = $lang->native;
+        $languages[$lang->language] = ($name == $native) ? $name : "$name ($native)";
+        if (!$lang->enabled) {
+          $languages[$lang->language] .= ' [' . t('disabled') . ']';
+        }
+      }
+    }
+    $form['languages'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Indexed languages'),
+      '#description' => t('Index only items in the selected languages. ' .
+          'When no language is selected, there will be no language-related restrictions.'),
+      '#options' => $languages,
+      '#default_value' => $this->options['languages'],
+    );
+
+    return $form;
+  }
+
+  /**
+   * 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.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $values['languages'] = array_filter($values['languages']);
+    return parent::configurationFormSubmit($form, $values, $form_state);
+  }
+
+  /**
+   * 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.
+   */
+  public function alterItems(array &$items) {
+    foreach ($items as $i => &$item) {
+      // Set item language, if a custom field was selected.
+      if ($field = $this->options['lang_field']) {
+        $wrapper = $this->index->entityWrapper($item);
+        if (isset($wrapper->$field)) {
+          try {
+            $item->search_api_language = $wrapper->$field->value();
+          }
+          catch (EntityMetadataWrapperException $e) {
+            // Something went wrong while accessing the language field. Probably
+            // doesn't really matter.
+          }
+        }
+      }
+      // Filter out items according to language, if any were selected.
+      if ($languages = $this->options['languages']) {
+        if (empty($languages[$item->search_api_language])) {
+          unset($items[$i]);
+        }
+      }
+    }
+  }
+
+}

+ 109 - 0
includes/callback_node_access.inc

@@ -0,0 +1,109 @@
+<?php
+/**
+ * @file
+ * Contains the SearchApiAlterNodeAccess class.
+ */
+
+/**
+ * Adds node access information to node indexes.
+ */
+class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Check whether this data-alter callback is applicable for a certain index.
+   *
+   * 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';
+  }
+
+  /**
+   * Declare the properties that are (or can be) added to items with this callback.
+   *
+   * 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(
+      'search_api_access_node' => array(
+        'label' => t('Node access information'),
+        'description' => t('Data needed to apply node access.'),
+        'type' => 'list<token>',
+      ),
+    );
+  }
+
+  /**
+   * 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.
+   */
+  public function alterItems(array &$items) {
+    static $account;
+
+    if (!isset($account)) {
+      // Load the anonymous user.
+      $account = drupal_anonymous_user();
+    }
+
+    foreach ($items as $nid => &$item) {
+      // Check whether all users have access to the node.
+      if (!node_access('view', $item, $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));
+
+        // Store all grants together with it's 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";
+        }
+      }
+      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');
+      }
+    }
+  }
+
+  /**
+   * Submit callback for the configuration form.
+   *
+   * If the data alteration is being enabled, set "Published" and "Author" to
+   * "indexed", because both are needed for the node access filter.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $old_status = !empty($form_state['index']->options['data_alter_callbacks']['search_api_alter_node_access']['status']);
+    $new_status = !empty($form_state['values']['callbacks']['search_api_alter_node_access']['status']);
+
+    if (!$old_status && $new_status) {
+      $form_state['index']->options['fields']['status']['type'] = 'boolean';
+      $form_state['index']->options['fields']['author']['type'] = 'integer';
+      $form_state['index']->options['fields']['author']['entity_type'] = 'user';
+    }
+
+    return parent::configurationFormSubmit($form, $values, $form_state);
+  }
+
+}

+ 45 - 0
includes/callback_node_status.inc

@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiAlterNodeStatus class.
+ */
+
+/**
+ * Exclude unpublished nodes from node indexes.
+ */
+class SearchApiAlterNodeStatus extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Check whether this data-alter callback is applicable for a certain index.
+   *
+   * 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) {
+    return $index->item_type === 'node';
+  }
+
+  /**
+   * Alter items before indexing.
+   *
+   * Items which are removed from the array won't be indexed, but will be marked
+   * as clean for future indexing.
+   *
+   * @param array $items
+   *   An array of items to be altered, keyed by item IDs.
+   */
+  public function alterItems(array &$items) {
+    foreach ($items as $nid => &$item) {
+      if (empty($item->status)) {
+        unset($items[$nid]);
+      }
+    }
+  }
+
+}

+ 698 - 0
includes/datasource.inc

@@ -0,0 +1,698 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiDataSourceControllerInterface as well as a default base class.
+ */
+
+/**
+ * Interface for all data source controllers for Search API indexes.
+ *
+ * Data source controllers encapsulate all operations specific to an item type.
+ * 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.
+ */
+interface SearchApiDataSourceControllerInterface {
+
+  /**
+   * Constructor for a data source controller.
+   *
+   * @param $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.
+   *
+   * @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.
+   */
+  public function getIdFieldInfo();
+
+  /**
+   * 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.
+   */
+  public function loadItems(array $ids);
+
+  /**
+   * 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()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array());
+
+  /**
+   * 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.
+   */
+  public function getItemId($item);
+
+  /**
+   * 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.
+   */
+  public function getItemLabel($item);
+
+  /**
+   * 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.
+   */
+  public function getItemUrl($item);
+
+  /**
+   * 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.
+   */
+  public function startTracking(array $indexes);
+
+  /**
+   * 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.
+   */
+  public function stopTracking(array $indexes);
+
+  /**
+   * 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.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes);
+
+  /**
+   * 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.
+   */
+  public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE);
+
+  /**
+   * 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.
+   */
+  public function trackItemQueued($item_ids, SearchApiIndex $index);
+
+  /**
+   * 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.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index);
+
+  /**
+   * 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.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes);
+
+  /**
+   * 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.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1);
+
+  /**
+   * 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.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function getIndexStatus(SearchApiIndex $index);
+
+}
+
+/**
+ * Default base class for the SearchApiDataSourceControllerInterface.
+ *
+ * Contains default implementations for a number of methods which will be
+ * similar for most data sources. Concrete data sources can decide to extend
+ * this base class to save time, but can also implement the interface directly.
+ *
+ * A subclass will still have to provide implementations for the following
+ * methods:
+ * - getIdFieldInfo()
+ * - loadItems()
+ * - getMetadataWrapper() or getPropertyInfo()
+ * - startTracking() or getAllItemIds()
+ *
+ * The table used by default for tracking the index status of items is
+ * {search_api_item}. This can easily be changed, for example when an item type
+ * has non-integer IDs, by changing the $table property.
+ */
+abstract class SearchApiAbstractDataSourceController implements SearchApiDataSourceControllerInterface {
+
+  /**
+   * The item type for this controller instance.
+   */
+  protected $type;
+
+  /**
+   * The info array for the item type, as specified via
+   * hook_search_api_item_type_info().
+   *
+   * @var array
+   */
+  protected $info;
+
+  /**
+   * The table used for tracking items. Set to NULL on subclasses to disable
+   * the default tracking for an item type, or change the property to use a
+   * different table for tracking.
+   *
+   * @var string
+   */
+  protected $table = 'search_api_item';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the item ID.
+   *
+   * @var string
+   */
+  protected $itemIdColumn = 'item_id';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the index ID.
+   *
+   * @var string
+   */
+  protected $indexIdColumn = 'index_id';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the indexing status.
+   *
+   * @var string
+   */
+  protected $changedColumn = 'changed';
+
+  /**
+   * Constructor for a data source controller.
+   *
+   * @param $type
+   *   The item type for which this controller is created.
+   */
+  public function __construct($type) {
+    $this->type = $type;
+    $this->info = search_api_get_item_type_info($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()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array()) {
+    $info += $this->getPropertyInfo();
+    return entity_metadata_wrapper($this->type, $item, $info);
+  }
+
+  /**
+   * Helper method that can be used by subclasses to specify the property
+   * information to use when creating a metadata wrapper.
+   *
+   * @return array
+   *   Property information as specified by hook_entity_property_info().
+   *
+   * @see hook_entity_property_info()
+   */
+  protected function getPropertyInfo() {
+    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.
+   */
+  public function getItemId($item) {
+    $id_info = $this->getIdFieldInfo();
+    $field = $id_info['key'];
+    $wrapper = $this->getMetadataWrapper($item);
+    if (!isset($wrapper->$field)) {
+      return NULL;
+    }
+    $id = $wrapper->$field->value();
+    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.
+   */
+  public function getItemLabel($item) {
+    $label = $this->getMetadataWrapper($item)->label();
+    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.
+   */
+  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.
+   */
+  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);
+    // Insert all items as new.
+    $this->trackItemInsert($this->getAllItemIds(), $indexes);
+  }
+
+  /**
+   * 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.
+   *
+   * @return array
+   *   An array containing all item IDs for this type.
+   */
+  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.
+   */
+  public function stopTracking(array $indexes) {
+    if (!$this->table) {
+      return;
+    }
+    // We could also use a single query with "IN" operator, but this method
+    // will mostly be called with only one index.
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+      $query = 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.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes) {
+    if (!$this->table) {
+      return;
+    }
+
+    // Since large amounts of items can overstrain the database, only add items
+    // in chunks.
+    foreach (array_chunk($item_ids, 1000) as $chunk) {
+      $insert = db_insert($this->table)
+        ->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,
+            $this->changedColumn => 1,
+          ));
+        }
+      }
+      $insert->execute();
+    }
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) {
+    if (!$this->table) {
+      return;
+    }
+    $index_ids = 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_ids, 'IN')
+      ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+    if ($item_ids !== FALSE) {
+      $update->condition($this->itemIdColumn, $item_ids, 'IN');
+    }
+    $update->execute();
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemQueued($item_ids, SearchApiIndex $index) {
+    if (!$this->table) {
+      return;
+    }
+    $update = db_update($this->table)
+      ->fields(array(
+        $this->changedColumn => -1,
+      ))
+      ->condition($this->indexIdColumn, $index->id);
+    if ($item_ids !== FALSE) {
+      $update->condition($this->itemIdColumn, $item_ids, 'IN');
+    }
+    $update->execute();
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
+    if (!$this->table) {
+      return;
+    }
+    $this->checkIndex($index);
+    db_update($this->table)
+      ->fields(array(
+        $this->changedColumn => 0,
+      ))
+      ->condition($this->itemIdColumn, $item_ids, 'IN')
+      ->condition($this->indexIdColumn, $index->id)
+      ->execute();
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes) {
+    if (!$this->table) {
+      return;
+    }
+    $index_ids = array();
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+      $index_ids[] = $index->id;
+    }
+    db_delete($this->table)
+      ->condition($this->itemIdColumn, $item_ids, 'IN')
+      ->condition($this->indexIdColumn, $index_ids, 'IN')
+      ->execute();
+  }
+
+  /**
+   * 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.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1) {
+    if ($limit == 0) {
+      return array();
+    }
+    $this->checkIndex($index);
+    $select = db_select($this->table, 't');
+    $select->addField('t', 'item_id');
+    $select->condition($this->indexIdColumn, $index->id);
+    $select->condition($this->changedColumn, 0, '>');
+    $select->orderBy($this->changedColumn, 'ASC');
+    if ($limit > 0) {
+      $select->range(0, $limit);
+    }
+    return $select->execute()->fetchCol();
+  }
+
+  /**
+   * 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.
+   */
+  public function getIndexStatus(SearchApiIndex $index) {
+    if (!$this->table) {
+      return array('indexed' => 0, 'total' => 0);
+    }
+    $this->checkIndex($index);
+    $indexed = db_select($this->table, 'i')
+      ->condition($this->indexIdColumn, $index->id)
+      ->condition($this->changedColumn, 0)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $total = db_select($this->table, 'i')
+      ->condition($this->indexIdColumn, $index->id)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    return array('indexed' => $indexed, 'total' => $total);
+  }
+
+  /**
+   * Helper method for ensuring that an index uses the same item type as this controller.
+   *
+   * @param SearchApiIndex $index
+   *   The index to check.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same type as this 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']));
+      throw new SearchApiDataSourceException($msg);
+    }
+  }
+
+}

+ 206 - 0
includes/datasource_entity.inc

@@ -0,0 +1,206 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiEntityDataSourceController class.
+ */
+
+/**
+ * Data source for all entities known to the Entity API.
+ */
+class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * Return 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.
+   */
+  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'])));
+    }
+    $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)));
+    }
+    $type = $properties['properties'][$field]['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)));
+    }
+    if ($type == 'token') {
+      $type = 'string';
+    }
+    return array(
+      'key' => $field,
+      '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.
+   */
+  public function loadItems(array $ids) {
+    $items = entity_load($this->type, $ids);
+    // If some items couldn't be loaded, remove them from tracking.
+    if (count($items) != count($ids)) {
+      $ids = array_flip($ids);
+      $unknown = array_keys(array_diff_key($ids, $items));
+      if ($unknown) {
+        search_api_track_item_delete($this->type, $unknown);
+      }
+    }
+    return $items;
+  }
+
+  /**
+   * 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()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array()) {
+    return entity_metadata_wrapper($this->type, $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.
+   */
+  public function getItemId($item) {
+    $id = entity_id($this->type, $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.
+   */
+  public function getItemLabel($item) {
+    $label = entity_label($this->type, $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.
+   */
+  public function getItemUrl($item) {
+    if ($this->type == 'file') {
+      return array(
+        'path' => file_create_url($item->uri),
+        'options' => array(
+          'entity_type' => 'file',
+          'entity' => $item,
+        ),
+      );
+    }
+    $url = entity_uri($this->type, $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.
+   */
+  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);
+
+    $entity_info = entity_get_info($this->type);
+
+    if (!empty($entity_info['base table'])) {
+      // 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'];
+
+      // We could also use a single insert (with a JOIN 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->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+        $query->addExpression('1', 'changed');
+
+        // INSERT ... SELECT ...
+        db_insert($this->table)
+          ->from($query)
+          ->execute();
+      }
+    }
+    else {
+      // In the absence of a 'base table', use the slow entity_load().
+      parent::startTracking($indexes);
+    }
+  }
+
+  /**
+   * 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.
+   *
+   * Will be used when the entity type doesn't specify a "base table".
+   *
+   * @return array
+   *   An array containing all item IDs for this type.
+   */
+  protected function getAllItemIds() {
+    return array_keys(entity_load($this->type));
+  }
+
+}

+ 268 - 0
includes/datasource_external.inc

@@ -0,0 +1,268 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiExternalDataSourceController class.
+ */
+
+/**
+ * Base class for data source controllers for external data sources.
+ *
+ * This data source controller is a base implementation for item types that
+ * represent external data, not directly accessible in Drupal. You can use this
+ * controller as a base class when you don't want to index items of the type via
+ * Drupal, but only want the search capabilities of the Search API. In addition
+ * you most probably also have to create a fitting service class for executing
+ * the actual searches.
+ *
+ * To use most of the functionality of the Search API and related modules, you
+ * 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().
+ */
+class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * Return information on the ID field for this controller's type.
+   *
+   * This implementation will return a field named "id" of type "string". This
+   * can also be used if the item type in question has no IDs.
+   *
+   * @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.
+   */
+  public function getIdFieldInfo() {
+    return array(
+      'key' => 'id',
+      'type' => 'string',
+    );
+  }
+
+  /**
+   * Load items of the type of this data source controller.
+   *
+   * Always returns an empty array. If you want the items of your type to be
+   * loadable, specify a function here.
+   *
+   * @param array $ids
+   *   The IDs of the items to laod.
+   *
+   * @return array
+   *   The loaded items, keyed by ID.
+   */
+  public function loadItems(array $ids) {
+    return array();
+  }
+
+  /**
+   * Helper method that can be used by subclasses to specify the property
+   * information to use when creating a metadata wrapper.
+   *
+   * 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()
+   */
+  protected function getPropertyInfo() {
+    $info['property info']['id'] = array(
+      'label' => t('ID'),
+      'type' => 'string',
+    );
+
+    return $info;
+  }
+
+  /**
+   * Get the unique ID of an item.
+   *
+   * Always returns 1.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either the unique ID of the item, or NULL if none is available.
+   */
+  public function getItemId($item) {
+    return 1;
+  }
+
+  /**
+   * Get a human-readable label for an item.
+   *
+   * Always returns NULL.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either a human-readable label for the item, or NULL if none is available.
+   */
+  public function getItemLabel($item) {
+    return NULL;
+  }
+
+  /**
+   * Get a URL at which the item can be viewed on the web.
+   *
+   * Always returns NULL.
+   *
+   * @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.
+   */
+  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.
+   */
+  public function startTracking(array $indexes) {
+    return;
+  }
+
+  /**
+   * 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.
+   */
+  public function stopTracking(array $indexes) {
+    return;
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes) {
+    return;
+  }
+
+  /**
+   * Set the tracking status of the given items to "changed"/"dirty".
+   *
+   * @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.
+   */
+  public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) {
+    return;
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
+    return;
+  }
+
+  /**
+   * 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.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes) {
+    return;
+  }
+
+  /**
+   * 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.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1) {
+    return array();
+  }
+
+  /**
+   * 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.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function getIndexStatus(SearchApiIndex $index) {
+    return array(
+      'indexed' => 0,
+      'total' => 0,
+    );
+  }
+
+}

+ 29 - 0
includes/exception.inc

@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * Represents an exception or error that occurred in some part of the Search API
+ * framework.
+ */
+class SearchApiException extends Exception {
+
+  /**
+   * Creates a new SearchApiException.
+   *
+   * @param $message
+   *   A string describing the cause of the exception.
+   */
+  public function __construct($message = NULL) {
+    if (!$message) {
+      $message = t('An error occcurred in the Search API framework.');
+    }
+    parent::__construct($message);
+  }
+
+}
+
+/**
+ * Represents an exception that occurred in a data source controller.
+ */
+class SearchApiDataSourceException extends SearchApiException {
+
+}

+ 936 - 0
includes/index_entity.inc

@@ -0,0 +1,936 @@
+<?php
+
+/**
+ * Class representing a search index.
+ */
+class SearchApiIndex extends Entity {
+
+  // Cache values, set when the corresponding methods are called for the first
+  // time.
+
+  /**
+   * Cached return value of datasource().
+   *
+   * @var SearchApiDataSourceControllerInterface
+   */
+  protected $datasource = NULL;
+
+  /**
+   * Cached return value of server().
+   *
+   * @var SearchApiServer
+   */
+  protected $server_object = NULL;
+
+  /**
+   * All enabled data alterations for this index.
+   *
+   * @var array
+   */
+  protected $callbacks = NULL;
+
+  /**
+   * All enabled processors for this index.
+   *
+   * @var array
+   */
+  protected $processors = NULL;
+
+  /**
+   * The properties added by data alterations on this index.
+   *
+   * @var array
+   */
+  protected $added_properties = NULL;
+
+  /**
+   * An array containing two arrays.
+   *
+   * At index 0, all fulltext fields of this index. At index 1, all indexed
+   * fulltext fields of this index.
+   *
+   * @var array
+   */
+  protected $fulltext_fields = array();
+
+  // Database values that will be set when object is loaded.
+
+  /**
+   * An integer identifying the index.
+   * Immutable.
+   *
+   * @var integer
+   */
+  public $id;
+
+  /**
+   * A name to be displayed for the index.
+   *
+   * @var string
+   */
+  public $name;
+
+  /**
+   * The machine name of the index.
+   * Immutable.
+   *
+   * @var string
+   */
+  public $machine_name;
+
+  /**
+   * A string describing the index' use to users.
+   *
+   * @var string
+   */
+  public $description;
+
+  /**
+   * The machine_name of the server with which data should be indexed.
+   *
+   * @var string
+   */
+  public $server;
+
+  /**
+   * The type of items stored in this index.
+   * Immutable.
+   *
+   * @var string
+   */
+  public $item_type;
+
+  /**
+   * An array of options for configuring this index. The layout is as follows:
+   * - 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.
+   * - fields: An array of all indexed fields for this index. Keys are the field
+   *   identifiers, the values are arrays for specifying the field settings. The
+   *   structure of those arrays looks like this:
+   *   - type: The type set for this field. One of the types returned by
+   *     search_api_default_field_types().
+   *   - real_type: (optional) If a custom data type was selected for this
+   *     field, this type will be stored here, and "type" contain the fallback
+   *     default data type.
+   *   - boost: (optional) A boost value for terms found in this field during
+   *     searches. Usually only relevant for fulltext fields. Defaults to 1.0.
+   *   - entity_type (optional): If set, the type of this field is really an
+   *     entity. The "type" key will then just contain the primitive data type
+   *     of the ID field, meaning that servers will ignore this and merely index
+   *     the entity's ID. Components displaying this field, though, are advised
+   *     to use the entity label instead of the ID.
+   * - additional fields: An associative array with keys and values being the
+   *   field identifiers of related entities whose fields should be displayed.
+   * - data_alter_callbacks: An array of all data alterations available. Keys
+   *   are the alteration identifiers, the values are arrays containing the
+   *   settings for that data alteration. The inner structure looks like this:
+   *   - status: Boolean indicating whether the data alteration is enabled.
+   *   - weight: Used for sorting the data alterations.
+   *   - settings: Alteration-specific settings, configured via the alteration's
+   *     configuration form.
+   * - processors: An array of all processors available for the index. The keys
+   *   are the processor identifiers, the values are arrays containing the
+   *   settings for that processor. The inner structure looks like this:
+   *   - status: Boolean indicating whether the processor is enabled.
+   *   - weight: Used for sorting the processors.
+   *   - settings: Processor-specific settings, configured via the processor's
+   *     configuration form.
+   *
+   * @var array
+   */
+  public $options = array();
+
+  /**
+   * A flag indicating whether this index is enabled.
+   *
+   * @var integer
+   */
+  public $enabled = 1;
+
+  /**
+   * A flag indicating whether to write to this index.
+   *
+   * @var integer
+   */
+  public $read_only = 0;
+
+  /**
+   * Constructor as a helper to the parent constructor.
+   */
+  public function __construct(array $values = array()) {
+    parent::__construct($values, 'search_api_index');
+  }
+
+  /**
+   * 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) {
+        $server->addIndex($this);
+      }
+      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);
+      }
+    }
+  }
+
+  /**
+   * Execute necessary tasks when the index is removed from the database.
+   */
+  public function postDelete() {
+    if ($server = $this->server()) {
+      if ($server->enabled) {
+        $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);
+      }
+    }
+
+    // Stop tracking entities for indexing.
+    $this->dequeueItems();
+  }
+
+  /**
+   * Record entities to index.
+   */
+  public function queueItems() {
+    if (!$this->read_only) {
+      $this->datasource()->startTracking(array($this));
+    }
+  }
+
+  /**
+   * Remove all records of entities to index.
+   */
+  public function dequeueItems() {
+    $this->datasource()->stopTracking(array($this));
+    _search_api_empty_cron_queue($this);
+  }
+
+  /**
+   * Saves this index to the database, either creating a new record or updating
+   * an existing one.
+   *
+   * @return
+   *   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.
+   */
+  public function save() {
+    if (empty($this->description)) {
+      $this->description = NULL;
+    }
+    if (empty($this->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;
+    }
+
+    return parent::save();
+  }
+
+  /**
+   * Helper method for updating entity properties.
+   *
+   * NOTE: You shouldn't change any properties of this object before calling
+   * this method, as this might lead to the fields not being saved correctly.
+   *
+   * @param array $fields
+   *   The new field values.
+   *
+   * @return
+   *   SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
+   *   the specified values.
+   */
+  public function update(array $fields) {
+    $changeable = array('name' => 1, 'enabled' => 1, 'description' => 1, 'server' => 1, 'options' => 1, 'read_only' => 1);
+    $changed = FALSE;
+    foreach ($fields as $field => $value) {
+      if (isset($changeable[$field]) && $value !== $this->$field) {
+        $this->$field = $value;
+        $changed = TRUE;
+      }
+    }
+
+    // If there are no new values, just return 0.
+    if (!$changed) {
+      return 0;
+    }
+
+    // Reset the index's internal property cache to correctly incorporate new
+    // settings.
+    $this->resetCaches();
+
+    return $this->save();
+  }
+
+  /**
+   * Schedules this search index for re-indexing.
+   *
+   * @return
+   *   TRUE on success, FALSE on failure.
+   */
+  public function reindex() {
+    if (!$this->server || $this->read_only) {
+      return TRUE;
+    }
+    _search_api_index_reindex($this);
+    module_invoke_all('search_api_index_reindex', $this, FALSE);
+    return TRUE;
+  }
+
+  /**
+   * Clears this search index and schedules all of its items for re-indexing.
+   *
+   * @return
+   *   TRUE on success, FALSE on failure.
+   */
+  public function clear() {
+    if (!$this->server || $this->read_only) {
+      return TRUE;
+    }
+
+    $server = $this->server();
+    if ($server->enabled) {
+      $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);
+      }
+    }
+
+    _search_api_index_reindex($this);
+    module_invoke_all('search_api_index_reindex', $this, TRUE);
+    return TRUE;
+  }
+
+  /**
+   * Magic method for determining which fields should be serialized.
+   *
+   * Don't serialize properties that are basically only caches.
+   *
+   * @return array
+   *   An array of properties to be serialized.
+   */
+  public function __sleep() {
+    $ret = get_object_vars($this);
+    unset($ret['server_object'], $ret['datasource'], $ret['processors'], $ret['added_properties'], $ret['fulltext_fields']);
+    return array_keys($ret);
+  }
+
+  /**
+   * Get the controller object of the data source used by this index.
+   *
+   * @throws SearchApiException
+   *   If the specified item type or data source doesn't exist or is invalid.
+   *
+   * @return SearchApiDataSourceControllerInterface
+   *   The data source controller for this index.
+   */
+  public function datasource() {
+    if (!isset($this->datasource)) {
+      $this->datasource = search_api_get_datasource_controller($this->item_type);
+    }
+    return $this->datasource;
+  }
+
+  /**
+   * Get the server this index lies on.
+   *
+   * @param $reset
+   *   Whether to reset the internal cache. Set to TRUE when the index' $server
+   *   property has just changed.
+   *
+   * @throws SearchApiException
+   *   If $this->server is set, but no server with that machine name exists.
+   *
+   * @return SearchApiServer
+   *   The server associated with this index, or NULL if this index currently
+   *   doesn't lie on a server.
+   */
+  public function server($reset = FALSE) {
+    if (!isset($this->server_object) || $reset) {
+      $this->server_object = $this->server ? search_api_server_load($this->server) : FALSE;
+      if ($this->server && !$this->server_object) {
+        throw new SearchApiException(t('Unknown server @server specified for index @name.', array('@server' => $this->server, '@name' => $this->machine_name)));
+      }
+    }
+    return $this->server_object ? $this->server_object : NULL;
+  }
+
+  /**
+   * Create a query object for this index.
+   *
+   * @param $options
+   *   Associative array of options configuring this query. See
+   *   SearchApiQueryInterface::__construct().
+   *
+   * @throws SearchApiException
+   *   If the index is currently disabled.
+   *
+   * @return SearchApiQueryInterface
+   *   A query object for searching this index.
+   */
+  public function query($options = array()) {
+    if (!$this->enabled) {
+      throw new SearchApiException(t('Cannot search on a disabled index.'));
+    }
+    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.
+   *
+   * @param array $items
+   *   An array of items to index.
+   *
+   * @return array
+   *   An array of the IDs of all items that should be marked as indexed.
+   */
+  public function index(array $items) {
+    if ($this->read_only) {
+      return array();
+    }
+    if (!$this->enabled) {
+      throw new SearchApiException(t("Couldn't index values on '@name' index (index is disabled)", array('@name' => $this->name)));
+    }
+    if (empty($this->options['fields'])) {
+      throw new SearchApiException(t("Couldn't index values on '@name' index (no fields selected)", array('@name' => $this->name)));
+    }
+    $fields = $this->options['fields'];
+    $custom_type_fields = array();
+    foreach ($fields as $field => $info) {
+      if (isset($info['real_type'])) {
+        $custom_type = search_api_extract_inner_type($info['real_type']);
+        if ($this->server()->supportsFeature('search_api_data_type_' . $custom_type)) {
+          $fields[$field]['type'] = $info['real_type'];
+          $custom_type_fields[$custom_type][$field] = search_api_list_nesting_level($info['real_type']);
+        }
+      }
+    }
+    if (empty($fields)) {
+      throw new SearchApiException(t("Couldn't index values on '@name' index (no fields selected)", array('@name' => $this->name)));
+    }
+
+    // Mark all items that are rejected as indexed.
+    $ret = array_keys($items);
+    drupal_alter('search_api_index_items', $items, $this);
+    if ($items) {
+      $this->dataAlter($items);
+    }
+    $ret = array_diff($ret, array_keys($items));
+
+    // Items that are rejected should also be deleted from the server.
+    if ($ret) {
+      $this->server()->deleteItems($ret, $this);
+    }
+    if (!$items) {
+      return $ret;
+    }
+
+    $data = array();
+    foreach ($items as $id => $item) {
+      $data[$id] = search_api_extract_fields($this->entityWrapper($item), $fields);
+      unset($items[$id]);
+      foreach ($custom_type_fields as $type => $type_fields) {
+        $info = search_api_get_data_type_info($type);
+        if (isset($info['conversion callback']) && is_callable($info['conversion callback'])) {
+          $callback = $info['conversion callback'];
+          foreach ($type_fields as $field => $nesting_level) {
+            if (isset($data[$id][$field]['value'])) {
+              $value = $data[$id][$field]['value'];
+              $original_type = $data[$id][$field]['original_type'];
+              $data[$id][$field]['value'] = _search_api_convert_custom_type($callback, $value, $original_type, $type, $nesting_level);
+            }
+          }
+        }
+      }
+    }
+
+    $this->preprocessIndexItems($data);
+
+    return array_merge($ret, $this->server()->indexItems($this, $data));
+  }
+
+  /**
+   * Calls data alteration hooks for a set of items, according to the index
+   * options.
+   *
+   * @param array $items
+   *   An array of items to be altered.
+   *
+   * @return SearchApiIndex
+   *   The called object.
+   */
+  public function dataAlter(array &$items) {
+    // First, execute our own search_api_language data alteration.
+    foreach ($items as &$item) {
+      $item->search_api_language = isset($item->language) ? $item->language : LANGUAGE_NONE;
+    }
+
+    foreach ($this->getAlterCallbacks() as $callback) {
+      $callback->alterItems($items);
+    }
+
+    return $this;
+  }
+
+  /**
+   * Property info alter callback that adds the infos of the properties added by
+   * data alter callbacks.
+   *
+   * @param EntityMetadataWrapper $wrapper
+   *   The wrapped data.
+   * @param $property_info
+   *   The original property info.
+   *
+   * @return array
+   *   The altered property info.
+   */
+  public function propertyInfoAlter(EntityMetadataWrapper $wrapper, array $property_info) {
+    if (entity_get_property_info($wrapper->type())) {
+      // Overwrite the existing properties with the list of properties including
+      // all fields regardless of the used bundle.
+      $property_info['properties'] = entity_get_all_property_info($wrapper->type());
+    }
+
+    if (!isset($this->added_properties)) {
+      $this->added_properties = array(
+        'search_api_language' => array(
+          'label' => t('Item language'),
+          'description' => t("A field added by the search framework to let components determine an item's language. Is always indexed."),
+          'type' => 'token',
+          '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.
+      foreach (array_reverse($this->getAlterCallbacks()) as $callback) {
+        $props = $callback->propertyInfo();
+        if ($props) {
+          $this->added_properties += $props;
+        }
+      }
+    }
+    // Let fields added by data-alter callbacks override default fields.
+    $property_info['properties'] = array_merge($property_info['properties'], $this->added_properties);
+
+    return $property_info;
+  }
+
+   /**
+   * Fills the $processors array for use by the pre-/postprocessing functions.
+   *
+   * @return SearchApiIndex
+   *   The called object.
+   * @return array
+   *   All enabled callbacks for this index, as SearchApiAlterCallbackInterface
+   *   objects.
+   */
+  protected function getAlterCallbacks() {
+    if (isset($this->callbacks)) {
+      return $this->callbacks;
+    }
+
+    $this->callbacks = array();
+    if (empty($this->options['data_alter_callbacks'])) {
+      return $this->callbacks;
+    }
+    $callback_settings = $this->options['data_alter_callbacks'];
+    $infos = search_api_get_alter_callbacks();
+
+    foreach ($callback_settings as $id => $settings) {
+      if (empty($settings['status'])) {
+        continue;
+      }
+      if (empty($infos[$id]) || !class_exists($infos[$id]['class'])) {
+        watchdog('search_api', t('Undefined data alteration @class specified in index @name', array('@class' => $id, '@name' => $this->name)), NULL, WATCHDOG_WARNING);
+        continue;
+      }
+      $class = $infos[$id]['class'];
+      $callback = new $class($this, empty($settings['settings']) ? array() : $settings['settings']);
+      if (!($callback instanceof SearchApiAlterCallbackInterface)) {
+        watchdog('search_api', t('Unknown callback class @class specified for data alteration @name', array('@class' => $class, '@name' => $id)), NULL, WATCHDOG_WARNING);
+        continue;
+      }
+
+      $this->callbacks[$id] = $callback;
+    }
+    return $this->callbacks;
+  }
+
+  /**
+   * @return array
+   *   All enabled processors for this index, as SearchApiProcessorInterface
+   *   objects.
+   */
+  protected function getProcessors() {
+    if (isset($this->processors)) {
+      return $this->processors;
+    }
+
+    $this->processors = array();
+    if (empty($this->options['processors'])) {
+      return $this->processors;
+    }
+    $processor_settings = $this->options['processors'];
+    $infos = search_api_get_processors();
+
+    foreach ($processor_settings as $id => $settings) {
+      if (empty($settings['status'])) {
+        continue;
+      }
+      if (empty($infos[$id]) || !class_exists($infos[$id]['class'])) {
+        watchdog('search_api', t('Undefined processor @class specified in index @name', array('@class' => $id, '@name' => $this->name)), NULL, WATCHDOG_WARNING);
+        continue;
+      }
+      $class = $infos[$id]['class'];
+      $processor = new $class($this, isset($settings['settings']) ? $settings['settings'] : array());
+      if (!($processor instanceof SearchApiProcessorInterface)) {
+        watchdog('search_api', t('Unknown processor class @class specified for processor @name', array('@class' => $class, '@name' => $id)), NULL, WATCHDOG_WARNING);
+        continue;
+      }
+
+      $this->processors[$id] = $processor;
+    }
+    return $this->processors;
+  }
+
+  /**
+   * Preprocess data items for indexing. Data added by data alter callbacks will
+   * be available on the items.
+   *
+   * Typically, a preprocessor will execute its preprocessing (e.g. stemming,
+   * n-grams, word splitting, stripping stop words, etc.) only on the items'
+   * fulltext fields. Other fields should usually be left untouched.
+   *
+   * @param array $items
+   *   An array of items to be preprocessed for indexing.
+   *
+   * @return SearchApiIndex
+   *   The called object.
+   */
+  public function preprocessIndexItems(array &$items) {
+    foreach ($this->getProcessors() as $processor) {
+      $processor->preprocessIndexItems($items);
+    }
+    return $this;
+  }
+
+
+  /**
+   * Preprocess a search query.
+   *
+   * The same applies as when preprocessing indexed items: typically, only the
+   * fulltext search keys should be processed, queries on specific fields should
+   * usually not be altered.
+   *
+   * @param SearchApiQuery $query
+   *   The object representing the query to be executed.
+   *
+   * @return SearchApiIndex
+   *   The called object.
+   */
+  public function preprocessSearchQuery(SearchApiQuery $query) {
+    foreach ($this->getProcessors() as $processor) {
+      $processor->preprocessSearchQuery($query);
+    }
+    return $this;
+  }
+
+  /**
+   * Postprocess search results before display.
+   *
+   * If a class is used for both pre- and post-processing a search query, the
+   * same object will be used for both calls (so preserving some data or state
+   * locally is possible).
+   *
+   * @param array $response
+   *   An array containing the search results. See
+   *   SearchApiServiceInterface->search() for the detailed format.
+   * @param SearchApiQuery $query
+   *   The object representing the executed query.
+   *
+   * @return SearchApiIndex
+   *   The called object.
+   */
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+    // Postprocessing is done in exactly the opposite direction than preprocessing.
+    foreach (array_reverse($this->getProcessors()) as $processor) {
+      $processor->postprocessSearchResults($response, $query);
+    }
+    return $this;
+  }
+
+  /**
+   * Returns a list of all known fields for this index.
+   *
+   * @param $only_indexed (optional)
+   *   Return only indexed fields, not all known fields. Defaults to TRUE.
+   * @param $get_additional (optional)
+   *   Return not only known/indexed fields, but also related entities whose
+   *   fields could additionally be added to the index.
+   *
+   * @return array
+   *   An array of all known fields for this index. Keys are the field
+   *   identifiers, the values are arrays for specifying the field settings. The
+   *   structure of those arrays looks like this:
+   *   - name: The human-readable name for the field.
+   *   - description: A description of the field, if available.
+   *   - indexed: Boolean indicating whether the field is indexed or not.
+   *   - type: The type set for this field. One of the types returned by
+   *     search_api_default_field_types().
+   *   - real_type: (optional) If a custom data type was selected for this
+   *     field, this type will be stored here, and "type" contain the fallback
+   *     default data type.
+   *   - boost: A boost value for terms found in this field during searches.
+   *     Usually only relevant for fulltext fields.
+   *   - entity_type (optional): If set, the type of this field is really an
+   *     entity. The "type" key will then contain "integer", meaning that
+   *     servers will ignore this and merely index the entity's ID. Components
+   *     displaying this field, though, are advised to use the entity label
+   *     instead of the ID.
+   *   If $get_additional is TRUE, this array is encapsulated in another
+   *   associative array, which contains the above array under the "fields" key,
+   *   and a list of related entities (field keys mapped to names) under the
+   *   "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;
+      }
+    }
+
+    // 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];
+          }
+        }
+        // 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;
+            }
+          }
+          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 . ']';
+              }
+              $additional[$key] = $name;
+              $used_names[$name] = $key;
+            }
+          }
+        }
+        unset($wrappers[$prefix]);
+      }
+    }
+
+    if (!$get_additional) {
+      return $flat;
+    }
+    $options = array();
+    $options['fields'] = $flat;
+    $options['additional fields'] = $additional;
+    return $options;
+  }
+
+  /**
+   * Convenience method for getting all of this index's fulltext fields.
+   *
+   * @param boolean $only_indexed
+   *   If set to TRUE, only the indexed fulltext fields will be returned.
+   *
+   * @return array
+   *   An array containing all (or all indexed) fulltext fields defined for this
+   *   index.
+   */
+  public function getFulltextFields($only_indexed = TRUE) {
+    $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);
+      foreach ($fields as $key => $field) {
+        if (search_api_is_text_type($field['type'])) {
+          $this->fulltext_fields[$i][] = $key;
+        }
+      }
+    }
+    return $this->fulltext_fields[$i];
+  }
+
+  /**
+   * Helper function for creating an entity metadata wrapper appropriate for
+   * this index.
+   *
+   * @param $item
+   *   Unless NULL, an item of this index's item type which should be wrapped.
+   * @param $alter
+   *   Whether to apply the index's active data alterations on the property
+   *   information used. To also apply the data alteration to the wrapped item,
+   *   execute SearchApiIndex::dataAlter() on it before calling this method.
+   *
+   * @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.
+   */
+  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);
+  }
+
+  /**
+   * Helper method to load items from the type lying on this index.
+   *
+   * @param array $ids
+   *   The IDs of the items to load.
+   *
+   * @return array
+   *   The requested items, as loaded by the data source.
+   *
+   * @see SearchApiDataSourceControllerInterface::loadItems()
+   */
+  public function loadItems(array $ids) {
+    return $this->datasource()->loadItems($ids);
+  }
+
+  /**
+   * Reset internal static caches.
+   *
+   * Should be used when things like fields or data alterations change to avoid
+   * using stale data.
+   */
+  public function resetCaches() {
+    $this->datasource = NULL;
+    $this->server_object = NULL;
+    $this->callbacks = NULL;
+    $this->processors = NULL;
+    $this->added_properties = NULL;
+    $this->fulltext_fields = array();
+  }
+
+}

+ 418 - 0
includes/processor.inc

@@ -0,0 +1,418 @@
+<?php
+
+/**
+ * Interface representing a Search API pre- and/or post-processor.
+ *
+ * While processors are enabled or disabled for both pre- and postprocessing at
+ * once, many processors will only need to run in one of those two phases. Then,
+ * the other method(s) should simply be left blank. A processor should make it
+ * clear in its description or documentation when it will run and what effect it
+ * will have.
+ * Usually, processors preprocessing indexed items will likewise preprocess
+ * search queries, so these two methods should mostly be implemented either both
+ * or neither.
+ */
+interface SearchApiProcessorInterface {
+
+  /**
+   * Construct a processor.
+   *
+   * @param SearchApiIndex $index
+   *   The index for which processing is done.
+   * @param array $options
+   *   The processor options set for this index.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array());
+
+  /**
+   * 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
+   * 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
+   * and at least throw an exception with a descriptive error message if this is
+   * violated on runtime.
+   *
+   * @param SearchApiIndex $index
+   *   The index to check for.
+   *
+   * @return boolean
+   *   TRUE if the processor can run on the given index; FALSE otherwise.
+   */
+  public function supportsIndex(SearchApiIndex $index);
+
+  /**
+   * Display a form for configuring this processor.
+   * Since forcing users to specify options for disabled processors makes no
+   * sense, none of the form elements should have the '#required' attribute set.
+   *
+   * @return array
+   *   A form array for configuring this processor, or FALSE if no configuration
+   *   is possible.
+   */
+  public function configurationForm();
+
+  /**
+   * 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.
+   */
+  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.
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state);
+
+  /**
+   * Preprocess data items for indexing.
+   *
+   * Typically, a preprocessor will execute its preprocessing (e.g. stemming,
+   * n-grams, word splitting, stripping stop words, etc.) only on the items'
+   * search_api_fulltext fields, if set. Other fields should usually be left
+   * untouched.
+   *
+   * @param array $items
+   *   An array of items to be preprocessed for indexing, formatted as specified
+   *   by SearchApiServiceInterface::indexItems().
+   */
+  public function preprocessIndexItems(array &$items);
+
+  /**
+   * Preprocess a search query.
+   *
+   * The same applies as when preprocessing indexed items: typically, only the
+   * fulltext search keys should be processed, queries on specific fields should
+   * usually not be altered.
+   *
+   * @param SearchApiQuery $query
+   *   The object representing the query to be executed.
+   */
+  public function preprocessSearchQuery(SearchApiQuery $query);
+
+  /**
+   * Postprocess search results before display.
+   *
+   * If a class is used for both pre- and post-processing a search query, the
+   * same object will be used for both calls (so preserving some data or state
+   * locally is possible).
+   *
+   * @param array $response
+   *   An array containing the search results. See the return value of
+   *   SearchApiQueryInterface->execute() for the detailed format.
+   * @param SearchApiQuery $query
+   *   The object representing the executed query.
+   */
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query);
+
+}
+
+/**
+ * Abstract processor implementation that provides an easy framework for only
+ * processing specific fields.
+ *
+ * Simple processors can just override process(), while others might want to
+ * override the other process*() methods, and test*() (for restricting
+ * processing to something other than all fulltext data).
+ */
+abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface {
+
+  /**
+   * @var SearchApiIndex
+   */
+  protected $index;
+
+  /**
+   * @var array
+   */
+  protected $options;
+
+  /**
+   * Constructor, saving its arguments into properties.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    $this->index   = $index;
+    $this->options = $options;
+  }
+
+  public function supportsIndex(SearchApiIndex $index) {
+    return TRUE;
+  }
+
+  public function configurationForm() {
+    $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+
+    $fields = $this->index->getFields();
+    $field_options = array();
+    $default_fields = array();
+    if (isset($this->options['fields'])) {
+      $default_fields = drupal_map_assoc(array_keys($this->options['fields']));
+    }
+    foreach ($fields as $name => $field) {
+      $field_options[$name] = $field['name'];
+      if (!empty($default_fields[$name]) || (!isset($this->options['fields']) && $this->testField($name, $field))) {
+        $default_fields[$name] = $name;
+      }
+    }
+
+    $form['fields'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Fields to run on'),
+      '#options' => $field_options,
+      '#default_value' => $default_fields,
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+    );
+
+    return $form;
+  }
+
+  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));
+    }
+    $values['fields'] = $fields;
+  }
+
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $this->options = $values;
+    return $values;
+  }
+
+  /**
+   * Calls processField() for all appropriate fields.
+   */
+  public function preprocessIndexItems(array &$items) {
+    foreach ($items as &$item) {
+      foreach ($item as $name => &$field) {
+        if ($this->testField($name, $field)) {
+          $this->processField($field['value'], $field['type']);
+        }
+      }
+    }
+  }
+
+  /**
+   * Calls processKeys() for the keys and processFilters() for the filters.
+   */
+  public function preprocessSearchQuery(SearchApiQuery $query) {
+    $keys = &$query->getKeys();
+    $this->processKeys($keys);
+    $filter = $query->getFilter();
+    $filters = &$filter->getFilters();
+    $this->processFilters($filters);
+  }
+
+  /**
+   * Does nothing.
+   */
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+    return;
+  }
+
+  /**
+   * Method for preprocessing field data.
+   *
+   * Calls process() either for the whole text, or each token, depending on the
+   * type. Also takes care of extracting list values and of fusing returned
+   * tokens back into a one-dimensional array.
+   */
+  protected function processField(&$value, &$type) {
+    if (!isset($value) || $value === '') {
+      return;
+    }
+    if (substr($type, 0, 5) == 'list<') {
+      $inner_type = $t = $t1 = substr($type, 5, -1);
+      foreach ($value as &$v) {
+        $t1 = $inner_type;
+        $this->processField($v, $t1);
+        // If one value got tokenized, all others have to follow.
+        if ($t1 != $inner_type) {
+          $t = $t1;
+        }
+      }
+      if ($t == 'tokens') {
+        foreach ($value as $i => &$v) {
+          if (!$v) {
+            unset($value[$i]);
+            continue;
+          }
+          if (!is_array($v)) {
+            $v = array(array('value' => $v, 'score' => 1));
+          }
+        }
+      }
+      $type = "list<$t>";
+      return;
+    }
+    if ($type == 'tokens') {
+      foreach ($value as &$token) {
+        $this->processFieldValue($token['value']);
+      }
+    }
+    else {
+      $this->processFieldValue($value);
+    }
+    if (is_array($value)) {
+      $type = 'tokens';
+      $value = $this->normalizeTokens($value);
+    }
+  }
+
+  /**
+   * Internal helper function for normalizing tokens.
+   */
+  protected function normalizeTokens($tokens, $score = 1) {
+    $ret = array();
+    foreach ($tokens as $token) {
+      if (empty($token['value']) && !is_numeric($token['value'])) {
+        // Filter out empty tokens.
+        continue;
+      }
+      if (!isset($token['score'])) {
+        $token['score'] = $score;
+      }
+      else {
+        $token['score'] *= $score;
+      }
+      if (is_array($token['value'])) {
+        foreach ($this->normalizeTokens($token['value'], $token['score']) as $t) {
+          $ret[] = $t;
+        }
+      }
+      else {
+        $ret[] = $token;
+      }
+    }
+    return $ret;
+  }
+
+  /**
+   * Method for preprocessing search keys.
+   */
+  protected function processKeys(&$keys) {
+    if (is_array($keys)) {
+      foreach ($keys as $key => &$v) {
+        if (element_child($key)) {
+          $this->processKeys($v);
+          if (!$v && !is_numeric($v)) {
+            unset($keys[$key]);
+          }
+        }
+      }
+    }
+    else {
+      $this->processKey($keys);
+    }
+  }
+
+  /**
+   * Method for preprocessing query filters.
+   */
+  protected function processFilters(array &$filters) {
+    $fields = $this->index->options['fields'];
+    foreach ($filters as &$f) {
+      if (is_array($f)) {
+        if (isset($fields[$f[0]]) && $this->testField($f[0], $fields[$f[0]])) {
+          $this->processFilterValue($f[1]);
+        }
+      }
+      else {
+        $child_filters = &$f->getFilters();
+        $this->processFilters($child_filters);
+      }
+    }
+  }
+
+  /**
+   * @param $name
+   *   The field's machine name.
+   * @param array $field
+   *   The field's information.
+   *
+   * @return
+   *   TRUE, iff the field should be processed.
+   */
+  protected function testField($name, array $field) {
+    if (empty($this->options['fields'])) {
+      return $this->testType($field['type']);
+    }
+    return !empty($this->options['fields'][$name]);
+  }
+
+  /**
+   * @return
+   *   TRUE, iff the type should be processed.
+   */
+  protected function testType($type) {
+    return search_api_is_text_type($type, array('text', 'tokens'));
+  }
+
+  /**
+   * Called for processing a single text element in a field. The default
+   * implementation just calls process().
+   *
+   * $value can either be left a string, or changed into an array of tokens. A
+   * token is an associative array containing:
+   * - value: Either the text inside the token, or a nested array of tokens. The
+   *   score of nested tokens will be multiplied by their parent's score.
+   * - score: The relative importance of the token, as a float, with 1 being
+   *   the default.
+   */
+  protected function processFieldValue(&$value) {
+    $this->process($value);
+  }
+
+  /**
+   * Called for processing a single search keyword. The default implementation
+   * just calls process().
+   *
+   * $value can either be left a string, or be changed into a nested keys array,
+   * as defined by SearchApiQueryInterface.
+   */
+  protected function processKey(&$value) {
+    $this->process($value);
+  }
+
+  /**
+   * Called for processing a single filter value. The default implementation
+   * just calls process().
+   *
+   * $value has to remain a string.
+   */
+  protected function processFilterValue(&$value) {
+    $this->process($value);
+  }
+
+  /**
+   * Function that is ultimately called for all text by the standard
+   * implementation, and does nothing by default.
+   *
+   * @param $value
+   *   The value to preprocess as a string. Can be manipulated directly, nothing
+   *   has to be returned. Since this can be called for all value types, $value
+   *   has to remain a string.
+   */
+  protected function process(&$value) {
+
+  }
+
+}

+ 137 - 0
includes/processor_html_filter.inc

@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * Processor for stripping HTML from indexed fulltext data. Supports assigning
+ * custom boosts for any HTML element.
+ */
+// @todo Process query?
+class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
+
+  /**
+   * @var array
+   */
+  protected $tags;
+
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    parent::__construct($index, $options);
+    $this->options += array(
+      'title' => FALSE,
+      'alt'   => TRUE,
+      'tags'  => "h1 = 5\n" .
+          "h2 = 3\n" .
+          "h3 = 2\n" .
+          "strong = 2\n" .
+          "b = 2\n" .
+          "em = 1.5\n" .
+          'u = 1.5',
+    );
+    $this->tags = drupal_parse_info_format($this->options['tags']);
+    // Specifying empty tags doesn't make sense.
+    unset($this->tags['br'], $this->tags['hr']);
+  }
+
+  public function configurationForm() {
+    $form = parent::configurationForm();
+    $form += array(
+      'title' => array(
+        '#type' => 'checkbox',
+        '#title' => t('Index title attribute'),
+        '#description' => t('If set, the contents of title attributes will be indexed.'),
+        '#default_value' => $this->options['title'],
+      ),
+      'alt' => array(
+        '#type' => 'checkbox',
+        '#title' => t('Index alt attribute'),
+        '#description' => t('If set, the alternative text of images will be indexed.'),
+        '#default_value' => $this->options['alt'],
+      ),
+      'tags' => array(
+        '#type' => 'textarea',
+        '#title' => t('Tag boosts'),
+        '#description' => t('Specify special boost values for certain HTML elements, in <a href="@link">INI file format</a>. ' .
+            'The boost values of nested elements are multiplied, elements not mentioned will have the default boost value of 1. ' .
+            'Assign a boost of 0 to ignore the text content of that HTML element.',
+            array('@link' => url('http://api.drupal.org/api/function/drupal_parse_info_format/7'))),
+        '#default_value' => $this->options['tags'],
+      ),
+    );
+
+    return $form;
+  }
+
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    parent::configurationFormValidate($form, $values, $form_state);
+
+    if (empty($values['tags'])) {
+      return;
+    }
+    $tags = drupal_parse_info_format($values['tags']);
+    $errors = array();
+    foreach ($tags as $key => $value) {
+      if (is_array($value)) {
+        $errors[] = t("Boost value for tag &lt;@tag&gt; can't be an array.", array('@tag' => $key));
+      }
+      elseif (!is_numeric($value)) {
+        $errors[] = t("Boost value for tag &lt;@tag&gt; must be numeric.", array('@tag' => $key));
+      }
+      elseif ($value < 0) {
+        $errors[] = t('Boost value for tag &lt;@tag&gt; must be non-negative.', array('@tag' => $key));
+      }
+    }
+    if ($errors) {
+      form_error($form['tags'], implode("<br />\n", $errors));
+    }
+  }
+
+  protected function processFieldValue(&$value) {
+    $text = str_replace(array('<', '>'), array(' <', '> '), $value); // Let removed tags still delimit words.
+    if ($this->options['title']) {
+      $text = preg_replace('/(<[-a-z_]+[^>]+)\btitle\s*=\s*("([^"]+)"|\'([^\']+)\')([^>]*>)/i', '$1 $5 $3$4 ', $text);
+    }
+    if ($this->options['alt']) {
+      $text = preg_replace('/<img\b[^>]+\balt\s*=\s*("([^"]+)"|\'([^\']+)\')[^>]*>/i', ' <img>$2$3</img> ', $text);
+    }
+    if ($this->tags) {
+      $text = strip_tags($text, '<' . implode('><', array_keys($this->tags)) . '>');
+      $value = $this->parseText($text);
+    }
+    else {
+      $value = strip_tags($text);
+    }
+  }
+
+  protected function parseText(&$text, $active_tag = NULL, $boost = 1) {
+    $ret = array();
+    while (($pos = strpos($text, '<')) !== FALSE) {
+      if ($boost && $pos > 0) {
+        $ret[] = array(
+          'value' => html_entity_decode(substr($text, 0, $pos), ENT_QUOTES, 'UTF-8'),
+          'score' => $boost,
+        );
+      }
+      $text = substr($text, $pos + 1);
+      preg_match('#^(/?)([-:_a-zA-Z]+)#', $text, $m);
+      $text = substr($text, strpos($text, '>') + 1);
+      if ($m[1]) {
+        // Closing tag.
+        if ($active_tag && $m[2] == $active_tag) {
+          return $ret;
+        }
+      }
+      else {
+        // Opening tag => recursive call.
+        $inner_boost = $boost * (isset($this->tags[$m[2]]) ? $this->tags[$m[2]] : 1);
+        $ret = array_merge($ret, $this->parseText($text, $m[2], $inner_boost));
+      }
+    }
+    if ($text) {
+      $ret[] = array(
+        'value' => html_entity_decode($text, ENT_QUOTES, 'UTF-8'),
+        'score' => $boost,
+      );
+      $text = '';
+    }
+    return $ret;
+  }
+
+}

+ 12 - 0
includes/processor_ignore_case.inc

@@ -0,0 +1,12 @@
+<?php
+
+/**
+ * Processor for making searches case-insensitive.
+ */
+class SearchApiIgnoreCase extends SearchApiAbstractProcessor {
+
+  protected function process(&$value) {
+    $value = drupal_strtolower($value);
+  }
+
+}

+ 94 - 0
includes/processor_stopwords.inc

@@ -0,0 +1,94 @@
+<?php
+
+/**
+ * Processor for removing stopwords from index and search terms.
+ */
+class SearchApiStopWords extends SearchApiAbstractProcessor {
+
+  public function configurationForm() {
+    $form = parent::configurationForm();
+
+    $form += array(
+      'help' => array(
+        '#markup' => '<p>' . t('Provide a stopwords file or enter the words in this form. If you do both, both will be used. Read about !stopwords.', array('!stopwords' => l(t('stop words'), "http://en.wikipedia.org/wiki/Stop_words"))) . '</p>',
+      ),
+      'file' => array(
+        '#type' => 'textfield',
+        '#title' => t('Stopwords file URI'),
+        '#title' => t('Enter the URI of your stopwords.txt 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(
+        '#type' => 'textarea',
+        '#title' => t('Stopwords'),
+        '#description' => t('Enter a space and/or linebreak separated list of stopwords that will be removed from content before it is indexed and from search terms before searching.'),
+        '#default_value' => t("but\ndid\nthe this that those\netc"),
+      ),
+    );
+
+    if (!empty($this->options)) {
+      $form['file']['#default_value'] = $this->options['file'];
+      $form['stopwords']['#default_value'] = $this->options['stopwords'];
+    }
+    return $form;
+  }
+
+  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)) {
+      $el = $form['file'];
+      form_error($el, t('Stopwords file') . ': ' . t('The file %uri is not readable or does not exist.', array('%uri' => $uri)));
+    }
+  }
+
+  public function process(&$value) {
+    $stopwords = $this->getStopWords();
+    if (empty($stopwords)) {
+      return;
+    }
+    $words = preg_split('/\s+/', $value);
+    foreach ($words as $sub_key => $sub_value) {
+      if (isset($stopwords[$sub_value])) {
+        unset($words[$sub_key]);
+        $this->ignored[] = $sub_value;
+      }
+    }
+    $value = implode(' ', $words);
+  }
+
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+    if (isset($this->ignored)) {
+      if (isset($response['ignored'])) {
+        $response['ignored'] = array_merge($response['ignored'], $this->ignored);
+      }
+      else $response['ignored'] = $this->ignored;
+    }
+  }
+
+  /**
+   * @return
+   *   An array whose keys are the stopwords set in either the file or the text
+   *   field.
+   */
+  protected function getStopWords() {
+    if (isset($this->stopwords)) {
+      return $this->stopwords;
+    }
+    $file_words = $form_words = array();
+    if (!empty($this->options['file']) && $stopwords_file = file_get_contents($this->options['file'])) {
+      $file_words = preg_split('/\s+/', $stopwords_file);
+    }
+    if (!empty($this->options['stopwords'])) {
+      $form_words = preg_split('/\s+/', $this->options['stopwords']);
+    }
+    $this->stopwords = array_flip(array_merge($file_words, $form_words));
+    return $this->stopwords;
+  }
+}

+ 95 - 0
includes/processor_tokenizer.inc

@@ -0,0 +1,95 @@
+<?php
+
+/**
+ * Processor for tokenizing fulltext data by replacing (configurable)
+ * non-letters with spaces.
+ */
+class SearchApiTokenizer extends SearchApiAbstractProcessor {
+
+  /**
+   * @var string
+   */
+  protected $spaces;
+
+  /**
+   * @var string
+   */
+  protected $ignorable;
+
+  public function configurationForm() {
+    $form = parent::configurationForm();
+    $form += array(
+      'spaces' => array(
+        '#type' => 'textfield',
+        '#title' => t('Whitespace characters'),
+        '#description' => t('Specify the characters that should be regarded as whitespace and therefore used as word-delimiters. ' .
+            'Specify the characters as a <a href="@link">PCRE character class</a>. ' .
+            'Note: For non-English content, the default setting might not be suitable.',
+            array('@link' => url('http://www.php.net/manual/en/regexp.reference.character-classes.php'))),
+        '#default_value' => "[^[:alnum:]]",
+      ),
+      'ignorable' => array(
+        '#type' => 'textfield',
+        '#title' => t('Ignorable characters'),
+        '#description' => t('Specify characters which should be removed from fulltext fields and search strings (e.g., "-"). The same format as above is used.'),
+        '#default_value' => "[']",
+      ),
+    );
+
+    if (!empty($this->options)) {
+      $form['spaces']['#default_value']   = $this->options['spaces'];
+      $form['ignorable']['#default_value'] = $this->options['ignorable'];
+    }
+
+    return $form;
+  }
+
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    parent::configurationFormValidate($form, $values, $form_state);
+
+    $spaces = str_replace('/', '\/', $values['spaces']);
+    $ignorable = str_replace('/', '\/', $values['ignorable']);
+    if (@preg_match('/(' . $spaces . ')+/u', '') === FALSE) {
+      $el = $form['spaces'];
+      form_error($el, $el['#title'] . ': ' . t('The entered text is no valid regular expression.'));
+    }
+    if (@preg_match('/(' . $ignorable . ')+/u', '') === FALSE) {
+      $el = $form['ignorable'];
+      form_error($el, $el['#title'] . ': ' . t('The entered text is no valid regular expression.'));
+    }
+  }
+
+  protected function processFieldValue(&$value) {
+    $this->prepare();
+    if ($this->ignorable) {
+      $value = preg_replace('/(' . $this->ignorable . ')+/u', '', $value);
+    }
+    if ($this->spaces) {
+      $arr = preg_split('/(' . $this->spaces . ')+/u', $value);
+      if (count($arr) > 1) {
+        $value = array();
+        foreach ($arr as $token) {
+          $value[] = array('value' => $token);
+        }
+      }
+    }
+  }
+
+  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);
+    }
+  }
+
+  protected function prepare() {
+    if (!isset($this->spaces)) {
+      $this->spaces = str_replace('/', '\/', $this->options['spaces']);
+      $this->ignorable = str_replace('/', '\/', $this->options['ignorable']);
+    }
+  }
+
+}

+ 1066 - 0
includes/query.inc

@@ -0,0 +1,1066 @@
+<?php
+
+/**
+ * Interface representing a search query on an Search API index.
+ *
+ * Methods not returning something else will return the object itself, so calls
+ * can be chained.
+ */
+interface SearchApiQueryInterface {
+
+  /**
+   * Constructor used when creating SearchApiQueryInterface objects.
+   *
+   * @param SearchApiIndex $index
+   *   The index the query should be executed on.
+   * @param array $options
+   *   Associative array of options configuring this query. Recognized options
+   *   are:
+   *   - conjunction: The type of conjunction to use for this query - either
+   *     'AND' or 'OR'. 'AND' by default. This only influences the search keys,
+   *     filters will always use AND by default.
+   *   - 'parse mode': The mode with which to parse the $keys variable, if it
+   *     is set and not already an array. See SearchApiQuery::parseModes() for
+   *     recognized parse modes.
+   *   - languages: The languages to search for, as an array of language IDs.
+   *     If not specified, all languages will be searched. Language-neutral
+   *     content (LANGUAGE_NONE) is always searched.
+   *   - offset: The position of the first returned search results relative to
+   *     the whole result in the index.
+   *   - limit: The maximum number of search results to return. -1 means no
+   *     limit.
+   *   - 'filter class': Can be used to change the SearchApiQueryFilterInterface
+   *     implementation to use.
+   *   - 'search id': A string that will be used as the identifier when storing
+   *     this search in the Search API's static cache.
+   *   - search_api_access_account: The account which will be used for entity
+   *     access checks, if available and enabled for the index.
+   *   - search_api_bypass_access: If set to TRUE, entity access checks will be
+   *     skipped, even if enabled for the index.
+   *   All options are optional. Third-party modules might define and use other
+   *   options not listed here.
+   *
+   * @throws SearchApiException
+   *   If a search on that index (or with those options) won't be possible.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array());
+
+  /**
+   * @return array
+   *   An associative array of parse modes recognized by objects of this class.
+   *   The keys are the parse modes' ids, values are associative arrays
+   *   containing the following entries:
+   *   - name: The translated name of the parse mode.
+   *   - description: (optional) A translated text describing the parse mode.
+   */
+  public function parseModes();
+
+  /**
+   * Method for creating a filter to use with this query object.
+   *
+   * @param $conjunction
+   *   The conjunction to use for the filter - either 'AND' or 'OR'.
+   *
+   * @return SearchApiQueryFilterInterface
+   *   A filter object that is set to use the specified conjunction.
+   */
+  public function createFilter($conjunction = 'AND');
+
+  /**
+   * Sets the keys to search for. If this method is not called on the query
+   * before execution, this will be a filter-only query.
+   *
+   * @param $keys
+   *   A string with the unparsed search keys, or NULL to use no search keys.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function keys($keys = NULL);
+
+  /**
+   * Sets the fields that will be searched for the search keys. If this is not
+   * called, all fulltext fields should be searched.
+   *
+   * @param array $fields
+   *   An array containing fulltext fields that should be searched.
+   *
+   * @throws SearchApiException
+   *   If one of the fields isn't of type "text".
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function fields(array $fields);
+
+  /**
+   * Adds a subfilter to this query's filter.
+   *
+   * @param SearchApiQueryFilterInterface $filter
+   *   A SearchApiQueryFilter object that should be added as a subfilter.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function filter(SearchApiQueryFilterInterface $filter);
+
+  /**
+   * Add a new ($field $operator $value) condition filter.
+   *
+   * @param $field
+   *   The field to filter on, e.g. 'title'.
+   * @param $value
+   *   The value the field should have (or be related to by the operator).
+   * @param $operator
+   *   The operator to use for checking the constraint. The following operators
+   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
+   *   have the same semantics as the corresponding SQL operators.
+   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
+   *   are in this case interpreted as "contains" or "doesn't contain",
+   *   respectively.
+   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
+   *   field must have no or some value, respectively.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function condition($field, $value, $operator = '=');
+
+  /**
+   * Add a sort directive to this search query. If no sort is manually set, the
+   * results will be sorted descending by relevance.
+   *
+   * @param $field
+   *   The field to sort by. The special fields 'search_api_relevance' (sort by
+   *   relevance) and 'search_api_id' (sort by item id) may be used.
+   * @param $order
+   *   The order to sort items in - either 'ASC' or 'DESC'.
+   *
+   * @throws SearchApiException
+   *   If the field is multi-valued or of a fulltext type.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function sort($field, $order = 'ASC');
+
+  /**
+   * Adds a range of results to return. This will be saved in the query's
+   * options. If called without parameters, this will remove all range
+   * restrictions previously set.
+   *
+   * @param $offset
+   *   The zero-based offset of the first result returned.
+   * @param $limit
+   *   The number of results to return.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function range($offset = NULL, $limit = NULL);
+
+  /**
+   * Executes this search query.
+   *
+   * @return array
+   *   An associative array containing the search results. The following keys
+   *   are standardized:
+   *   - 'result count': The overall number of results for this query, without
+   *     range restrictions. Might be approximated, for large numbers.
+   *   - results: An array of results, ordered as specified. The array keys are
+   *     the items' IDs, values are arrays containing the following keys:
+   *     - id: The item's ID.
+   *     - score: A float measuring how well the item fits the search.
+   *     - fields: (optional) If set, an array containing some field values
+   *       already ready-to-use. This allows search engines (or postprocessors)
+   *       to store extracted fields so other modules don't have to extract them
+   *       again. This fields should always be checked by modules that want to
+   *       use field contents of the result items.
+   *     - entity: (optional) If set, the fully loaded result item. This field
+   *       should always be used by modules using search results, to avoid
+   *       duplicate item loads.
+   *     - excerpt: (optional) If set, an HTML text containing highlighted
+   *       portions of the fulltext that match the query.
+   *   - warnings: A numeric array of translated warning messages that may be
+   *     displayed to the user.
+   *   - ignored: A numeric array of search keys that were ignored for this
+   *     search (e.g., because of being too short or stop words).
+   *   - performance: An associative array with the time taken (as floats, in
+   *     seconds) for specific parts of the search execution:
+   *     - complete: The complete runtime of the query.
+   *     - hooks: Hook invocations and other client-side preprocessing.
+   *     - preprocessing: Preprocessing of the service class.
+   *     - execution: The actual query to the search server, in whatever form.
+   *     - postprocessing: Preparing the results for returning.
+   *   Additional metadata may be returned in other keys. Only 'result count'
+   *   and 'result' always have to be set, all other entries are optional.
+   */
+  public function execute();
+
+  /**
+   * Prepares the query object for the search.
+   *
+   * This method should always be called by execute() and contain all necessary
+   * operations before the query is passed to the server's search() method.
+   */
+  public function preExecute();
+
+  /**
+   * Postprocess the search results before they are returned.
+   *
+   * This method should always be called by execute() and contain all necessary
+   * operations after the results are returned from the server.
+   *
+   * @param array $results
+   *   The results returned by the server, which may be altered.
+   */
+  public function postExecute(array &$results);
+
+  /**
+   * @return SearchApiIndex
+   *   The search index this query should be executed on.
+   */
+  public function getIndex();
+
+  /**
+   * @return
+   *   This object's search keys - either a string or an array specifying a
+   *   complex search expression.
+   *   An array will contain a '#conjunction' key specifying the conjunction
+   *   type, and search strings or nested expression arrays at numeric keys.
+   *   Additionally, a '#negation' key might be present, which means – unless it
+   *   maps to a FALSE value – that the search keys contained in that array
+   *   should be negated, i.e. not be present in returned results. The negation
+   *   works on the whole array, not on each contained term individually – i.e.,
+   *   with the "AND" conjunction and negation, only results that contain all
+   *   the terms in the array should be excluded; with the "OR" conjunction and
+   *   negation, all results containing one or more of the terms in the array
+   *   should be excluded.
+   */
+  public function &getKeys();
+
+  /**
+   * @return
+   *   The unprocessed search keys, exactly as passed to this object. Has the
+   *   same format as getKeys().
+   */
+  public function getOriginalKeys();
+
+  /**
+   * @return array
+   *   An array containing the fields that should be searched for the search
+   *   keys.
+   */
+  public function &getFields();
+
+  /**
+   * @return SearchApiQueryFilterInterface
+   *   This object's associated filter object.
+   */
+  public function getFilter();
+
+  /**
+   * @return array
+   *   An array specifying the sort order for this query. Array keys are the
+   *   field names in order of importance, the values are the respective order
+   *   in which to sort the results according to the field.
+   */
+  public function &getSort();
+
+  /**
+   * @param $name string
+   *   The name of an option.
+   * @param $default mixed
+   *   The value to return if the specified option is not set.
+   *
+   * @return mixed
+   *   The value of the option with the specified name, if set. NULL otherwise.
+   */
+  public function getOption($name, $default = NULL);
+
+  /**
+   * @param string $name
+   *   The name of an option.
+   * @param mixed $value
+   *   The new value of the option.
+   *
+   * @return The option's previous value.
+   */
+  public function setOption($name, $value);
+
+  /**
+   * @return array
+   *   An associative array of query options.
+   */
+  public function &getOptions();
+
+}
+
+/**
+ * Standard implementation of SearchApiQueryInterface.
+ */
+class SearchApiQuery implements SearchApiQueryInterface {
+
+  /**
+   * The index.
+   *
+   * @var SearchApiIndex
+   */
+  protected $index;
+
+  /**
+   * The search keys. If NULL, this will be a filter-only search.
+   *
+   * @var mixed
+   */
+  protected $keys;
+
+  /**
+   * The unprocessed search keys, as passed to the keys() method.
+   *
+   * @var mixed
+   */
+  protected $orig_keys;
+
+  /**
+   * The fields that will be searched for the keys.
+   *
+   * @var array
+   */
+  protected $fields;
+
+  /**
+   * The search filter associated with this query.
+   *
+   * @var SearchApiQueryFilterInterface
+   */
+  protected $filter;
+
+  /**
+   * The sort associated with this query.
+   *
+   * @var array
+   */
+  protected $sort;
+
+  /**
+   * Search options configuring this query.
+   *
+   * @var array
+   */
+  protected $options;
+
+  /**
+   * Count for providing a unique ID.
+   */
+  protected static $count = 0;
+
+  /**
+   * Constructor for SearchApiQuery objects.
+   *
+   * @param SearchApiIndex $index
+   *   The index the query should be executed on.
+   * @param array $options
+   *   Associative array of options configuring this query. Recognized options
+   *   are:
+   *   - conjunction: The type of conjunction to use for this query - either
+   *     'AND' or 'OR'. 'AND' by default. This only influences the search keys,
+   *     filters will always use AND by default.
+   *   - 'parse mode': The mode with which to parse the $keys variable, if it
+   *     is set and not already an array. See SearchApiQuery::parseModes() for
+   *     recognized parse modes.
+   *   - languages: The languages to search for, as an array of language IDs.
+   *     If not specified, all languages will be searched. Language-neutral
+   *     content (LANGUAGE_NONE) is always searched.
+   *   - offset: The position of the first returned search results relative to
+   *     the whole result in the index.
+   *   - limit: The maximum number of search results to return. -1 means no
+   *     limit.
+   *   - 'filter class': Can be used to change the SearchApiQueryFilterInterface
+   *     implementation to use.
+   *   - 'search id': A string that will be used as the identifier when storing
+   *     this search in the Search API's static cache.
+   *   - search_api_access_account: The account which will be used for entity
+   *     access checks, if available and enabled for the index.
+   *   - search_api_bypass_access: If set to TRUE, entity access checks will be
+   *     skipped, even if enabled for the index.
+   *   All options are optional. Third-party modules might define and use other
+   *   options not listed here.
+   *
+   * @throws SearchApiException
+   *   If a search on that index (or with those options) won't be possible.
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    if (empty($index->options['fields'])) {
+      throw new SearchApiException(t("Can't search an index which hasn't got any fields defined."));
+    }
+    if (empty($index->enabled)) {
+      throw new SearchApiException(t("Can't search a disabled index."));
+    }
+    if (isset($options['parse mode'])) {
+      $modes = $this->parseModes();
+      if (!isset($modes[$options['parse mode']])) {
+        throw new SearchApiException(t('Unknown parse mode: @mode.', array('@mode' => $options['parse mode'])));
+      }
+    }
+    $this->index = $index;
+    $this->options = $options + array(
+      'conjunction' => 'AND',
+      'parse mode' => 'terms',
+      'filter class' => 'SearchApiQueryFilter',
+      'search id' => __CLASS__,
+    );
+    $this->filter = $this->createFilter('AND');
+    $this->sort = array();
+  }
+
+  /**
+   * @return array
+   *   An associative array of parse modes recognized by objects of this class.
+   *   The keys are the parse modes' ids, values are associative arrays
+   *   containing the following entries:
+   *   - name: The translated name of the parse mode.
+   *   - description: (optional) A translated text describing the parse mode.
+   */
+  public function parseModes() {
+    $modes['direct'] = array(
+      'name' => t('Direct query'),
+      'description' => t("Don't parse the query, just hand it to the search server unaltered. " .
+          "Might fail if the query contains syntax errors in regard to the specific server's query syntax."),
+    );
+    $modes['single'] = array(
+      'name' => t('Single term'),
+      'description' => t('The query is interpreted as a single keyword, maybe containing spaces or special characters.'),
+    );
+    $modes['terms'] = array(
+      'name' => t('Multiple terms'),
+      'description' => t('The query is interpreted as multiple keywords seperated by spaces. ' .
+          'Keywords containing spaces may be "quoted". Quoted keywords must still be seperated by spaces.'),
+    );
+    // @todo Add fourth mode for complicated expressions, e.g.: »"vanilla ice" OR (love NOT hate)«
+    return $modes;
+  }
+
+  /**
+   * Parses the keys string according to the $mode parameter.
+   *
+   * @return
+   *   The parsed keys. Either a string or an array.
+   */
+  protected function parseKeys($keys, $mode) {
+    if ($keys === NULL || is_array($keys)) {
+      return $keys;
+    }
+    $keys = '' . $keys;
+    switch ($mode) {
+      case 'direct':
+        return $keys;
+
+      case 'single':
+        return array('#conjunction' => $this->options['conjunction'], $keys);
+
+      case 'terms':
+        $ret = explode(' ', $keys);
+        $quoted = FALSE;
+        $str = '';
+        foreach ($ret as $k => $v) {
+          if (!$v) {
+            continue;
+          }
+          if ($quoted) {
+            if (substr($v, -1) == '"') {
+              $v = substr($v, 0, -1);
+              $str .= ' ' . $v;
+              $ret[$k] = $str;
+              $quoted = FALSE;
+            }
+            else {
+              $str .= ' ' . $v;
+              unset($ret[$k]);
+            }
+          }
+          elseif ($v[0] == '"') {
+            $len = strlen($v);
+            if ($len > 1 && $v[$len-1] == '"') {
+              $ret[$k] = substr($v, 1, -1);
+            }
+            else {
+              $str = substr($v, 1);
+              $quoted = TRUE;
+              unset($ret[$k]);
+            }
+          }
+        }
+        if ($quoted) {
+          $ret[] = $str;
+        }
+        $ret['#conjunction'] = $this->options['conjunction'];
+        return array_filter($ret);
+    }
+  }
+
+  /**
+   * Method for creating a filter to use with this query object.
+   *
+   * @param $conjunction
+   *   The conjunction to use for the filter - either 'AND' or 'OR'.
+   *
+   * @return SearchApiQueryFilterInterface
+   *   A filter object that is set to use the specified conjunction.
+   */
+  public function createFilter($conjunction = 'AND') {
+    $filter_class = $this->options['filter class'];
+    return new $filter_class($conjunction);
+  }
+
+  /**
+   * Sets the keys to search for. If this method is not called on the query
+   * before execution, this will be a filter-only query.
+   *
+   * @param $keys
+   *   A string with the unparsed search keys, or NULL to use no search keys.
+   *
+   * @return SearchApiQuery
+   *   The called object.
+   */
+  public function keys($keys = NULL) {
+    $this->orig_keys = $keys;
+    if (isset($keys)) {
+      $this->keys = $this->parseKeys($keys, $this->options['parse mode']);
+    }
+    else {
+      $this->keys = NULL;
+    }
+    return $this;
+  }
+  /**
+   * Sets the fields that will be searched for the search keys. If this is not
+   * called, all fulltext fields should be searched.
+   *
+   * @param array $fields
+   *   An array containing fulltext fields that should be searched.
+   *
+   * @throws SearchApiException
+   *   If one of the fields isn't of type "text".
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function fields(array $fields) {
+    $fulltext_fields = $this->index->getFulltextFields();
+    foreach (array_diff($fields, $fulltext_fields) as $field) {
+      throw new SearchApiException(t('Trying to search on field @field which is no indexed fulltext field.', array('@field' => $field)));
+    }
+    $this->fields = $fields;
+    return $this;
+  }
+
+  /**
+   * Adds a subfilter to this query's filter.
+   *
+   * @param SearchApiQueryFilterInterface $filter
+   *   A SearchApiQueryFilter object that should be added as a subfilter.
+   *
+   * @return SearchApiQuery
+   *   The called object.
+   */
+  public function filter(SearchApiQueryFilterInterface $filter) {
+    $this->filter->filter($filter);
+    return $this;
+  }
+
+  /**
+   * Add a new ($field $operator $value) condition filter.
+   *
+   * @param $field
+   *   The field to filter on, e.g. 'title'.
+   * @param $value
+   *   The value the field should have (or be related to by the operator).
+   * @param $operator
+   *   The operator to use for checking the constraint. The following operators
+   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
+   *   have the same semantics as the corresponding SQL operators.
+   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
+   *   are in this case interpreted as "contains" or "doesn't contain",
+   *   respectively.
+   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
+   *   field must have no or some value, respectively.
+   *
+   * @return SearchApiQuery
+   *   The called object.
+   */
+  public function condition($field, $value, $operator = '=') {
+    $this->filter->condition($field, $value, $operator);
+    return $this;
+  }
+
+  /**
+   * Add a sort directive to this search query. If no sort is manually set, the
+   * results will be sorted descending by relevance.
+   *
+   * @param $field
+   *   The field to sort by. The special fields 'search_api_relevance' (sort by
+   *   relevance) and 'search_api_id' (sort by item id) may be used.
+   * @param $order
+   *   The order to sort items in - either 'ASC' or 'DESC'.
+   *
+   * @throws SearchApiException
+   *   If the field is multi-valued or of a fulltext type.
+   *
+   * @return SearchApiQuery
+   *   The called object.
+   */
+  public function sort($field, $order = 'ASC') {
+    $fields = $this->index->options['fields'];
+    $fields += array(
+      'search_api_relevance' => array('type' => 'decimal'),
+      'search_api_id' => array('type' => 'integer'),
+    );
+    if (empty($fields[$field])) {
+      throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $field)));
+    }
+    $type = $fields[$field]['type'];
+    if (search_api_is_list_type($type) || search_api_is_text_type($type)) {
+      throw new SearchApiException(t('Trying to sort on field @field of illegal type @type.', array('@field' => $field, '@type' => $type)));
+    }
+    $order = strtoupper(trim($order)) == 'DESC' ? 'DESC' : 'ASC';
+    $this->sort[$field] = $order;
+    return $this;
+  }
+
+  /**
+   * Adds a range of results to return. This will be saved in the query's
+   * options. If called without parameters, this will remove all range
+   * restrictions previously set.
+   *
+   * @param $offset
+   *   The zero-based offset of the first result returned.
+   * @param $limit
+   *   The number of results to return.
+   *
+   * @return SearchApiQueryInterface
+   *   The called object.
+   */
+  public function range($offset = NULL, $limit = NULL) {
+    $this->options['offset'] = $offset;
+    $this->options['limit'] = $limit;
+    return $this;
+  }
+
+
+  /**
+   * Executes this search query.
+   *
+   * @return array
+   *   An associative array containing the search results. The following keys
+   *   are standardized:
+   *   - 'result count': The overall number of results for this query, without
+   *     range restrictions. Might be approximated, for large numbers.
+   *   - results: An array of results, ordered as specified. The array keys are
+   *     the items' IDs, values are arrays containing the following keys:
+   *     - id: The item's ID.
+   *     - score: A float measuring how well the item fits the search.
+   *     - fields: (optional) If set, an array containing some field values
+   *       already ready-to-use. This allows search engines (or postprocessors)
+   *       to store extracted fields so other modules don't have to extract them
+   *       again. This fields should always be checked by modules that want to
+   *       use field contents of the result items.
+   *     - entity: (optional) If set, the fully loaded result item. This field
+   *       should always be used by modules using search results, to avoid
+   *       duplicate item loads.
+   *     - excerpt: (optional) If set, an HTML text containing highlighted
+   *       portions of the fulltext that match the query.
+   *   - warnings: A numeric array of translated warning messages that may be
+   *     displayed to the user.
+   *   - ignored: A numeric array of search keys that were ignored for this
+   *     search (e.g., because of being too short or stop words).
+   *   - performance: An associative array with the time taken (as floats, in
+   *     seconds) for specific parts of the search execution:
+   *     - complete: The complete runtime of the query.
+   *     - hooks: Hook invocations and other client-side preprocessing.
+   *     - preprocessing: Preprocessing of the service class.
+   *     - execution: The actual query to the search server, in whatever form.
+   *     - postprocessing: Preparing the results for returning.
+   *   Additional metadata may be returned in other keys. Only 'result count'
+   *   and 'result' always have to be set, all other entries are optional.
+   */
+  public final function execute() {
+    $start = microtime(TRUE);
+
+    // Prepare the query for execution by the server.
+    $this->preExecute();
+
+    $pre_search = microtime(TRUE);
+
+    // Execute query.
+    $response = $this->index->server()->search($this);
+
+    $post_search = microtime(TRUE);
+
+    // Postprocess the search results.
+    $this->postExecute($response);
+
+    $end = microtime(TRUE);
+    $response['performance']['complete'] = $end - $start;
+    $response['performance']['hooks'] = $response['performance']['complete'] - ($post_search - $pre_search);
+
+    // Store search for later retrieval for facets, etc.
+    search_api_current_search(NULL, $this, $response);
+
+    return $response;
+  }
+
+  /**
+   * Helper method for adding a language filter.
+   */
+  protected function addLanguages(array $languages) {
+    if (array_search(LANGUAGE_NONE, $languages) === FALSE) {
+      $languages[] = LANGUAGE_NONE;
+    }
+
+    $languages = drupal_map_assoc($languages);
+    $langs_to_add = $languages;
+    $filters = $this->filter->getFilters();
+    while ($filters && $langs_to_add) {
+      $filter = array_shift($filters);
+      if (is_array($filter)) {
+        if ($filter[0] == 'search_api_language' && $filter[2] == '=') {
+          $lang = $filter[1];
+          if (isset($languages[$lang])) {
+            unset($langs_to_add[$lang]);
+          }
+          else {
+            throw new SearchApiException(t('Impossible combination of filters and languages. There is a filter for "@language", but allowed languages are: "@languages".', array('@language' => $lang, '@languages' => implode('", "', $languages))));
+          }
+        }
+      }
+      else {
+        if ($filter->getConjunction() == 'AND') {
+          $filters += $filter->getFilters();
+        }
+      }
+    }
+    if ($langs_to_add) {
+      if (count($langs_to_add) == 1) {
+        $this->condition('search_api_language', reset($langs_to_add));
+      }
+      else {
+        $filter = $this->createFilter('OR');
+        foreach ($langs_to_add as $lang) {
+          $filter->condition('search_api_language', $lang);
+        }
+        $this->filter($filter);
+      }
+    }
+  }
+
+  /**
+   * Prepares the query object for the search.
+   *
+   * This method should always be called by execute() and contain all necessary
+   * operations before the query is passed to the server's search() method.
+   */
+  public function preExecute() {
+    // Add filter for languages.
+    if (isset($this->options['languages'])) {
+      $this->addLanguages($this->options['languages']);
+    }
+
+    // Add fulltext fields, unless set
+    if ($this->fields === NULL) {
+      $this->fields = $this->index->getFulltextFields();
+    }
+
+    // Preprocess query.
+    $this->index->preprocessSearchQuery($this);
+
+    // Let modules alter the query.
+    drupal_alter('search_api_query', $this);
+  }
+
+  /**
+   * Postprocess the search results before they are returned.
+   *
+   * This method should always be called by execute() and contain all necessary
+   * operations after the results are returned from the server.
+   *
+   * @param array $results
+   *   The results returned by the server, which may be altered.
+   */
+  public function postExecute(array &$results) {
+    // Postprocess results.
+    $this->index->postprocessSearchResults($results, $this);
+  }
+
+  /**
+   * @return SearchApiIndex
+   *   The search index this query will be executed on.
+   */
+  public function getIndex() {
+    return $this->index;
+  }
+
+  /**
+   * @return
+   *   This object's search keys - either a string or an array specifying a
+   *   complex search expression.
+   *   An array will contain a '#conjunction' key specifying the conjunction
+   *   type, and search strings or nested expression arrays at numeric keys.
+   *   Additionally, a '#negation' key might be present, which means – unless it
+   *   maps to a FALSE value – that the search keys contained in that array
+   *   should be negated, i.e. not be present in returned results. The negation
+   *   works on the whole array, not on each contained term individually – i.e.,
+   *   with the "AND" conjunction and negation, only results that contain all
+   *   the terms in the array should be excluded; with the "OR" conjunction and
+   *   negation, all results containing one or more of the terms in the array
+   *   should be excluded.
+   */
+  public function &getKeys() {
+    return $this->keys;
+  }
+
+  /**
+   * @return
+   *   The unprocessed search keys, exactly as passed to this object. Has the
+   *   same format as getKeys().
+   */
+  public function getOriginalKeys() {
+    return $this->orig_keys;
+  }
+
+  /**
+   * @return array
+   *   An array containing the fields that should be searched for the search
+   *   keys.
+   */
+  public function &getFields() {
+    return $this->fields;
+  }
+
+  /**
+   * @return SearchApiQueryFilterInterface
+   *   This object's associated filter object.
+   */
+  public function getFilter() {
+    return $this->filter;
+  }
+
+  /**
+   * @return array
+   *   An array specifying the sort order for this query. Array keys are the
+   *   field names in order of importance, the values are the respective order
+   *   in which to sort the results according to the field.
+   */
+  public function &getSort() {
+    return $this->sort;
+  }
+
+  /**
+   * @param $name string
+   *   The name of an option.
+   *
+   * @return mixed
+   *   The option with the specified name, if set, or NULL otherwise.
+   */
+  public function getOption($name, $default = NULL) {
+    return array_key_exists($name, $this->options) ? $this->options[$name] : $default;
+  }
+
+  /**
+   * @param string $name
+   *   The name of an option.
+   * @param mixed $value
+   *   The new value of the option.
+   *
+   * @return The option's previous value.
+   */
+  public function setOption($name, $value) {
+    $old = $this->getOption($name);
+    $this->options[$name] = $value;
+    return $old;
+  }
+
+  /**
+   * @return array
+   *   An associative array of query options.
+   */
+  public function &getOptions() {
+    return $this->options;
+  }
+
+}
+
+/**
+ * Interface representing a search query filter, that filters on one or more
+ * fields with a specific conjunction (AND or OR).
+ *
+ * Methods not noting otherwise will return the object itself, so calls can be
+ * chained.
+ */
+interface SearchApiQueryFilterInterface {
+
+  /**
+   * Constructs a new filter that uses the specified conjunction.
+   *
+   * @param $conjunction
+   *   The conjunction to use for this filter - either 'AND' or 'OR'.
+   */
+  public function __construct($conjunction = 'AND');
+
+  /**
+   * Sets this filter's conjunction.
+   *
+   * @param $conjunction
+   *   The conjunction to use for this filter - either 'AND' or 'OR'.
+   *
+   * @return SearchApiQueryFilterInterface
+   *   The called object.
+   */
+  public function setConjunction($conjunction);
+
+  /**
+   * Adds a subfilter.
+   *
+   * @param $filter
+   *   A SearchApiQueryFilterInterface object that should be added as a
+   *   subfilter.
+   *
+   * @return SearchApiQueryFilterInterface
+   *   The called object.
+   */
+  public function filter(SearchApiQueryFilterInterface $filter);
+
+  /**
+   * Add a new ($field $operator $value) condition.
+   *
+   * @param $field
+   *   The field to filter on, e.g. 'title'.
+   * @param $value
+   *   The value the field should have (or be related to by the operator).
+   * @param $operator
+   *   The operator to use for checking the constraint. The following operators
+   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
+   *   have the same semantics as the corresponding SQL operators.
+   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
+   *   are in this case interpreted as "contains" or "doesn't contain",
+   *   respectively.
+   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
+   *   field must have no or some value, respectively.
+   *
+   * @return SearchApiQueryFilterInterface
+   *   The called object.
+   */
+  public function condition($field, $value, $operator = '=');
+
+  /**
+   * @return
+   *   The conjunction used by this filter - either 'AND' or 'OR'.
+   */
+  public function getConjunction();
+
+  /**
+   * @return array
+   *   An array containing this filter's subfilters. Each of these is either an
+   *   array (field, value, operator), or another SearchApiFilter object.
+   */
+  public function &getFilters();
+
+}
+
+/**
+ * Standard implementation of SearchApiQueryFilterInterface.
+ */
+class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
+
+  /**
+   * Array containing subfilters. Each of these is either an array
+   * (field, value, operator), or another SearchApiFilter object.
+   */
+  protected $filters;
+
+  /** String specifying this filter's conjunction ('AND' or 'OR'). */
+  protected $conjunction;
+
+  /**
+   * Constructs a new filter that uses the specified conjunction.
+   *
+   * @param $conjunction
+   *   The conjunction to use for this filter - either 'AND' or 'OR'.
+   */
+  public function __construct($conjunction = 'AND') {
+    $this->setConjunction($conjunction);
+    $this->filters = array();
+  }
+
+  /**
+   * Sets this filter's conjunction.
+   *
+   * @param $conjunction
+   *   The conjunction to use for this filter - either 'AND' or 'OR'.
+   *
+   * @return SearchApiQueryFilter
+   *   The called object.
+   */
+  public function setConjunction($conjunction) {
+    $this->conjunction = strtoupper(trim($conjunction)) == 'OR' ? 'OR' : 'AND';
+    return $this;
+  }
+
+  /**
+   * Adds a subfilter.
+   *
+   * @param $filter
+   *   A SearchApiQueryFilterInterface object that should be added as a
+   *   subfilter.
+   *
+   * @return SearchApiQueryFilter
+   *   The called object.
+   */
+  public function filter(SearchApiQueryFilterInterface $filter) {
+    $this->filters[] = $filter;
+    return $this;
+  }
+
+  /**
+   * Add a new ($field $operator $value) condition.
+   *
+   * @param $field
+   *   The field to filter on, e.g. 'title'.
+   * @param $value
+   *   The value the field should have (or be related to by the operator).
+   * @param $operator
+   *   The operator to use for checking the constraint. The following operators
+   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
+   *   have the same semantics as the corresponding SQL operators.
+   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
+   *   are in this case interpreted as "contains" or "doesn't contain",
+   *   respectively.
+   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
+   *   field must have no or some value, respectively.
+   *
+   * @return SearchApiQueryFilter
+   *   The called object.
+   */
+  public function condition($field, $value, $operator = '=') {
+    $this->filters[] = array($field, $value, $operator);
+    return $this;
+  }
+
+  /**
+   * @return
+   *   The conjunction used by this filter - either 'AND' or 'OR'.
+   */
+  public function getConjunction() {
+    return $this->conjunction;
+  }
+
+  /**
+   * @return array
+   *   An array containing this filter's subfilters. Each of these is either an
+   *   array (field, value, operator), or another SearchApiFilter object.
+   */
+  public function &getFilters() {
+    return $this->filters;
+  }
+
+}

+ 228 - 0
includes/server_entity.inc

@@ -0,0 +1,228 @@
+<?php
+
+/**
+ * Class representing a search server.
+ *
+ * This can handle the same calls as defined in the SearchApiServiceInterface
+ * and pass it on to the service implementation appropriate for this server.
+ */
+class SearchApiServer extends Entity {
+
+  /* Database values that will be set when object is loaded: */
+
+  /**
+   * The primary identifier for a server.
+   *
+   * @var integer
+   */
+  public $id = 0;
+
+  /**
+   * The displayed name for a server.
+   *
+   * @var string
+   */
+  public $name = '';
+
+  /**
+   * The machine name for a server.
+   *
+   * @var string
+   */
+  public $machine_name = '';
+
+  /**
+   * The displayed description for a server.
+   *
+   * @var string
+   */
+  public $description = '';
+
+  /**
+   * The id of the service class to use for this server.
+   *
+   * @var string
+   */
+  public $class = '';
+
+  /**
+   * The options used to configure the service object.
+   *
+   * @var array
+   */
+  public $options = array();
+
+  /**
+   * A flag indicating whether the server is enabled.
+   *
+   * @var integer
+   */
+  public $enabled = 1;
+
+  /**
+   * Proxy object for invoking service methods.
+   *
+   * @var SearchApiServiceInterface
+   */
+  protected $proxy;
+
+  /**
+   * Constructor as a helper to the parent constructor.
+   */
+  public function __construct(array $values = array()) {
+    parent::__construct($values, 'search_api_server');
+  }
+
+  /**
+   * Helper method for updating entity properties.
+   *
+   * NOTE: You shouldn't change any properties of this object before calling
+   * this method, as this might lead to the fields not being saved correctly.
+   *
+   * @param array $fields
+   *   The new field values.
+   *
+   * @return
+   *   SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
+   *   the specified values.
+   */
+  public function update(array $fields) {
+    $changeable = array('name' => 1, 'enabled' => 1, 'description' => 1, 'options' => 1);
+    $changed = FALSE;
+    foreach ($fields as $field => $value) {
+      if (isset($changeable[$field]) && $value !== $this->$field) {
+        $this->$field = $value;
+        $changed = TRUE;
+      }
+    }
+    // If there are no new values, just return 0.
+    if (!$changed) {
+      return 0;
+    }
+    return $this->save();
+  }
+
+  /**
+   * Magic method for determining which fields should be serialized.
+   *
+   * Serialize all properties except the proxy object.
+   *
+   * @return array
+   *   An array of properties to be serialized.
+   */
+  public function __sleep() {
+    $ret = get_object_vars($this);
+    unset($ret['proxy'], $ret['status'], $ret['module'], $ret['is_new']);
+    return array_keys($ret);
+  }
+
+  /**
+   * Helper method for ensuring the proxy object is set up.
+   */
+  protected function ensureProxy() {
+    if (!isset($this->proxy)) {
+      $class = search_api_get_service_info($this->class);
+      if ($class && class_exists($class['class'])) {
+        if (empty($this->options)) {
+          // We always have to provide the options.
+          $this->options = array();
+        }
+        $this->proxy = new $class['class']($this);
+      }
+      if (!($this->proxy instanceof SearchApiServiceInterface)) {
+        throw new SearchApiException(t('Search server with machine name @name specifies illegal service class @class.', array('@name' => $this->machine_name, '@class' => $this->class)));
+      }
+    }
+  }
+
+  /**
+   * If the service class defines additional methods, not specified in the
+   * SearchApiServiceInterface interface, then they are called via this magic
+   * method.
+   */
+  public function __call($name, $arguments = array()) {
+    $this->ensureProxy();
+    return call_user_func_array(array($this->proxy, $name), $arguments);
+  }
+
+  // Proxy methods
+
+  // For increased clarity, and since some parameters are passed by reference,
+  // we don't use the __call() magic method for those.
+
+  public function configurationForm(array $form, array &$form_state) {
+    $this->ensureProxy();
+    return $this->proxy->configurationForm($form, $form_state);
+  }
+
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    $this->ensureProxy();
+    return $this->proxy->configurationFormValidate($form, $values, $form_state);
+  }
+
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    $this->ensureProxy();
+    return $this->proxy->configurationFormSubmit($form, $values, $form_state);
+  }
+
+  public function supportsFeature($feature) {
+    $this->ensureProxy();
+    return $this->proxy->supportsFeature($feature);
+  }
+
+  public function viewSettings() {
+    $this->ensureProxy();
+    return $this->proxy->viewSettings();
+  }
+
+  public function postCreate() {
+    $this->ensureProxy();
+    return $this->proxy->postCreate();
+  }
+
+  public function postUpdate() {
+    $this->ensureProxy();
+    return $this->proxy->postUpdate();
+  }
+
+  public function preDelete() {
+    $this->ensureProxy();
+    return $this->proxy->preDelete();
+  }
+
+  public function addIndex(SearchApiIndex $index) {
+    $this->ensureProxy();
+    return $this->proxy->addIndex($index);
+  }
+
+  public function fieldsUpdated(SearchApiIndex $index) {
+    $this->ensureProxy();
+    return $this->proxy->fieldsUpdated($index);
+  }
+
+  public function removeIndex($index) {
+    $this->ensureProxy();
+    return $this->proxy->removeIndex($index);
+  }
+
+  public function indexItems(SearchApiIndex $index, array $items) {
+    $this->ensureProxy();
+    return $this->proxy->indexItems($index, $items);
+  }
+
+  public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
+    $this->ensureProxy();
+    return $this->proxy->deleteItems($ids, $index);
+  }
+
+  public function query(SearchApiIndex $index, $options = array()) {
+    $this->ensureProxy();
+    return $this->proxy->query($index, $options);
+  }
+
+  public function search(SearchApiQueryInterface $query) {
+    $this->ensureProxy();
+    return $this->proxy->search($query);
+  }
+
+}

+ 469 - 0
includes/service.inc

@@ -0,0 +1,469 @@
+<?php
+
+/**
+ * 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).
+ */
+interface SearchApiServiceInterface {
+
+  /**
+   * Constructor for a service class, setting the server configuration used with
+   * this service.
+   *
+   * @param SearchApiServer $server
+   *   The server object for this service.
+   */
+  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.
+   *
+   * @return array
+   *   A form array for setting service-specific options.
+   */
+  public function configurationForm(array $form, array &$form_state);
+
+  /**
+   * Validation callback for the form returned by configurationForm().
+   *
+   * $form_state['server'] will contain the server that is created or edited.
+   * Use form_error() to flag errors on form elements.
+   *
+   * @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 should set the options of this service' server according to
+   * $values.
+   *
+   * @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);
+
+  /**
+   * 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.
+   *
+   * @return boolean
+   *   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.
+   */
+  public function viewSettings();
+
+  /**
+   * 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.
+   *
+   * @return
+   *   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.
+   *
+   * 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.
+   */
+  public function preDelete();
+
+  /**
+   * Add 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.
+   */
+  public function addIndex(SearchApiIndex $index);
+
+  /**
+   * 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.
+   *
+   * @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.
+   */
+  public function fieldsUpdated(SearchApiIndex $index);
+
+  /**
+   * 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.
+   *
+   * If the index wasn't added to the server, the method call should be ignored.
+   *
+   * @param $index
+   *   Either an object representing the index to remove, or its machine name
+   *   (if the index was completely deleted).
+   */
+  public function removeIndex($index);
+
+  /**
+   * Index the specified items.
+   *
+   * @param SearchApiIndex $index
+   *   The search index for which items should be indexed.
+   * @param array $items
+   *   An array of items to be indexed, keyed by their id. The values are
+   *   associative arrays of the fields to be stored, where each field is an
+   *   array with the following keys:
+   *   - type: One of the data types recognized by the Search API, or the
+   *     special type "tokens" for fulltext fields.
+   *   - original_type: The original type of the property, as defined by the
+   *     datasource controller for the index's item type.
+   *   - value: The value to index.
+   *
+   *   The special field "search_api_language" contains the item's language and
+   *   should always be indexed.
+   *
+   *   The value of fields with the "tokens" type is an array of tokens. Each
+   *   token is an array containing the following keys:
+   *   - value: The word that the token represents.
+   *   - score: A score for the importance of that word.
+   *
+   * @return array
+   *   An array of the ids of all items that were successfully indexed.
+   *
+   * @throws SearchApiException
+   *   If indexing was prevented by a fundamental configuration error.
+   */
+  public function indexItems(SearchApiIndex $index, array $items);
+
+  /**
+   * Delete items from an index on 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
+   * indexes on this server.
+   *
+   * @param $ids
+   *   Either an array containing the ids of the items that should be deleted,
+   *   or 'all' if all items should be deleted. Other formats might be
+   *   recognized by implementing classes, but these are not standardized.
+   * @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').
+   */
+  public function deleteItems($ids = 'all', SearchApiIndex $index = NULL);
+
+  /**
+   * Create a query object for searching on an index lying on this server.
+   *
+   * @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.
+   */
+  public function query(SearchApiIndex $index, $options = array());
+
+  /**
+   * Executes a search on the server represented by this object.
+   *
+   * @param $query
+   *   The SearchApiQueryInterface object to execute.
+   *
+   * @return array
+   *   An associative array containing the search results, as required by
+   *   SearchApiQueryInterface::execute().
+   *
+   * @throws SearchApiException
+   *   If an error prevented the search from completing.
+   */
+  public function search(SearchApiQueryInterface $query);
+
+}
+
+/**
+ * Abstract class with generic implementation of most service methods.
+ */
+abstract class SearchApiAbstractService implements SearchApiServiceInterface {
+
+  /**
+   * @var SearchApiServer
+   */
+  protected $server;
+
+  /**
+   * Direct reference to the server's $options property.
+   *
+   * @var array
+   */
+  protected $options = array();
+
+  /**
+   * Constructor for a service class, setting the server configuration used with
+   * this service.
+   *
+   * 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;
+    $this->options = &$server->options;
+  }
+
+  /**
+   * Form callback. Might be called on an uninitialized object - in this case,
+   * the form is for configuring a newly created server.
+   *
+   * 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().
+   *
+   * 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().
+   *
+   * 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)) {
+      $values += $this->options;
+    }
+    $this->options = $values;
+  }
+
+  /**
+   * 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.
+   *
+   * @return boolean
+   *   TRUE if this service knows and supports the specified feature. FALSE
+   *   otherwise.
+   */
+  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.
+   *
+   * The default implementation does a crude output as a definition list, with
+   * option names taken from the configuration form.
+   */
+  public function viewSettings() {
+    $output = '';
+    $form = $form_state = array();
+    $option_form = $this->configurationForm($form, $form_state);
+    $option_names = array();
+    foreach ($option_form as $key => $element) {
+      if (isset($element['#title']) && isset($this->options[$key])) {
+        $option_names[$key] = $element['#title'];
+      }
+    }
+
+    foreach ($option_names as $key => $name) {
+      $value = $this->options[$key];
+      $output .= '<dt>' . check_plain($name) . '</dt>' . "\n";
+      $output .= '<dd>' . nl2br(check_plain(print_r($value, TRUE))) . '</dd>' . "\n";
+    }
+
+    return $output ? "<dl>\n$output</dl>" : '';
+  }
+
+  /**
+   * Called once, when the server is first created. Allows it to set up its
+   * necessary infrastructure.
+   *
+   * Does nothing, by default.
+   */
+  public function postCreate() {
+    return;
+  }
+
+  /**
+   * 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
+   *   TRUE, if the update requires reindexing of all content on the server.
+   */
+  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.
+   *
+   * 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);
+    }
+  }
+
+  /**
+   * Add a new index to this server.
+   *
+   * 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.
+   *
+   * @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.
+   */
+  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.
+   *
+   * 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);
+  }
+
+  /**
+   * Create a query object for searching on an index lying on this server.
+   *
+   * @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.
+   */
+  public function query(SearchApiIndex $index, $options = array()) {
+    return new SearchApiQuery($index, $options);
+  }
+
+}

+ 44 - 0
search_api.admin.css

@@ -0,0 +1,44 @@
+
+td.search-api-status {
+  text-align: center;
+}
+
+div.search-api-edit-menu {
+  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;
+  padding: 0;
+}
+
+div.search-api-edit-menu ul li {
+  padding: 0;
+  list-style-type: none;
+  display: block;
+}
+
+div.search-api-edit-menu.collapsed {
+  display: none;
+}
+
+.search-api-alter-add-aggregation-fields,
+.search-api-checkboxes-list {
+  max-height: 12em;
+  overflow: auto;
+}
+
+/* Workaround for http://drupal.org/node/1015798 */
+.vertical-tabs fieldset div.fieldset-wrapper fieldset legend {
+  display: block;
+  margin-bottom: 2em;
+}
+

+ 1970 - 0
search_api.admin.inc

@@ -0,0 +1,1970 @@
+<?php
+
+/**
+ * Page callback that shows an overview of defined servers and indexes.
+ */
+function search_api_admin_overview() {
+  $base_path = drupal_get_path('module', 'search_api') . '/';
+  drupal_add_css($base_path . 'search_api.admin.css');
+  drupal_add_js($base_path . 'search_api.admin.js');
+
+  $servers = search_api_server_load_multiple(FALSE);
+  $indexes = array();
+  // When any entity was not normally created in the database, then show status for all.
+  $show_config_status = FALSE;
+  foreach (search_api_index_load_multiple(FALSE) as $index) {
+    $indexes[$index->server][$index->machine_name] = $index;
+    if (!$show_config_status && $index->status != ENTITY_CUSTOM) {
+      $show_config_status = TRUE;
+    }
+  }
+  // Show disabled servers after enabled ones.
+  foreach ($servers as $id => $server) {
+    if (!$server->enabled) {
+      unset($servers[$id]);
+      $servers[$id] = $server;
+    }
+    if (!$show_config_status && $server->status != ENTITY_CUSTOM) {
+      $show_config_status = TRUE;
+    }
+  }
+
+  $rows = array();
+  $t_server = array('data' => t('Server'), 'colspan' => 2);
+  $t_index = t('Index');
+  $t_enabled['data'] = array(
+    '#theme' => 'image',
+    '#path' => $base_path . 'enabled.png',
+    '#alt' => t('enabled'),
+    '#title' => t('enabled'),
+  );
+  $t_enabled['class'] = array('search-api-status');
+  $t_disabled['data'] = array(
+    '#theme' => 'image',
+    '#path' => $base_path . 'disabled.png',
+    '#alt' => t('disabled'),
+    '#title' => t('disabled'),
+  );
+  $t_disabled['class'] = array('search-api-status');
+  $t_enable = t('enable');
+  $t_disable = t('disable');
+  $t_edit = t('edit');
+  $pre_server = 'admin/config/search/search_api/server';
+  $pre_index = 'admin/config/search/search_api/index';
+  $enable = '/enable';
+  $disable = '/disable';
+  $edit = '/edit';
+  $edit_link_options['attributes']['class'][] = 'search-api-edit-menu-toggle';
+  foreach ($servers as $server) {
+    $url = $pre_server . '/' . $server->machine_name;
+    $row = array();
+    $row[] = $server->enabled ? $t_enabled : $t_disabled;
+    if ($show_config_status) {
+      $row[] = theme('entity_status', array('status' => $server->status));
+    }
+    $row[] = $t_server;
+    $row[] = l($server->name, $url);
+    $row[] = $server->enabled ? l($t_disable, $url . $disable) : l($t_enable, $url . $enable, array('query' => array('token' => drupal_get_token($server->machine_name))));
+    $row[] = l($t_edit, $url . $edit);
+    $row[] = _search_api_admin_delete_link($server);
+    $rows[] = $row;
+    if (!empty($indexes[$server->machine_name])) {
+      foreach ($indexes[$server->machine_name] as $index) {
+        $url = $pre_index . '/' . $index->machine_name;
+        $row = array();
+        $row[] = $index->enabled ? $t_enabled : $t_disabled;
+        if ($show_config_status) {
+          $row[] = theme('entity_status', array('status' => $index->status));
+        }
+        $row[] = '';
+        $row[] = $t_index;
+        $row[] = l($index->name, $url);
+        $row[] = $index->enabled
+            ? l($t_disable, $url . $disable)
+            : ($server->enabled ? l($t_enable, $url . $enable, array('query' => array('token' => drupal_get_token($index->machine_name)))) : '');
+        $row[] = l($t_edit, $url . $edit, $edit_link_options) .
+            '<div class="search-api-edit-menu collapsed">' .
+            theme('links', array('links' => menu_contextual_links('search-api-index', $pre_index, array($index->machine_name)))) .
+            '</div>';
+        $row[] = _search_api_admin_delete_link($index);
+        $rows[] = $row;
+      }
+    }
+  }
+  if (!empty($indexes[''])) {
+    foreach ($indexes[''] as $index) {
+      $url = $pre_index . '/' . $index->machine_name;
+      $row = array();
+      $row[] = $t_disabled;
+      if ($show_config_status) {
+        $row[] = theme('entity_status', array('status' => $index->status));
+      }
+      $row[] = array('data' => $t_index, 'colspan' => 2);
+      $row[] = l($index->name, $url);
+      $row[] = '';
+      $row[] = l($t_edit, $url . $edit, $edit_link_options) .
+            '<div class="search-api-edit-menu collapsed">' .
+            theme('links', array('links' => menu_contextual_links('search-api-index', $pre_index, array($index->machine_name)))) .
+            '</div>';
+      $row[] = _search_api_admin_delete_link($index);
+      $rows[] = $row;
+    }
+  }
+
+  $header = array();
+  $header[] = t('Status');
+  if ($show_config_status) {
+    $header[] = t('Configuration');
+  }
+  $header[] = array('data' => t('Type'), 'colspan' => 2);
+  $header[] = t('Name');
+  $header[] = array('data' => t('Operations'), 'colspan' => 3);
+
+  return array(
+    '#theme' => 'table',
+    '#header' => $header,
+    '#rows' => $rows,
+    '#empty' => t('There are no search servers or indexes defined yet.'),
+  );
+}
+
+/**
+ * @param Entity $entity
+ *   The index or server for which a link should be generated.
+ *
+ * @return string
+ *   A link to a delete form for the entity, if applicable.
+ */
+function _search_api_admin_delete_link(Entity $entity) {
+  // Delete link only makes sense if entity is in the database (custom or overridden).
+  if ($entity->hasStatus(ENTITY_CUSTOM)) {
+    $type = $entity instanceof SearchApiServer ? 'server' : 'index';
+    $url = 'admin/config/search/search_api/' . $type . '/' . $entity->machine_name . '/delete';
+    $title = $entity->hasStatus(ENTITY_IN_CODE) ? t('revert') : t('delete');
+    return l($title, $url);
+  }
+  return '';
+}
+
+/**
+ * Form callback showing a form for adding a server.
+ */
+function search_api_admin_add_server(array $form, array &$form_state) {
+  drupal_set_title(t('Add server'));
+
+  $class = empty($form_state['values']['class']) ? '' : $form_state['values']['class'];
+  $form_state['server'] = entity_create('search_api_server', array());
+
+  if (empty($form_state['storage']['step_one'])) {
+    $form['name'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Server name'),
+      '#description' => t('Enter the displayed name for the new server.'),
+      '#maxlength' => 50,
+      '#required' => TRUE,
+    );
+
+    $form['machine_name'] = array(
+      '#type' => 'machine_name',
+      '#maxlength' => 50,
+      '#machine_name' => array(
+        'exists' => 'search_api_server_load',
+      ),
+    );
+
+    $form['enabled'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Enabled'),
+      '#description' => t('Select if the new server will be enabled after creation.'),
+      '#default_value' => TRUE,
+    );
+    $form['description'] = array(
+      '#type' => 'textarea',
+      '#title' => t('Server description'),
+      '#description' => t('Enter a description for the new server.'),
+    );
+    $form['class'] = array(
+      '#type' => 'select',
+      '#title' => t('Service class'),
+      '#description' => t('Choose a service class to use for this server.'),
+      '#options' => array('' => '< ' . t('Choose a service class') . ' >'),
+      '#required' => TRUE,
+      '#default_value' => $class,
+      '#ajax' => array(
+        'callback' => 'search_api_admin_add_server_ajax_callback',
+        'wrapper' => 'search-api-class-options',
+      ),
+    );
+  }
+  elseif (!$class) {
+    $class = $form_state['storage']['step_one']['class'];
+  }
+
+  foreach (search_api_get_service_info() as $id => $info) {
+    if (empty($form_state['storage']['step_one'])) {
+      $form['class']['#options'][$id] = $info['name'];
+    }
+
+    if (!$class || $class != $id) {
+      continue;
+    }
+
+    $service = NULL;
+    if (class_exists($info['class'])) {
+      $service = new $info['class']($form_state['server']);
+    }
+    if (!($service instanceof SearchApiServiceInterface)) {
+      watchdog('search_api', t('Service class @id specifies an illegal class: @class', array('@id' => $id, '@class' => $info['class'])), NULL, WATCHDOG_ERROR);
+      continue;
+    }
+    $service_form = isset($form['options']['form']) ? $form['options']['form'] : array();
+    $service_form = $service->configurationForm($service_form, $form_state);
+    $form['options']['form'] = $service_form ? $service_form : array('#markup' => t('There are no configuration options for this service class.'));
+    $form['options']['class']['#type'] = 'value';
+    $form['options']['class']['#value'] = $class;
+    $form['options']['#type'] = 'fieldset';
+    $form['options']['#tree'] = TRUE;
+    $form['options']['#collapsible'] = TRUE;
+    $form['options']['#title'] = $info['name'];
+    $form['options']['#description'] = $info['description'];
+  }
+  $form['options']['#prefix'] = '<div id="search-api-class-options">';
+  $form['options']['#suffix'] = '</div>';
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Create server'),
+  );
+
+  return $form;
+}
+
+/**
+ * AJAX callback that just returns the "options" array of the already built form
+ * array.
+ */
+function search_api_admin_add_server_ajax_callback(array $form, array &$form_state) {
+  return $form['options'];
+}
+
+/**
+ * Form validation callback for adding a server.
+ *
+ * Validates the machine name and calls the service class' validation handler.
+ */
+function search_api_admin_add_server_validate(array $form, array &$form_state) {
+  if (!empty($form_state['values']['machine_name'])) {
+    $name = $form_state['values']['machine_name'];
+    if (is_numeric($name)) {
+      form_set_error('machine_name', t('The machine name must not be a pure number.'));
+    }
+  }
+
+  if (empty($form_state['values']['options']['class'])) {
+    return;
+  }
+  $class = $form_state['values']['options']['class'];
+  $info = search_api_get_service_info($class);
+  $service = NULL;
+  if (class_exists($info['class'])) {
+    $service = new $info['class']($form_state['server']);
+  }
+  if (!($service instanceof SearchApiServiceInterface)) {
+    form_set_error('class', t('There seems to be something wrong with the selected service class.'));
+    return;
+  }
+  $form_state['values']['options']['service'] = $service;
+  $values = isset($form_state['values']['options']['form']) ? $form_state['values']['options']['form'] : array();
+  $service->configurationFormValidate($form['options']['form'], $values, $form_state);
+}
+
+/**
+ * Form submit callback for adding a server.
+ */
+function search_api_admin_add_server_submit(array $form, array &$form_state) {
+  form_state_values_clean($form_state);
+  $values = $form_state['values'];
+
+  if (!empty($form_state['storage']['step_one'])) {
+    $values += $form_state['storage']['step_one'];
+    unset($form_state['storage']);
+  }
+
+  if (empty($values['options']) || ($values['class'] != $values['options']['class'])) {
+    unset($values['options']);
+    $form_state['storage']['step_one'] = $values;
+    $form_state['rebuild'] = TRUE;
+    drupal_set_message(t('Please configure the used service.'));
+    return;
+  }
+
+  $options = isset($values['options']['form']) ? $values['options']['form'] : array();
+  unset($values['options']);
+  $form_state['server']  = $server = entity_create('search_api_server', $values);
+  $server->configurationFormSubmit($form['options']['form'], $options, $form_state);
+  $server->save();
+  $form_state['redirect'] = 'admin/config/search/search_api/server/' . $server->machine_name;
+  drupal_set_message(t('The server was successfully created.'));
+}
+
+/**
+ * Title callback for viewing or editing a server or index.
+ */
+function search_api_admin_item_title($object) {
+  return $object->name;
+}
+
+/**
+ * Displays a server's details.
+ *
+ * @param SearchApiServer $server
+ *   The server to display.
+ * @param $action
+ *   One of 'enable', 'disable', 'delete'; or NULL if the server is only viewed.
+ */
+function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
+  if (!empty($action)) {
+    if ($action == 'enable') {
+      if (isset($_GET['token']) && drupal_valid_token($_GET['token'], $server->machine_name)) {
+        if ($server->update(array('enabled' => 1))) {
+          drupal_set_message(t('The server was successfully enabled.'));
+        }
+        else {
+          drupal_set_message(t('The server could not be enabled. Check the logs for details.'), 'error');
+        }
+        drupal_goto('admin/config/search/search_api/server/' . $server->machine_name);
+      }
+      else {
+        return MENU_ACCESS_DENIED;
+      }
+    }
+    else {
+      $ret = drupal_get_form('search_api_admin_confirm', 'server', $action, $server);
+      if ($ret) {
+        return $ret;
+      }
+    }
+  }
+
+  drupal_set_title(search_api_admin_item_title($server));
+  $class = search_api_get_service_info($server->class);
+  $options = $server->viewSettings();
+  return array(
+    '#theme' => 'search_api_server',
+    '#id' => $server->id,
+    '#name' => $server->name,
+    '#machine_name' => $server->machine_name,
+    '#description' => $server->description,
+    '#enabled' => $server->enabled,
+    '#class_name' => $class['name'],
+    '#class_description' => $class['description'],
+    '#options' => $options,
+    '#status' => $server->status,
+  );
+}
+
+/**
+ * Theme function for displaying a server.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - id: The server's id.
+ *   - name: The server's name.
+ *   - machine_name: The server's machine name.
+ *   - description: The server's description.
+ *   - enabled: Boolean indicating whether the server is enabled.
+ *   - class_name: The used service class' display name.
+ *   - class_description: The used service class' description.
+ *   - options: An HTML string or render array containing information about the
+ *     server's service-specific settings.
+ *   - status: The entity configuration status (in database, in code, etc.).
+ */
+function theme_search_api_server(array $variables) {
+  extract($variables);
+  $output = '';
+
+  $output .= '<h3>' . check_plain($name) . '</h3>' . "\n";
+
+  $output .= '<dl>' . "\n";
+
+  $output .= '<dt>' . t('Status') . '</dt>' . "\n";
+  $output .= '<dd>';
+  if ($enabled) {
+    $output .= t('enabled (!disable_link)', array('!disable_link' => l(t('disable'), 'admin/config/search/search_api/server/' . $machine_name . '/disable')));
+  }
+  else {
+    $output .= t('disabled (!enable_link)', array('!enable_link' => l(t('enable'), 'admin/config/search/search_api/server/' . $machine_name . '/enable', array('query' => array('token' => drupal_get_token($machine_name))))));
+  }
+  $output .= '</dd>' . "\n";
+
+  $output .= '<dt>' . t('Machine name') . '</dt>' . "\n";
+  $output .= '<dd>' . check_plain($machine_name) . '</dd>' . "\n";
+
+  if (!empty($description)) {
+    $output .= '<dt>' . t('Description') . '</dt>' . "\n";
+    $output .= '<dd>' . nl2br(check_plain($description)) . '</dd>' . "\n";
+  }
+
+  if (!empty($class_name)) {
+    $output .= '<dt>' . t('Service class') . '</dt>' . "\n";
+    $output .= '<dd><em>' . check_plain($class_name) . '</em>';
+    if (!empty($class_description)) {
+      $output .= '<p class="description">' . $class_description . '</p>';
+    }
+    $output .= '</dd>' . "\n";
+  }
+
+  if (!empty($options)) {
+    $output .= '<dt>' . t('Service options') . '</dt>' . "\n";
+    $output .= '<dd>' . "\n";
+    $output .= render($options);
+    $output .= '</dd>' . "\n";
+  }
+
+  $output .= '<dt>' . t('Configuration status') . '</dt>' . "\n";
+  $output .= '<dd>' . "\n";
+  $output .= theme('entity_status', array('status' => $status));
+  $output .= '</dd>' . "\n";
+
+  $output .= '</dl>';
+
+  return $output;
+}
+
+/**
+ * Edit a server's settings.
+ *
+ * @param SearchApiServer $server
+ *   The server to edit.
+ */
+function search_api_admin_server_edit(array $form, array &$form_state, SearchApiServer $server) {
+  $form_state['server'] = $server;
+
+  $form['name'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Server name'),
+    '#description' => t('Enter the displayed name for the  server.'),
+    '#maxlength' => 50,
+    '#default_value' => $server->name,
+    '#required' => TRUE,
+  );
+  $form['enabled'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Enabled'),
+    '#default_value' => $server->enabled,
+  );
+  $form['description'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Server description'),
+    '#description' => t('Enter a description for the new server.'),
+    '#default_value' => $server->description,
+  );
+
+  $class = search_api_get_service_info($server->class);
+
+  $service_options = array();
+  $service_options = $server->configurationForm($service_options, $form_state);
+  if ($service_options) {
+    $form['options']['form'] = $service_options;
+  }
+  $form['options']['#type'] = 'fieldset';
+  $form['options']['#tree'] = TRUE;
+  $form['options']['#collapsible'] = TRUE;
+  $form['options']['#title'] = $class['name'];
+  $form['options']['#description'] = $class['description'];
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Save settings'),
+  );
+
+  return $form;
+}
+
+/**
+ * Validation function for search_api_admin_server_edit.
+ */
+function search_api_admin_server_edit_validate(array $form, array &$form_state) {
+  $form_state['server']->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+}
+
+/**
+ * Submit function for search_api_admin_server_edit.
+ */
+function search_api_admin_server_edit_submit(array $form, array &$form_state) {
+  form_state_values_clean($form_state);
+  $values = $form_state['values'];
+
+  $server = $form_state['server'];
+  if (isset($values['options'])) {
+    $server->configurationFormSubmit($form['options']['form'], $values['options']['form'], $form_state);
+  }
+  unset($values['options']);
+
+  $server->update($values);
+  $form_state['redirect'] = 'admin/config/search/search_api/server/' . $server->machine_name;
+  drupal_set_message(t('The search server was successfully edited.'));
+}
+
+/**
+ * Form callback showing a form for adding an index.
+ */
+function search_api_admin_add_index(array $form, array &$form_state) {
+  drupal_set_title(t('Add index'));
+
+  $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+  $form['#tree'] = TRUE;
+  $form['name'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Index name'),
+    '#maxlength' => 50,
+    '#required' => TRUE,
+  );
+
+  $form['machine_name'] = array(
+    '#type' => 'machine_name',
+    '#maxlength' => 50,
+    '#machine_name' => array(
+      'exists' => 'search_api_index_load',
+    ),
+  );
+
+  $form['item_type'] = array(
+    '#type' => 'select',
+    '#title' => t('Item type'),
+    '#description' => t('Select the type of items that will be indexed in this index. ' .
+        'This setting cannot be changed afterwards.'),
+    '#options' => array(),
+    '#required' => TRUE,
+  );
+  foreach (search_api_get_item_type_info() as $type => $info) {
+    $form['item_type']['#options'][$type] = $info['name'];
+  }
+  $form['enabled'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Enabled'),
+    '#description' => t('This will only take effect if the selected server is also enabled.'),
+    '#default_value' => TRUE,
+  );
+  $form['description'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Index description'),
+  );
+  $form['server'] = array(
+    '#type' => 'select',
+    '#title' => t('Server'),
+    '#description' => t('Select the server this index should reside on.'),
+    '#default_value' => '',
+    '#options' => array('' => t('< No server >'))
+  );
+  $servers = search_api_server_load_multiple(FALSE);
+  // List enabled servers first.
+  foreach ($servers as $server) {
+    if ($server->enabled) {
+      $form['server']['#options'][$server->machine_name] = $server->name;
+    }
+  }
+  foreach ($servers as $server) {
+    if (!$server->enabled) {
+      $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
+    }
+  }
+  $form['read_only'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Read only'),
+    '#description' => t('Do not write to this index or track the status of items in this index.'),
+    '#default_value' => FALSE,
+  );
+  $form['options']['index_directly'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Index items immediately'),
+    '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
+        'This might have serious performance drawbacks and is generally not advised for larger sites.'),
+    '#default_value' => FALSE,
+  );
+  $form['options']['cron_limit'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Cron batch size'),
+    '#description' => t('Set how many items will be indexed at once when indexing items during a cron run. ' .
+        '"0" means that no items will be indexed by cron for this index, "-1" means that cron should index all items at once.'),
+    '#default_value' => SEARCH_API_DEFAULT_CRON_LIMIT,
+    '#size' => 4,
+    '#attributes' => array('class' => array('search-api-cron-limit')),
+  );
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Create index'),
+  );
+
+  return $form;
+}
+
+/**
+ * Validation callback for search_api_admin_add_index.
+ */
+function search_api_admin_add_index_validate(array $form, array &$form_state) {
+  $name = $form_state['values']['machine_name'];
+  if (is_numeric($name)) {
+    form_set_error('machine_name', t('The machine name must not be a pure number.'));
+  }
+
+  $cron_limit = $form_state['values']['options']['cron_limit'];
+  if ($cron_limit != '' . ((int) $cron_limit)) {
+    // We don't enforce stricter rules and treat all negative values as -1.
+    form_set_error('options[cron_limit]', t('The cron batch size must be an integer.'));
+  }
+}
+
+/**
+ * Submit callback for search_api_admin_add_index.
+ */
+function search_api_admin_add_index_submit(array $form, array &$form_state) {
+  form_state_values_clean($form_state);
+
+  $values = $form_state['values'];
+
+  // Validation of whether the server of an enabled index is also enabled is
+  // done in the *_insert() function.
+  search_api_index_insert($values);
+
+  drupal_set_message(t('The index was successfully created. Please set up its indexed fields now.'));
+  $form_state['redirect'] = 'admin/config/search/search_api/index/' . $values['machine_name'] . '/fields';
+}
+
+/**
+ * Displays an index' details.
+ *
+ * @param SearchApiIndex $index
+ *   The index to display.
+ */
+function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NULL) {
+  if (empty($index)) {
+    return MENU_NOT_FOUND;
+  }
+
+  if (!empty($action)) {
+    if ($action == 'enable') {
+      if (isset($_GET['token']) && drupal_valid_token($_GET['token'], $index->machine_name)) {
+        if ($index->update(array('enabled' => 1))) {
+          drupal_set_message(t('The index was successfully enabled.'));
+        }
+        else {
+          drupal_set_message(t('The index could not be enabled. Check the logs for details.'), 'error');
+        }
+        drupal_goto('admin/config/search/search_api/index/' . $index->machine_name);
+      }
+      else {
+        return MENU_ACCESS_DENIED;
+      }
+    }
+    else {
+      $ret = drupal_get_form('search_api_admin_confirm', 'index', $action, $index);
+      if ($ret) {
+        return $ret;
+      }
+    }
+  }
+
+  $ret = array(
+    '#theme' => 'search_api_index',
+    '#id' => $index->id,
+    '#name' => $index->name,
+    '#machine_name' => $index->machine_name,
+    '#description' => $index->description,
+    '#item_type' => $index->item_type,
+    '#enabled' => $index->enabled,
+    '#server' => $index->server(),
+    '#options' => $index->options,
+    '#fields' => $index->getFields(),
+    '#status' => $index->status,
+    '#read_only' => $index->read_only,
+  );
+
+  return $ret;
+}
+
+/**
+ * Theme function for displaying an index.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - id: The index's id.
+ *   - name: The index' name.
+ *   - machine_name: The index' machine name.
+ *   - description: The index' description.
+ *   - item_type: The type of items stored in this index.
+ *   - enabled: Boolean indicating whether the index is enabled.
+ *   - server: The server this index currently rests on, if any.
+ *   - options: The index' options, like cron limit.
+ *   - fields: All indexed fields of the index.
+ *   - indexed_items: The number of items already indexed in their latest
+ *     version on this index.
+ *   - total_items: The total number of items that have to be indexed for this
+ *     index.
+ *   - status: The entity configuration status (in database, in code, etc.).
+ *   - read_only: Boolean indicating whether this index is read only.
+ */
+function theme_search_api_index(array $variables) {
+  extract($variables);
+
+  $output = '';
+
+  $output .= '<h3>' . check_plain($name) . '</h3>' . "\n";
+
+  $output .= '<dl>' . "\n";
+
+  $output .= '<dt>' . t('Status') . '</dt>' . "\n";
+  $output .= '<dd>';
+  if ($enabled) {
+    $output .= t('enabled (!disable_link)', array('!disable_link' => l(t('disable'), 'admin/config/search/search_api/index/' . $machine_name . '/disable')));
+  }
+  elseif ($server && $server->enabled) {
+    $output .= t('disabled (!enable_link)', array('!enable_link' => l(t('enable'), 'admin/config/search/search_api/index/' . $machine_name . '/enable', array('query' => array('token' => drupal_get_token($machine_name))))));
+  }
+  else {
+    $output .= t('disabled');
+  }
+  $output .= '</dd>' . "\n";
+
+  $output .= '<dt>' . t('Machine name') . '</dt>' . "\n";
+  $output .= '<dd>' . check_plain($machine_name) . '</dd>' . "\n";
+
+  $output .= '<dt>' . t('Item type') . '</dt>' . "\n";
+  $type = search_api_get_item_type_info($item_type);
+  $type = $type['name'];
+  $output .= '<dd>' . check_plain($type) . '</dd>' . "\n";
+
+  if (!empty($description)) {
+    $output .= '<dt>' . t('Description') . '</dt>' . "\n";
+    $output .= '<dd>' . nl2br(check_plain($description)) . '</dd>' . "\n";
+  }
+
+  if (!empty($server)) {
+    $output .= '<dt>' . t('Server') . '</dt>' . "\n";
+    $output .= '<dd>' . l($server->name, 'admin/config/search/search_api/server/' . $server->machine_name);
+    if (!empty($server->description)) {
+      $output .= '<p class="description">' . nl2br(check_plain($server->description)) . '</p>';
+    }
+    $output .= '</dd>' . "\n";
+  }
+
+  if (!$read_only && !empty($options)) {
+    $output .= '<dt>' . t('Index options') . '</dt>' . "\n";
+    $output .= '<dd><dl>' . "\n";
+    $output .= '<dt>' . t('Cron batch size') . '</dt>' . "\n";
+    if (empty($options['cron_limit'])) {
+      $output .= '<dd>' . t("Don't index during cron runs") . '</dd>' . "\n";
+    }
+    elseif ($options['cron_limit'] < 0) {
+      $output .= '<dd>' . t('Unlimited') . '</dd>' . "\n";
+    }
+    else {
+      $output .= '<dd>' . format_plural($options['cron_limit'], '1 item per cron batch.', '@count items per cron batch.') . '</dd>' . "\n";
+    }
+
+    if (!empty($fields)) {
+      $fields_list = array();
+      foreach ($fields as $name => $field) {
+        if (search_api_is_text_type($field['type'])) {
+          $fields_list[] = t('@field (@boost x)', array('@field' => $field['name'], '@boost' => $field['boost']));
+        }
+        else {
+          $fields_list[] = check_plain($field['name']);
+        }
+      }
+      if ($fields_list) {
+        $output .= '<dt>' . t('Indexed fields') . '</dt>' . "\n";
+        $output .= '<dd>' . implode(', ', $fields_list) . '</dd>' . "\n";
+      }
+    }
+
+    $output .= '</dl></dd>' . "\n";
+  }
+  elseif ($read_only) {
+    $output .= '<dt>' . t('Read only') . '</dt>' . "\n";
+    $output .= '<dd>' . t('This index is read-only.') . '</dd>' . "\n";
+  }
+
+  $output .= '<dt>' . t('Configuration status') . '</dt>' . "\n";
+  $output .= '<dd>' . "\n";
+  $output .= theme('entity_status', array('status' => $status));
+  $output .= '</dd>' . "\n";
+
+  $output .= '</dl>';
+
+  return $output;
+}
+
+/**
+ * Form function for displaying an index status form.
+ *
+ * @param SearchApiIndex $index
+ *   The index whose status should be displayed.
+ */
+function search_api_admin_index_status_form(array $form, array &$form_state, SearchApiIndex $index) {
+  $enabled = !empty($index->enabled);
+  $status = search_api_index_status($index);
+  $server = $index->server();
+
+  $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+  $form_state['index'] = $index;
+
+  $form['status_message'] = array(
+    '#type' => 'item',
+    '#title' => t('Status'),
+    '#description' => $enabled ? t('The index is currently enabled.') : t('The index is currently disabled.'),
+  );
+  if (!empty($server->enabled)) {
+    $form['status'] = array(
+      '#type' => 'submit',
+      '#value' => $enabled ? t('Disable') : t('Enable'),
+    );
+  }
+
+  if ($index->read_only) {
+    $form['read_only'] = array(
+      '#type' => 'item',
+      '#title' => t('Read only'),
+      '#description' => t('The index is currently in read-only mode. ' .
+          'No new items will be indexed, nor will old ones be deleted.'),
+    );
+
+    return $form;
+  }
+
+  if ($enabled) {
+    $form['progress'] = array(
+      '#type' => 'item',
+      '#title' => t('Progress'),
+    );
+    $all = ($status['indexed'] == $status['total']);
+    if ($all) {
+      $form['progress']['#description'] = t('All items have been indexed (@total / @total).',
+          array('@total' => $status['total']));
+    }
+    elseif (!$status['indexed']) {
+      $form['progress']['#description'] = t('All items still need to be indexed (@total total).',
+          array('@total' => $status['total']));
+    }
+    else {
+      $percentage = (int) (100 * $status['indexed'] / $status['total']);
+      $form['progress']['#description'] = t('About @percentage% of all items have been indexed in their latest version (@indexed / @total).',
+          array('@indexed' => $status['indexed'], '@total' => $status['total'], '@percentage' => $percentage));
+    }
+
+    if (!$all) {
+      $form['index'] = array(
+        '#type' => 'fieldset',
+        '#title' => t('Index now'),
+        '#collapsible' => TRUE,
+      );
+      $form['index']['settings'] = array(
+        '#type' => 'fieldset',
+        '#title' => t('Advanced settings'),
+        '#collapsible' => TRUE,
+        '#collapsed' => TRUE,
+      );
+      $form['index']['settings']['limit'] = array(
+        '#type' => 'textfield',
+        '#title' => t('Number of items to index'),
+        '#default_value' => -1,
+        '#size' => 4,
+        '#attributes' => array('class' => array('search-api-limit')),
+        '#description' => t('Number of items to index. Set to -1 for all items.'),
+      );
+      $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
+      $form['index']['settings']['batch_size'] = array(
+        '#type' => 'textfield',
+        '#title' => t('Number of items per batch run'),
+        '#default_value' => $batch_size,
+        '#size' => 4,
+        '#attributes' => array('class' => array('search-api-batch-size')),
+        '#description' => t('Number of items per batch run. Set to -1 for all items at once (not recommended). Defaults to the cron batch size of the index.'),
+      );
+      $form['index']['button'] = array(
+        '#type' => 'submit',
+        '#value' => t('Index now'),
+      );
+      $form['index']['total'] = array(
+        '#type' => 'value',
+        '#value' => $status['total'],
+      );
+      $form['index']['remaining'] = array(
+        '#type' => 'value',
+        '#value' => $status['total'] - $status['indexed'],
+      );
+    }
+  }
+
+  if ($server) {
+    if ($enabled && $status['indexed'] > 0) {
+      $form['reindex'] = array(
+        '#type' => 'fieldset',
+        '#title' => t('Re-indexing'),
+        '#collapsible' => TRUE,
+      );
+      $form['reindex']['message'] = array(
+        '#type' => 'item',
+        '#description' => t('This will add all items to the index again (overwriting the index), but existing items in the index will remain searchable.'),
+      );
+      $form['reindex']['button'] = array(
+        '#type' => 'submit',
+        '#value' => t('Re-index content'),
+      );
+    }
+
+    $form['clear'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Clear index'),
+      '#collapsible' => TRUE,
+    );
+    $form['clear']['message'] = array(
+      '#type' => 'item',
+      '#description' => t('All items will be deleted from the index and have to be inserted again by normally indexing them. ' .
+          'Until all items are re-indexed, searches on this index will return incomplete results.<br />' .
+          'Use with care, in most cases rebuilding the index might be enough.'),
+    );
+    $form['clear']['button'] = array(
+      '#type' => 'submit',
+      '#value' => t('Clear index'),
+    );
+  }
+
+  return $form;
+}
+
+/**
+ * Validation function for search_api_admin_index_status_form.
+ */
+function search_api_admin_index_status_form_validate(array $form, array &$form_state) {
+  if ($form_state['values']['op'] == t('Index now') && !$form_state['values']['limit']) {
+    form_set_error('number', t('You have to set the number of items to index. Set to -1 for indexing all items.'));
+  }
+}
+
+/**
+ * Submit function for search_api_admin_index_status_form.
+ */
+function search_api_admin_index_status_form_submit(array $form, array &$form_state) {
+  $redirect = &$form_state['redirect'];
+  $values = $form_state['values'];
+  $index = $form_state['index'];
+  $pre = 'admin/config/search/search_api/index/' . $index->machine_name;
+  switch ($values['op']) {
+    case t('Enable'):
+      $redirect = $pre . '/enable';
+      break;
+    case t('Disable'):
+      $redirect = $pre . '/disable';
+      break;
+    case t('Index now'):
+      if (!_search_api_batch_indexing_create($index, $values['batch_size'], $values['limit'], $values['remaining'])) {
+        drupal_set_message(t("Couldn't create a batch, please check the batch size and limit."), 'warning');
+      }
+      $redirect = $pre . '/status';
+      break;
+    case t('Re-index content'):
+      if ($index->reindex()) {
+        drupal_set_message(t('The index was successfully scheduled for re-indexing.'));
+      }
+      else {
+        drupal_set_message(t('An error has occurred while performing the desired action. Check the logs for details.'), 'error');
+      }
+      $redirect = $pre . '/status';
+      break;
+    case t('Clear index'):
+      if ($index->clear()) {
+        drupal_set_message(t('The index was successfully cleared.'));
+      }
+      else {
+        drupal_set_message(t('An error has occurred while performing the desired action. Check the logs for details.'), 'error');
+      }
+      $redirect = $pre . '/status';
+      break;
+
+    default:
+      throw new SearchApiException(t('Unknown action.'));
+  }
+}
+
+/**
+ * Edit an index' settings.
+ *
+ * @param SearchApiIndex $index
+ *   The index to edit.
+ */
+function search_api_admin_index_edit(array $form, array &$form_state, SearchApiIndex $index) {
+  $form_state['index'] = $index;
+
+  $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+  $form['#tree'] = TRUE;
+  $form['name'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Index name'),
+    '#maxlength' => 50,
+    '#default_value' => $index->name,
+    '#required' => TRUE,
+  );
+  $form['enabled'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Enabled'),
+    '#default_value' => $index->enabled,
+    // Can't enable an index lying on a disabled server, or no server at all.
+    '#disabled' => !$index->enabled && (!$index->server() || !$index->server()->enabled),
+  );
+  $form['description'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Index description'),
+    '#default_value' => $index->description,
+  );
+  $form['server'] = array(
+    '#type' => 'select',
+    '#title' => t('Server'),
+    '#description' => t('Select the server this index should reside on.'),
+    '#default_value' => $index->server,
+    '#options' => array('' => t('< No server >'))
+  );
+  $servers = search_api_server_load_multiple(FALSE);
+  // List enabled servers first.
+  foreach ($servers as $server) {
+    if ($server->enabled) {
+      $form['server']['#options'][$server->machine_name] = $server->name;
+    }
+  }
+  foreach ($servers as $server) {
+    if (!$server->enabled) {
+      $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
+    }
+  }
+  $form['read_only'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Read only'),
+    '#description' => t('Do not write to this index or track the status of items in this index.'),
+    '#default_value' => $index->read_only,
+  );
+  $form['options']['index_directly'] = array(
+    '#type' => 'checkbox',
+    '#title' => t('Index items immediately'),
+    '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
+        'This might have serious performance drawbacks and is generally not advised for larger sites.'),
+    '#default_value' => !empty($index->options['index_directly']),
+    '#states' => array(
+      'invisible' => array(':input[name="read_only"]' => array('checked' => TRUE)),
+    ),
+  );
+  $form['options']['cron_limit'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Cron batch size'),
+    '#description' => t('Set how many items will be indexed at once when indexing items during a cron run. ' .
+        '"0" means that no items will be indexed by cron for this index, "-1" means that cron should index all items at once.'),
+    '#default_value' => isset($index->options['cron_limit']) ? $index->options['cron_limit'] : SEARCH_API_DEFAULT_CRON_LIMIT,
+    '#size' => 4,
+    '#attributes' => array('class' => array('search-api-cron-limit')),
+    '#element_validate' => array('_element_validate_integer'),
+    '#states' => array(
+      'invisible' => array(':input[name="read_only"]' => array('checked' => TRUE)),
+    ),
+  );
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Save settings'),
+  );
+
+  return $form;
+}
+
+/**
+ * Submit callback for search_api_admin_index_edit.
+ */
+function search_api_admin_index_edit_submit(array $form, array &$form_state) {
+  form_state_values_clean($form_state);
+
+  $values = $form_state['values'];
+  $index = $form_state['index'];
+  $values['options'] += $index->options;
+
+  $ret = $index->update($values);
+  $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name;
+  if ($ret) {
+    drupal_set_message(t('The search index was successfully edited.'));
+  }
+  else {
+    drupal_set_message(t('No values were changed.'));
+  }
+}
+
+/**
+ * Edit an index' workflow (data alter callbacks, pre-/postprocessors, and their
+ * order).
+ *
+ * @param SearchApiIndex $index
+ *   The index to edit.
+ */
+// Copied from filter_admin_format_form
+function search_api_admin_index_workflow(array $form, array &$form_state, SearchApiIndex $index) {
+  $callback_info = search_api_get_alter_callbacks();
+  $processor_info = search_api_get_processors();
+  $options = empty($index->options) ? array() : $index->options;
+
+  $form_state['index'] = $index;
+  $form['#tree'] = TRUE;
+  $form['#attached']['js'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.js';
+
+  // Callbacks
+
+  $callbacks = empty($options['data_alter_callbacks']) ? array() : $options['data_alter_callbacks'];
+  $callback_objects = isset($form_state['callbacks']) ? $form_state['callbacks'] : array();
+  foreach ($callback_info as $name => $callback) {
+    if (!isset($callbacks[$name])) {
+      $callbacks[$name]['status'] = 0;
+      $callbacks[$name]['weight'] = $callback['weight'];
+    }
+    $settings = empty($callbacks[$name]['settings']) ? array() : $callbacks[$name]['settings'];
+    if (empty($callback_objects[$name]) && class_exists($callback['class'])) {
+      $callback_objects[$name] = new $callback['class']($index, $settings);
+    }
+    if (!(class_exists($callback['class']) && $callback_objects[$name] instanceof SearchApiAlterCallbackInterface)) {
+      watchdog('search_api', t('Data alteration @id specifies illegal callback class @class.', array('@id' => $name, '@class' => $callback['class'])), NULL, WATCHDOG_WARNING);
+      unset($callback_info[$name]);
+      unset($callbacks[$name]);
+      unset($callback_objects[$name]);
+      continue;
+    }
+    if (!$callback_objects[$name]->supportsIndex($index)) {
+      unset($callback_info[$name]);
+      unset($callbacks[$name]);
+      unset($callback_objects[$name]);
+      continue;
+    }
+  }
+  $form_state['callbacks'] = $callback_objects;
+  $form['#callbacks'] = $callbacks;
+  $form['callbacks'] = array(
+    '#type' => 'fieldset',
+    '#title' => t('Data alterations'),
+    '#description' => t('Select the alterations that will be executed on indexed items, and their order.'),
+    '#collapsible' => TRUE,
+  );
+
+  // Callback status.
+  $form['callbacks']['status'] = array(
+    '#type' => 'item',
+    '#title' => t('Enabled data alterations'),
+    '#prefix' => '<div class="search-api-status-wrapper">',
+    '#suffix' => '</div>',
+  );
+  foreach ($callback_info as $name => $callback) {
+    $form['callbacks']['status'][$name] = array(
+      '#type' => 'checkbox',
+      '#title' => $callback['name'],
+      '#default_value' => $callbacks[$name]['status'],
+      '#parents' => array('callbacks', $name, 'status'),
+      '#description' => $callback['description'],
+      '#weight' => $callback['weight'],
+    );
+  }
+
+  // Callback order (tabledrag).
+  $form['callbacks']['order'] = array(
+    '#type' => 'item',
+    '#title' => t('Data alteration processing order'),
+    '#theme' => 'search_api_admin_item_order',
+    '#table_id' => 'search-api-callbacks-order-table',
+  );
+  foreach ($callback_info as $name => $callback) {
+    $form['callbacks']['order'][$name]['item'] = array(
+      '#markup' => $callback['name'],
+    );
+    $form['callbacks']['order'][$name]['weight'] = array(
+      '#type' => 'weight',
+      '#delta' => 50,
+      '#default_value' => $callbacks[$name]['weight'],
+      '#parents' => array('callbacks', $name, 'weight'),
+    );
+    $form['callbacks']['order'][$name]['#weight'] = $callbacks[$name]['weight'];
+  }
+
+  // Callback settings.
+  $form['callbacks']['settings_title'] = array(
+    '#type' => 'item',
+    '#title' => t('Callback settings'),
+  );
+  $form['callbacks']['settings'] = array(
+    '#type' => 'vertical_tabs',
+  );
+
+  foreach ($callback_info as $name => $callback) {
+    $settings_form = $callback_objects[$name]->configurationForm();
+    if (!empty($settings_form)) {
+      $form['callbacks']['settings'][$name] = array(
+        '#type' => 'fieldset',
+        '#title' => $callback['name'],
+        '#parents' => array('callbacks', $name, 'settings'),
+        '#weight' => $callback['weight'],
+      );
+      $form['callbacks']['settings'][$name] += $settings_form;
+    }
+  }
+
+  // Processors
+
+  $processors = empty($options['processors']) ? array() : $options['processors'];
+  $processor_objects = isset($form_state['processors']) ? $form_state['processors'] : array();
+  foreach ($processor_info as $name => $processor) {
+    if (!isset($processors[$name])) {
+      $processors[$name]['status'] = 0;
+      $processors[$name]['weight'] = $processor['weight'];
+    }
+    $settings = empty($processors[$name]['settings']) ? array() : $processors[$name]['settings'];
+    if (empty($processor_objects[$name]) && class_exists($processor['class'])) {
+      $processor_objects[$name] = new $processor['class']($index, $settings);
+    }
+    if (!(class_exists($processor['class']) && $processor_objects[$name] instanceof SearchApiProcessorInterface)) {
+      watchdog('search_api', t('Processor @id specifies illegal processor class @class.', array('@id' => $name, '@class' => $processor['class'])), NULL, WATCHDOG_WARNING);
+      unset($processor_info[$name]);
+      unset($processors[$name]);
+      unset($processor_objects[$name]);
+      continue;
+    }
+    if (!$processor_objects[$name]->supportsIndex($index)) {
+      unset($processor_info[$name]);
+      unset($processors[$name]);
+      unset($processor_objects[$name]);
+      continue;
+    }
+  }
+  $form_state['processors'] = $processor_objects;
+  $form['#processors'] = $processors;
+  $form['processors'] = array(
+    '#type' => 'fieldset',
+    '#title' => t('Processors'),
+    '#description' => t('Select processors which will pre- and post-process data at index and search time, and their order. ' .
+        'Most processors will only influence fulltext fields, but refer to their individual descriptions for details regarding their effect.'),
+    '#collapsible' => TRUE,
+  );
+
+  // Processor status.
+  $form['processors']['status'] = array(
+    '#type' => 'item',
+    '#title' => t('Enabled processors'),
+    '#prefix' => '<div class="search-api-status-wrapper">',
+    '#suffix' => '</div>',
+  );
+  foreach ($processor_info as $name => $processor) {
+    $form['processors']['status'][$name] = array(
+      '#type' => 'checkbox',
+      '#title' => $processor['name'],
+      '#default_value' => $processors[$name]['status'],
+      '#parents' => array('processors', $name, 'status'),
+      '#description' => $processor['description'],
+      '#weight' => $processor['weight'],
+    );
+  }
+
+  // Processor order (tabledrag).
+  $form['processors']['order'] = array(
+    '#type' => 'item',
+    '#title' => t('Processor processing order'),
+    '#description' => t('Set the order in which preprocessing will be done at index and search time. ' .
+        'Postprocessing of search results will be in the exact opposite direction.'),
+    '#theme' => 'search_api_admin_item_order',
+    '#table_id' => 'search-api-processors-order-table',
+  );
+  foreach ($processor_info as $name => $processor) {
+    $form['processors']['order'][$name]['item'] = array(
+      '#markup' => $processor['name'],
+    );
+    $form['processors']['order'][$name]['weight'] = array(
+      '#type' => 'weight',
+      '#delta' => 50,
+      '#default_value' => $processors[$name]['weight'],
+      '#parents' => array('processors', $name, 'weight'),
+    );
+    $form['processors']['order'][$name]['#weight'] = $processors[$name]['weight'];
+  }
+
+  // Processor settings.
+  $form['processors']['settings_title'] = array(
+    '#type' => 'item',
+    '#title' => t('Processor settings'),
+  );
+  $form['processors']['settings'] = array(
+    '#type' => 'vertical_tabs',
+  );
+
+  foreach ($processor_info as $name => $processor) {
+    $settings_form = $processor_objects[$name]->configurationForm();
+    if (!empty($settings_form)) {
+      $form['processors']['settings'][$name] = array(
+        '#type' => 'fieldset',
+        '#title' => $processor['name'],
+        '#parents' => array('processors', $name, 'settings'),
+        '#weight' => $processor['weight'],
+      );
+      $form['processors']['settings'][$name] += $settings_form;
+    }
+  }
+
+  $form['actions'] = array('#type' => 'actions');
+  $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save configuration'));
+
+  return $form;
+}
+
+/**
+ * Returns HTML for a processor/callback order form.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - element: A render element representing the form.
+ */
+function theme_search_api_admin_item_order(array $variables) {
+  $element = $variables['element'];
+
+  $rows = array();
+  foreach (element_children($element, TRUE) as $name) {
+    $element[$name]['weight']['#attributes']['class'][] = 'search-api-order-weight';
+    $rows[] = array(
+      'data' => array(
+        drupal_render($element[$name]['item']),
+        drupal_render($element[$name]['weight']),
+      ),
+      'class' => array('draggable'),
+    );
+  }
+  $output = drupal_render_children($element);
+  $output .= theme('table', array('rows' => $rows, 'attributes' => array('id' => $element['#table_id'])));
+  drupal_add_tabledrag($element['#table_id'], 'order', 'sibling', 'search-api-order-weight', NULL, NULL, TRUE);
+
+  return $output;
+}
+
+/**
+ * Validation callback for search_api_admin_index_workflow.
+ */
+function search_api_admin_index_workflow_validate(array $form, array &$form_state) {
+  // Call validation functions.
+  foreach ($form_state['callbacks'] as $name => $callback) {
+    if (isset($form['callbacks']['settings'][$name]) && isset($form_state['values']['callbacks'][$name]['settings'])) {
+      $callback->configurationFormValidate($form['callbacks']['settings'][$name], $form_state['values']['callbacks'][$name]['settings'], $form_state);
+    }
+  }
+  foreach ($form_state['processors'] as $name => $processor) {
+    if (isset($form['processors']['settings'][$name]) && isset($form_state['values']['processors'][$name]['settings'])) {
+      $processor->configurationFormValidate($form['processors']['settings'][$name], $form_state['values']['processors'][$name]['settings'], $form_state);
+    }
+  }
+}
+
+/**
+ * Submit callback for search_api_admin_index_workflow.
+ */
+function search_api_admin_index_workflow_submit(array $form, array &$form_state) {
+  $values = $form_state['values'];
+  unset($values['callbacks']['settings']);
+  unset($values['processors']['settings']);
+  $index = $form_state['index'];
+
+  $options = empty($index->options) ? array() : $index->options;
+  $fields_set = !empty($options['fields']);
+
+  // Store callback and processor settings.
+  foreach ($form_state['callbacks'] as $name => $callback) {
+    $callback_form = isset($form['callbacks']['settings'][$name]) ? $form['callbacks']['settings'][$name] : array();
+    $values['callbacks'][$name] += array('settings' => array());
+    $values['callbacks'][$name]['settings'] = $callback->configurationFormSubmit($callback_form, $values['callbacks'][$name]['settings'], $form_state);
+  }
+  foreach ($form_state['processors'] as $name => $processor) {
+    $processor_form = isset($form['processors']['settings'][$name]) ? $form['processors']['settings'][$name] : array();
+    $values['processors'][$name] += array('settings' => array());
+    $values['processors'][$name]['settings'] = $processor->configurationFormSubmit($processor_form, $values['processors'][$name]['settings'], $form_state);
+  }
+
+  $types = search_api_field_types();
+  foreach ($form_state['callbacks'] as $name => $callback) {
+    // Check whether callback status has changed.
+    if ($values['callbacks'][$name]['status'] == empty($options['data_alter_callbacks'][$name]['status'])) {
+      if ($values['callbacks'][$name]['status']) {
+        // Callback was just enabled, add its fields.
+        $properties = $callback->propertyInfo();
+        if ($properties) {
+          foreach ($properties as $key => $field) {
+            $type = $field['type'];
+            $inner = search_api_extract_inner_type($type);
+            if ($inner != 'token' && empty($types[$inner])) {
+              // Someone apparently added a structure or entity as a property in a data-alter callback.
+              continue;
+            }
+            if ($inner == 'token' || (search_api_is_text_type($inner) && !empty($field['options list']))) {
+              $old = $type;
+              $type = 'string';
+              while (search_api_is_list_type($old)) {
+                $old = substr($old, 5, -1);
+                $type = "list<$type>";
+              }
+            }
+            $index->options['fields'][$key] = array(
+              'type' => $type,
+            );
+          }
+        }
+      }
+      else {
+        // Callback was just disabled, remove its fields.
+        $properties = $callback->propertyInfo();
+        if ($properties) {
+          foreach ($properties as $key => $field) {
+            unset($index->options['fields'][$key]);
+          }
+        }
+
+      }
+    }
+  }
+
+  if (!isset($options['data_alter_callbacks']) || !isset($options['processors'])
+      || $options['data_alter_callbacks'] != $values['callbacks']
+      || $options['processors'] != $values['processors']) {
+    $index->options['data_alter_callbacks'] = $values['callbacks'];
+    $index->options['processors'] = $values['processors'];
+
+    // Save the already sorted arrays to avoid having to sort them at each use.
+    uasort($index->options['data_alter_callbacks'], 'search_api_admin_element_compare');
+    uasort($index->options['processors'], 'search_api_admin_element_compare');
+
+    // Reset the index's internal property cache to correctly incorporate the
+    // new data alterations.
+    $index->resetCaches();
+
+    $index->save();
+    $index->reindex();
+    drupal_set_message(t("The search index' workflow was successfully edited. " .
+        'All content was scheduled for re-indexing so the new settings can take effect.'));
+  }
+  else {
+    drupal_set_message(t('No values were changed.'));
+  }
+
+  $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/workflow';
+}
+
+/**
+ * Sort callback sorting array elements by their "weight" key, if present.
+ *
+ * @see element_sort
+ */
+function search_api_admin_element_compare($a, $b) {
+  $a_weight = (is_array($a) && isset($a['weight'])) ? $a['weight'] : 0;
+  $b_weight = (is_array($b) && isset($b['weight'])) ? $b['weight'] : 0;
+  if ($a_weight == $b_weight) {
+    return 0;
+  }
+  return ($a_weight < $b_weight) ? -1 : 1;
+}
+
+/**
+ * Select the indexed fields.
+ *
+ * @param SearchApiIndex $index
+ *   The index to edit.
+ */
+function search_api_admin_index_fields(array $form, array &$form_state, SearchApiIndex $index) {
+  $options = $index->getFields(FALSE, TRUE);
+  $fields = $options['fields'];
+  $additional = $options['additional fields'];
+
+  // An array of option arrays for types, keyed by nesting level.
+  $types = array(0 => search_api_field_types());
+  $fulltext_type = array(0 => 'text');
+  $entity_types = entity_get_info();
+  $default_types = search_api_default_field_types();
+  $boosts = drupal_map_assoc(array('0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0'));
+
+  $form_state['index'] = $index;
+  $form['#theme'] = 'search_api_admin_fields_table';
+  $form['#tree'] = TRUE;
+  $form['description'] = array(
+    '#type' => 'item',
+    '#title' => t('Select fields to index'),
+    '#description' => t('<p>The datatype of a field determines how it can be used for searching and filtering. ' .
+        'The boost is used to give additional weight to certain fields, e.g. titles or tags. It only takes effect for fulltext fields.</p>' .
+        '<p>Whether detailed field types are supported depends on the type of server this index resides on. ' .
+        'In any case, fields of type "Fulltext" will always be fulltext-searchable.</p>'),
+  );
+  if ($index->server) {
+    $form['description']['#description'] .= '<p>' . t('Check the <a href="@server-url">' . "server's</a> service class description for details.",
+        array('@server-url' => url('admin/config/search/search_api/server/' . $index->server))) . '</p>';
+  }
+  foreach ($fields as $key => $info) {
+    $form['fields'][$key]['title']['#markup'] = check_plain($info['name']);
+    if (isset($info['description'])) {
+      $form['fields'][$key]['description'] = array(
+        '#type' => 'value',
+        '#value' => $info['description'],
+      );
+    }
+    $form['fields'][$key]['indexed'] = array(
+      '#type' => 'checkbox',
+      '#default_value' => $info['indexed'],
+    );
+    if (empty($info['entity_type'])) {
+      // Determine the correct type options (i.e., with the correct nesting level).
+      $level = search_api_list_nesting_level($info['type']);
+      if (empty($types[$level])) {
+        $type_prefix = str_repeat('list<', $level);
+        $type_suffix = str_repeat('>', $level);
+        $types[$level] = array();
+        foreach ($types[0] as $type => $name) {
+          // We use the singular name for list types, since the user usually doesn't care about the nesting level.
+          $types[$level][$type_prefix . $type . $type_suffix] = $name;
+        }
+        $fulltext_type[$level] = $type_prefix . 'text' . $type_suffix;
+      }
+      $css_key = '#edit-fields-' . drupal_clean_css_identifier($key);
+      $form['fields'][$key]['type'] = array(
+        '#type' => 'select',
+        '#options' => $types[$level],
+        '#default_value' => isset($info['real_type']) ? $info['real_type'] : $info['type'],
+        '#states' => array(
+          'visible' => array(
+            $css_key . '-indexed' => array('checked' => TRUE),
+          ),
+        ),
+      );
+      $form['fields'][$key]['boost'] = array(
+        '#type' => 'select',
+        '#options' => $boosts,
+        '#default_value' => $info['boost'],
+        '#states' => array(
+          'visible' => array(
+            $css_key . '-indexed' => array('checked' => TRUE),
+            $css_key . '-type' => array('value' => $fulltext_type[$level]),
+          ),
+        ),
+      );
+    }
+    else {
+      // This is an entity.
+      $label = $entity_types[$info['entity_type']]['label'];
+      if (!isset($entity_description_added)) {
+        $form['description']['#description'] .= '<p>' .
+            t('Note that indexing an entity-valued field (like %field, which has type %type) directly will only index the entity ID. ' .
+            'This will be used for filtering and also sorting (which might not be what you expect). ' .
+            'The entity label will usually be used when displaying the field, though. ' .
+            'Use the "Add related fields" option at the bottom for indexing other fields of related entities.',
+            array('%field' => $info['name'], '%type' => $label)) . '</p>';
+        $entity_description_added = TRUE;
+      }
+      $form['fields'][$key]['type'] = array(
+        '#type' => 'value',
+        '#value' => $info['type'],
+      );
+      $form['fields'][$key]['entity_type'] = array(
+        '#type' => 'value',
+        '#value' => $info['entity_type'],
+      );
+      $form['fields'][$key]['type_name'] = array(
+        '#markup' => check_plain($label),
+      );
+      $form['fields'][$key]['boost'] = array(
+        '#type' => 'value',
+        '#value' => $info['boost'],
+      );
+      $form['fields'][$key]['boost_text'] = array(
+        '#markup' => '&nbsp;',
+      );
+    }
+    if ($key == 'search_api_language') {
+      // Is treated specially to always index the language.
+      $form['fields'][$key]['type']['#default_value'] = 'string';
+      $form['fields'][$key]['type']['#disabled'] = TRUE;
+      $form['fields'][$key]['boost']['#default_value'] = '1.0';
+      $form['fields'][$key]['boost']['#disabled'] = TRUE;
+      $form['fields'][$key]['indexed']['#default_value'] = 1;
+      $form['fields'][$key]['indexed']['#disabled'] = TRUE;
+    }
+  }
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Save changes'),
+  );
+
+  if ($additional) {
+    reset($additional);
+    $form['additional'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Add related fields'),
+      '#description' => t('There are entities related to entities of this type. ' .
+          'You can add their fields to the list above so they can be indexed too.') . '<br />',
+      '#collapsible' => TRUE,
+      '#collapsed' => TRUE,
+      '#attributes' => array('class' => array('container-inline')),
+      'field' => array(
+        '#type' => 'select',
+        '#options' => $additional,
+        '#default_value' => key($additional),
+      ),
+      'add' => array(
+        '#type' => 'submit',
+        '#value' => t('Add fields'),
+      ),
+    );
+  }
+
+  return $form;
+}
+
+/**
+ * Helper function for building the field list for an index.
+ *
+ * @deprecated Use SearchApiIndex::getFields() instead.
+ */
+function _search_api_admin_get_fields(SearchApiIndex $index, EntityMetadataWrapper $wrapper) {
+  $fields = empty($index->options['fields']) ? array() : $index->options['fields'];
+  $additional = array();
+  $entity_types = entity_get_info();
+
+  // First we need all already added prefixes.
+  $added = array();
+  foreach (array_keys($fields) as $key) {
+    $key = substr($key, 0, strrpos($key, ':'));
+    $added[$key] = TRUE;
+  }
+
+  // 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];
+        }
+      }
+      // 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])) {
+          if (isset($fields[$key])) {
+            // This field is already known in the index configuration.
+            $fields[$key]['name'] = $prefix_name . $info['label'];
+            $fields[$key]['description'] = empty($info['description']) ? NULL : $info['description'];
+            $flat[$key] = $fields[$key];
+            // Update its type.
+            if (isset($entity_types[$type])) {
+              // Always enforce the proper entity type.
+              $flat[$key]['type'] = $info['type'];
+            }
+            else {
+              // Else, only update the nesting level.
+              $set_type = search_api_extract_inner_type(isset($flat[$key]['real_type']) ? $flat[$key]['real_type'] : $flat[$key]['type']);
+              $flat[$key]['type'] = $info['type'];
+              $flat[$key]['real_type'] = search_api_nest_type($set_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 (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 . ']';
+            }
+            $additional[$key] = $name;
+            $used_names[$name] = $key;
+          }
+        }
+      }
+      unset($wrappers[$prefix]);
+    }
+  }
+
+  $options = array();
+  $options['fields'] = $flat;
+  $options['additional fields'] = $additional;
+  return $options;
+}
+
+/**
+ * Returns HTML for a field list form.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - element: A render element representing the form.
+ */
+function theme_search_api_admin_fields_table($variables) {
+  $form = $variables['element'];
+  $header = array(t('Field'), t('Indexed'), t('Type'), t('Boost'));
+
+  $rows = array();
+  foreach (element_children($form['fields']) as $name) {
+    $row = array();
+    foreach (element_children($form['fields'][$name]) as $field) {
+      if ($cell = render($form['fields'][$name][$field])) {
+        $row[] = $cell;
+      }
+    }
+    if (empty($form['fields'][$name]['description']['#value'])) {
+      $rows[] = $row;
+    }
+    else {
+      $rows[] = array(
+        'data' => $row,
+        'title' => strip_tags($form['fields'][$name]['description']['#value']),
+      );
+    }
+  }
+
+  $submit = $form['submit'];
+  $additional = isset($form['additional']) ? $form['additional'] : FALSE;
+  unset($form['submit'], $form['additional']);
+  $output = drupal_render_children($form);
+  $output .= theme('table', array('header' => $header, 'rows' => $rows));
+  $output .= render($submit);
+  if ($additional) {
+    $output .= render($additional);
+  }
+
+  return $output;
+}
+
+/**
+ * Submit function for search_api_admin_index_fields.
+ */
+function search_api_admin_index_fields_submit(array $form, array &$form_state) {
+  $index = $form_state['index'];
+  $options = isset($index->options) ? $index->options : array();
+  if ($form_state['values']['op'] == t('Save changes')) {
+    $fields = $form_state['values']['fields'];
+    $default_types = search_api_default_field_types();
+    $custom_types = search_api_get_data_type_info();
+    foreach ($fields as $name => $field) {
+      if (empty($field['indexed'])) {
+        unset($fields[$name]);
+      }
+      else {
+        // Don't store the description. "indexed" is implied.
+        unset($fields[$name]['description'], $fields[$name]['indexed']);
+        // For non-default types, set type to the fallback and only real_type to
+        // the custom type.
+        $inner_type = search_api_extract_inner_type($field['type']);
+        if (!isset($default_types[$inner_type])) {
+          $fields[$name]['real_type'] = $field['type'];
+          $fields[$name]['type'] = search_api_nest_type($custom_types[$inner_type]['fallback'], $field['type']);
+        }
+        // Boost defaults to 1.0.
+        if ($field['boost'] == '1.0') {
+          unset($fields[$name]['boost']);
+        }
+      }
+    }
+    $options['fields'] = $fields;
+    unset($options['additional fields']);
+    $ret = $index->update(array('options' => $options));
+
+    if ($ret) {
+      drupal_set_message(t('The indexed fields were successfully changed. ' .
+          'The index was cleared and will have to be re-indexed with the new settings.'));
+    }
+    else {
+      drupal_set_message(t('No values were changed.'));
+    }
+    if (isset($index->options['data_alter_callbacks']) || isset($index->options['processors'])) {
+      $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/fields';
+    }
+    else {
+      drupal_set_message(t('Please set up the index workflow.'));
+      $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/workflow';
+    }
+    return;
+  }
+  // Adding a related entity's fields.
+  $prefix = $form_state['values']['additional']['field'];
+  $options['additional fields'][$prefix] = $prefix;
+  $ret = $index->update(array('options' => $options));
+
+  if ($ret) {
+    drupal_set_message(t('The available fields were successfully changed.'));
+  }
+  else {
+    drupal_set_message(t('No values were changed.'));
+  }
+  $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/fields';
+}
+
+
+/**
+ * Helper function for displaying a generic confirmation form.
+ *
+ * @return
+ *   Either a form array, or FALSE if this combination of type and action is
+ *   not supported.
+ */
+function search_api_admin_confirm(array $form, array &$form_state, $type, $action, Entity $entity) {
+  switch ($type) {
+    case 'server':
+      switch ($action) {
+        case 'disable':
+          $text = array(
+            t('Disable server @name', array('@name' => $entity->name)),
+            t('Do you really want to disable this server?'),
+            t('This will disable both the server and all associated indexes. ' .
+                "Searches on these indexes won't be available until they are re-enabled."),
+            t('The server and its indexes were successfully disabled.'),
+          );
+          break;
+        case 'delete':
+          if ($entity->hasStatus(ENTITY_OVERRIDDEN)) {
+            $text = array(
+              t('Revert server @name', array('@name' => $entity->name)),
+              t('Do you really want to revert this server?'),
+              t('This will revert all settings for this server back to the defaults. This action cannot be undone.'),
+              t('The server settings have been successfully reverted.'),
+            );
+          }
+          else {
+            $text = array(
+              t('Delete server @name', array('@name' => $entity->name)),
+              t('Do you really want to delete this server?'),
+              t('This will delete the server and disable all associated indexes. ' .
+                  "Searches on these indexes won't be available until they are moved to another server and re-enabled."),
+              t('The server was successfully deleted.'),
+            );
+          }
+          break;
+        default:
+          return FALSE;
+      }
+      break;
+    case 'index':
+      switch ($action) {
+        case 'disable':
+          $text = array(
+            t('Disable index @name', array('@name' => $entity->name)),
+            t('Do you really want to disable this index?'),
+            t("Searches on this index won't be available until it is re-enabled."),
+            t('The index was successfully disabled.'),
+          );
+          break;
+        case 'delete':
+          if ($entity->hasStatus(ENTITY_OVERRIDDEN)) {
+            $text = array(
+              t('Revert index @name', array('@name' => $entity->name)),
+              t('Do you really want to revert this index?'),
+              t('This will revert all settings on this index back to the defaults. This action cannot be undone.'),
+              t('The index settings have been successfully reverted.'),
+            );
+          }
+          else {
+            $text = array(
+              t('Delete index @name', array('@name' => $entity->name)),
+              t('Do you really want to delete this index?'),
+              t('This will remove the index from the server and delete all settings. ' .
+                  'All data on this index will be lost.'),
+              t('The index has been successfully deleted.'),
+            );
+          }
+          break;
+        default:
+          return FALSE;
+      }
+      break;
+    default:
+      return FALSE;
+  }
+
+  $form = array(
+    'type' => array(
+      '#type' => 'value',
+      '#value' => $type,
+    ),
+    'action' => array(
+      '#type' => 'value',
+      '#value' => $action,
+    ),
+    'id' => array(
+      '#type' => 'value',
+      '#value' => $entity->machine_name,
+    ),
+    'message' => array(
+      '#type' => 'value',
+      '#value' => $text[3],
+    ),
+  );
+  $desc = "<h3>{$text[1]}</h3><p>{$text[2]}</p>";
+  return confirm_form($form, $text[0], "admin/config/search/search_api/$type/{$entity->machine_name}", $desc);
+}
+
+/**
+ * Submit function for search_api_admin_confirm().
+ */
+function search_api_admin_confirm_submit(array $form, array &$form_state) {
+  $values = $form_state['values'];
+
+  $type = $values['type'];
+  $action = $values['action'];
+  $id = $values['id'];
+
+  $function = "search_api_{$type}_{$action}";
+  if ($function($id)) {
+    drupal_set_message($values['message']);
+  }
+  else {
+    drupal_set_message(t('An error has occurred while performing the desired action. Check the logs for details.'), 'error');
+  }
+
+  $form_state['redirect'] = $action == 'delete'
+      ? "admin/config/search/search_api"
+      : "admin/config/search/search_api/$type/$id";
+}

+ 61 - 0
search_api.admin.js

@@ -0,0 +1,61 @@
+
+// Copied from filter.admin.js
+(function ($) {
+
+Drupal.behaviors.searchApiStatus = {
+  attach: function (context, settings) {
+    $('.search-api-status-wrapper input.form-checkbox', context).once('search-api-status', function () {
+      var $checkbox = $(this);
+      // Retrieve the tabledrag row belonging to this processor.
+      var $row = $('#' + $checkbox.attr('id').replace(/-status$/, '-weight'), context).closest('tr');
+      // Retrieve the vertical tab belonging to this processor.
+      var $tab = $('#' + $checkbox.attr('id').replace(/-status$/, '-settings'), context).data('verticalTab');
+
+      // Bind click handler to this checkbox to conditionally show and hide the
+      // filter's tableDrag row and vertical tab pane.
+      $checkbox.bind('click.searchApiUpdate', function () {
+        if ($checkbox.is(':checked')) {
+          $row.show();
+          if ($tab) {
+            $tab.tabShow().updateSummary();
+          }
+        }
+        else {
+          $row.hide();
+          if ($tab) {
+            $tab.tabHide().updateSummary();
+          }
+        }
+        // Restripe table after toggling visibility of table row.
+        Drupal.tableDrag['search-api-' + $checkbox.attr('id').replace(/^edit-([^-]+)-.*$/, '$1') + '-order-table'].restripeTable();
+      });
+
+      // Attach summary for configurable items (only for screen-readers).
+      if ($tab) {
+        $tab.fieldset.drupalSetSummary(function (tabContext) {
+          return $checkbox.is(':checked') ? Drupal.t('Enabled') : Drupal.t('Disabled');
+        });
+      }
+
+      // Trigger our bound click handler to update elements to initial state.
+      $checkbox.triggerHandler('click.searchApiUpdate');
+    });
+  }
+};
+
+Drupal.behaviors.searchApiEditMenu = {
+  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');
+      }
+      return false;
+    });
+  }
+};
+
+})(jQuery);

+ 535 - 0
search_api.api.php

@@ -0,0 +1,535 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Search API module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Defines one or more search service classes a module offers.
+ *
+ * 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:
+ *   - name: The service class' translated name.
+ *   - 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.
+ *   - class: The service class, which has to implement the
+ *     SearchApiServiceInterface interface.
+ */
+function hook_search_api_service_info() {
+  $services['example_some'] = array(
+    'name' => t('Some Service'),
+    'description' => t('Service for some search engine.'),
+    'class' => 'SomeServiceClass',
+    // Unknown keys can later be read by the object for additional information.
+    'init args' => array('foo' => 'Foo', 'bar' => 42),
+  );
+  $services['example_other'] = array(
+    'name' => t('Other Service'),
+    'description' => t('Service for another search engine.'),
+    'class' => 'OtherServiceClass',
+  );
+
+  return $services;
+}
+
+/**
+ * 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()
+ *
+ * @param array $service_info
+ *   The Search API service info array, keyed by service id.
+ */
+function hook_search_api_service_info_alter(array &$service_info) {
+  foreach ($service_info as $id => $info) {
+    $service_info[$id]['class'] = 'MyProxyServiceClass';
+    $service_info[$id]['example_original_class'] = $info['class'];
+  }
+}
+
+/**
+ * Define new types of items that can be searched.
+ *
+ * This hook allows modules to define their own item types, for which indexes
+ * can then be created. (Note that the Search API natively provides support for
+ * all entity types that specify property information, so they should not be
+ * added here. You should therefore also not use an existing entity type as the
+ * identifier of a new item type.)
+ *
+ * The main part of defining a new item type is implementing its data source
+ * controller class, which is responsible for loading items, providing metadata
+ * and tracking existing items. The module defining a certain item type is also
+ * responsible for observing creations, updates and deletions of items of that
+ * type and notifying the Search API of them by calling
+ * search_api_track_item_insert(), search_api_track_item_change() and
+ * search_api_track_item_delete(), as appropriate.
+ * The only other restriction for item types is that they have to have a single
+ * item ID field, with a scalar value. This is, e.g., used to track indexed
+ * items.
+ *
+ * Note, however, that you can also define item types where some of these
+ * conditions are not met, as long as you are aware that some functionality of
+ * the Search API and related modules might then not be available for that type.
+ *
+ * @return array
+ *   An associative array keyed by item type identifier, and containing type
+ *   information arrays with at least the following keys:
+ *   - name: A human-readable name for the type.
+ *   - datasource controller: A class implementing the
+ *     SearchApiDataSourceControllerInterface interface which will be used as
+ *     the data source controller for this type.
+ *   Other, datasource-specific settings might also be placed here. These should
+ *   be specified with the data source controller in question.
+ *
+ * @see hook_search_api_item_type_info_alter()
+ */
+function hook_search_api_item_type_info() {
+  // Copied from search_api_search_api_item_type_info().
+  $types = array();
+
+  foreach (entity_get_property_info() as $type => $property_info) {
+    if ($info = entity_get_info($type)) {
+      $types[$type] = array(
+        'name' => $info['label'],
+        'datasource controller' => 'SearchApiEntityDataSourceController',
+      );
+    }
+  }
+
+  return $types;
+}
+
+/**
+ * Alter the 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.
+ *
+ * @param array $infos
+ *   The item type info array, keyed by type identifier.
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function hook_search_api_item_type_info_alter(array &$infos) {
+  // Adds a boolean value is_entity to all type options telling whether the item
+  // type represents an entity type.
+  foreach ($infos as $type => $info) {
+    $info['is_entity'] = (bool) entity_get_info($type);
+  }
+}
+
+/**
+ * Define new data types for indexed properties.
+ *
+ * New data types will appear as new option for the „Type“ field on indexes'
+ * „Fields“ tabs. Whether choosing a custom data type will have any effect
+ * depends on the server on which the data is indexed.
+ *
+ * @return array
+ *   An array containing custom data type definitions, keyed by their type
+ *   identifier and containing the following keys:
+ *   - name: The human-readable name of the type.
+ *   - fallback: (optional) One of the default data types (the keys from
+ *     search_api_default_field_types()) which should be used as a fallback if
+ *     the server doesn't support this data type. Defaults to "string".
+ *   - conversion callback: (optional) If specified, a callback function for
+ *     converting raw values to the given type, if possible. For the contract
+ *     of such a callback, see example_data_type_conversion().
+ *
+ * @see hook_search_api_data_type_info_alter()
+ * @see search_api_get_data_type_info()
+ * @see example_data_type_conversion()
+ */
+function hook_search_api_data_type_info() {
+  return array(
+    'example_type' => array(
+      'name' => t('Example type'),
+      // Could be omitted, as "string" is the default.
+      'fallback' => 'string',
+      'conversion callback' => 'example_data_type_conversion',
+    ),
+  );
+}
+
+/**
+ * Alter the data type info.
+ *
+ * Modules may implement this hook to alter the information that defines a data
+ * type, or to add/remove some entirely. All properties that are available in
+ * hook_search_api_data_type_info() can be altered here.
+ *
+ * @param array $infos
+ *   The data type info array, keyed by type identifier.
+ *
+ * @see hook_search_api_data_type_info()
+ */
+function hook_search_api_data_type_info_alter(array &$infos) {
+  $infos['example_type']['name'] .= ' 2';
+}
+
+/**
+ * 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.
+ *
+ * Data-alter callbacks (which are called "Data alterations" in the UI) are
+ * classes implementing the SearchApiAlterCallbackInterface interface.
+ *
+ * @see SearchApiAlterCallbackInterface
+ *
+ * @return array
+ *   An associative array keyed by the callback IDs and containing arrays with
+ *   the following keys:
+ *   - name: The name to display for this callback.
+ *   - description: A short description of what the callback does.
+ *   - class: The callback class.
+ *   - weight: (optional) Defines the order in which callbacks are displayed
+ *     (and, therefore, invoked) by default. Defaults to 0.
+ */
+function hook_search_api_alter_callback_info() {
+  $callbacks['example_random_alter'] = array(
+    'name' => t('Random alteration'),
+    'description' => t('Alters all passed item data completely randomly.'),
+    'class' => 'ExampleRandomAlter',
+    'weight' => 100,
+  );
+  $callbacks['example_add_comments'] = array(
+    'name' => t('Add comments'),
+    'description' => t('For nodes and similar entities, adds comments.'),
+    'class' => 'ExampleAddComments',
+  );
+
+  return $callbacks;
+}
+
+/**
+ * Registers one or more processors. These are classes implementing the
+ * SearchApiProcessorInterface interface which can be used at index and search
+ * time to pre-process item data or the search query, and at search time to
+ * post-process the returned search results.
+ *
+ * @see SearchApiProcessorInterface
+ *
+ * @return array
+ *   An associative array keyed by the processor id and containing arrays
+ *   with the following keys:
+ *   - name: The name to display for this processor.
+ *   - description: A short description of what the processor does at each
+ *     phase.
+ *   - class: The processor class, which has to implement the
+ *     SearchApiProcessorInterface interface.
+ *   - weight: (optional) Defines the order in which processors are displayed
+ *     (and, therefore, invoked) by default. Defaults to 0.
+ */
+function hook_search_api_processor_info() {
+  $callbacks['example_processor'] = array(
+    'name' => t('Example processor'),
+    'description' => t('Pre- and post-processes data in really cool ways.'),
+    'class' => 'ExampleSearchApiProcessor',
+    'weight' => -1,
+  );
+  $callbacks['example_processor_minimal'] = array(
+    'name' => t('Example processor 2'),
+    'description' => t('Processor with minimal description.'),
+    'class' => 'ExampleSearchApiProcessor2',
+  );
+
+  return $callbacks;
+}
+
+/**
+ * Allows you to log or alter the items that are indexed.
+ *
+ * Please be aware that generally preventing the indexing of certain items is
+ * deprecated. This is better done with data alterations, which can easily be
+ * configured and only added to indexes where this behaviour is wanted.
+ * If your module will use this hook to reject certain items from indexing,
+ * please document this clearly to avoid confusion.
+ *
+ * @param array $items
+ *   The entities that will be indexed (before calling any data alterations).
+ * @param SearchApiIndex $index
+ *   The search index on which items will be indexed.
+ */
+function hook_search_api_index_items_alter(array &$items, SearchApiIndex $index) {
+  foreach ($items as $id => $item) {
+    if ($id % 5 == 0) {
+      unset($items[$id]);
+    }
+  }
+  example_store_indexed_entity_ids($index->item_type, array_keys($items));
+}
+
+/**
+ * Lets modules alter a search query before executing it.
+ *
+ * @param SearchApiQueryInterface $query
+ *   The SearchApiQueryInterface object representing the search query.
+ */
+function hook_search_api_query_alter(SearchApiQueryInterface $query) {
+  $info = entity_get_info($index->item_type);
+  $query->condition($info['entity keys']['id'], 0, '!=');
+}
+
+/**
+ * Act on search servers when they are loaded.
+ *
+ * @param array $servers
+ *   An array of loaded SearchApiServer objects.
+ */
+function hook_search_api_server_load(array $servers) {
+  foreach ($servers as $server) {
+    db_insert('example_search_server_access')
+      ->fields(array(
+        'server' => $server->machine_name,
+        'access_time' => REQUEST_TIME,
+      ))
+      ->execute();
+  }
+}
+
+/**
+ * A new search server was created.
+ *
+ * @param SearchApiServer $server
+ *   The new server.
+ */
+function hook_search_api_server_insert(SearchApiServer $server) {
+  db_insert('example_search_server')
+    ->fields(array(
+      'server' => $server->machine_name,
+      'insert_time' => REQUEST_TIME,
+    ))
+    ->execute();
+}
+
+/**
+ * A search server was edited, enabled or disabled.
+ *
+ * @param SearchApiServer $server
+ *   The edited server.
+ */
+function hook_search_api_server_update(SearchApiServer $server) {
+  if ($server->name != $server->original->name) {
+    db_insert('example_search_server_name_update')
+      ->fields(array(
+        'server' => $server->machine_name,
+        'update_time' => REQUEST_TIME,
+      ))
+      ->execute();
+  }
+}
+
+/**
+ * A search server was deleted.
+ *
+ * @param SearchApiServer $server
+ *   The deleted server.
+ */
+function hook_search_api_server_delete(SearchApiServer $server) {
+  db_insert('example_search_server_update')
+    ->fields(array(
+      'server' => $server->machine_name,
+      'update_time' => REQUEST_TIME,
+    ))
+    ->execute();
+  db_delete('example_search_server')
+    ->condition('server', $server->machine_name)
+    ->execute();
+}
+
+/**
+* Define default search servers.
+*
+* @return array
+*   An array of default search servers, keyed by machine names.
+*
+* @see hook_default_search_api_server_alter()
+*/
+function hook_default_search_api_server() {
+  $defaults['main'] = entity_create('search_api_server', array(
+    'name' => 'Main server',
+    'machine_name' => 'main',// Must be same as the used array key.
+    // Other properties ...
+  ));
+  return $defaults;
+}
+
+/**
+* Alter default search servers.
+*
+* @param array $defaults
+*   An array of default search servers, keyed by machine names.
+*
+* @see hook_default_search_api_server()
+*/
+function hook_default_search_api_server_alter(array &$defaults) {
+  $defaults['main']->name = 'Customized main server';
+}
+
+/**
+ * Act on search indexes when they are loaded.
+ *
+ * @param array $indexes
+ *   An array of loaded SearchApiIndex objects.
+ */
+function hook_search_api_index_load(array $indexes) {
+  foreach ($indexes as $index) {
+    db_insert('example_search_index_access')
+      ->fields(array(
+        'index' => $index->machine_name,
+        'access_time' => REQUEST_TIME,
+      ))
+      ->execute();
+  }
+}
+
+/**
+ * A new search index was created.
+ *
+ * @param SearchApiIndex $index
+ *   The new index.
+ */
+function hook_search_api_index_insert(SearchApiIndex $index) {
+  db_insert('example_search_index')
+    ->fields(array(
+      'index' => $index->machine_name,
+      'insert_time' => REQUEST_TIME,
+    ))
+    ->execute();
+}
+
+/**
+ * A search index was edited in any way.
+ *
+ * @param SearchApiIndex $index
+ *   The edited index.
+ */
+function hook_search_api_index_update(SearchApiIndex $index) {
+  if ($index->name != $index->original->name) {
+    db_insert('example_search_index_name_update')
+      ->fields(array(
+        'index' => $index->machine_name,
+        'update_time' => REQUEST_TIME,
+      ))
+      ->execute();
+  }
+}
+
+/**
+ * A search index was scheduled for reindexing
+ *
+ * @param SearchApiIndex $index
+ *   The edited index.
+ * @param $clear
+ *   Boolean indicating whether the index was also cleared.
+ */
+function hook_search_api_index_reindex(SearchApiIndex $index, $clear = FALSE) {
+  db_insert('example_search_index_reindexed')
+    ->fields(array(
+      'index' => $index->id,
+      'update_time' => REQUEST_TIME,
+    ))
+    ->execute();
+}
+
+/**
+ * A search index was deleted.
+ *
+ * @param SearchApiIndex $index
+ *   The deleted index.
+ */
+function hook_search_api_index_delete(SearchApiIndex $index) {
+  db_insert('example_search_index_update')
+    ->fields(array(
+      'index' => $index->machine_name,
+      'update_time' => REQUEST_TIME,
+    ))
+    ->execute();
+  db_delete('example_search_index')
+    ->condition('index', $index->machine_name)
+    ->execute();
+}
+
+/**
+* Define default search indexes.
+*
+* @return array
+*   An array of default search indexes, keyed by machine names.
+*
+* @see hook_default_search_api_index_alter()
+*/
+function hook_default_search_api_index() {
+  $defaults['main'] = entity_create('search_api_index', array(
+    'name' => 'Main index',
+    'machine_name' => 'main',// Must be same as the used array key.
+    // Other properties ...
+  ));
+  return $defaults;
+}
+
+/**
+* Alter default search indexes.
+*
+* @param array $defaults
+*   An array of default search indexes, keyed by machine names.
+*
+* @see hook_default_search_api_index()
+*/
+function hook_default_search_api_index_alter(array &$defaults) {
+  $defaults['main']->name = 'Customized main index';
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
+
+/**
+ * Convert a raw value from an entity to a custom data type.
+ *
+ * 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
+ *   The raw, single value, as extracted from an entity wrapper.
+ * @param $original_type
+ *   The original Entity API type of the value.
+ * @param $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
+ *   The converted value, if a conversion could be executed. NULL otherwise.
+ *
+ * @see hook_search_api_data_type_info()
+ */
+function example_data_type_conversion($value, $original_type, $type) {
+  if ($type === 'example_type') {
+    // The example_type type apparently requires a rather complex data format.
+    return array(
+      'value' => $value,
+      'original' => $original_type,
+    );
+  }
+  // Someone used this callback for another, unknown type. Return NULL.
+  // (Normally, you can just assume that the/a correct type is given.)
+  return NULL;
+}

+ 311 - 0
search_api.drush.inc

@@ -0,0 +1,311 @@
+<?php
+
+/**
+ * @file
+ * Drush commands for SearchAPI.
+ *
+ * Original file by agentrickard for Palantir.net
+ */
+
+/**
+ * Implements hook_drush_command().
+ */
+function search_api_drush_command() {
+  $items = array();
+
+  $items['search-api-list'] = array(
+    'description' => 'List all search indexes.',
+    'examples' => array(
+      'drush searchapi-list' => dt('List all search indexes.'),
+      'drush sapi-l' => dt('Alias to list all search indexes.'),
+    ),
+    'aliases' => array('sapi-l'),
+  );
+
+  $items['search-api-status'] = array(
+    'description' => 'Show the status of one or all search indexes.',
+    'examples' => array(
+      'drush searchapi-status' => dt('Show the status of all search indexes.'),
+      'drush sapi-s' => dt('Alias to show the status of all search indexes.'),
+      'drush sapi-s 1' => dt('Show the status of the search index with the ID !id.', array('!id' => 1)),
+      'drush sapi-s default_node_index' => dt('Show the status of 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.'),
+    ),
+    'aliases' => array('sapi-s'),
+  );
+
+  $items['search-api-index'] = array(
+    'description' => 'Index items for one or all enabled search_api indexes.',
+    'examples' => array(
+      'drush searchapi-index' => dt('Index items for all enabled indexes.'),
+      'drush sapi-i' => dt('Alias to index items for all enabled indexes.'),
+      'drush sapi-i 1' => dt('Index items for the index with the ID !id.', array('!id' => 1)),
+      '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)),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index.'),
+      '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."),
+    ),
+    'aliases' => array('sapi-i'),
+  );
+
+  $items['search-api-reindex'] = array(
+    'description' => 'Force reindexing of one or all search indexes, without clearing existing index data.',
+    'examples' => array(
+      'drush searchapi-reindex' => dt('Schedule all search indexes for reindexing.'),
+      'drush sapi-r' => dt('Alias to schedule all search indexes for reindexing .'),
+      'drush sapi-r 1' => dt('Schedule the search index with the ID !id for re-indexing.', array('!id' => 1)),
+      'drush sapi-r default_node_index' => dt('Schedule the search index with the machine name !name for re-indexing.', array('!name' => 'default_node_index')),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index.'),
+    ),
+    'aliases' => array('sapi-r'),
+  );
+
+  $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')),
+    ),
+    'arguments' => array(
+      'index_id' => dt('The numeric ID or machine name of an index.'),
+    ),
+    'aliases' => array('sapi-c'),
+  );
+
+  return $items;
+}
+
+
+/**
+ * List all search indexes.
+ */
+function drush_search_api_list() {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  // See search_api_list_indexes()
+  $indexes = search_api_index_load_multiple(FALSE);
+  if (empty($indexes)) {
+    drush_print(dt('There are no indexes present.'));
+    return;
+  }
+  $rows[] = array(
+    dt('Id'),
+    dt('Name'),
+    dt('Index'),
+    dt('Server'),
+    dt('Type'),
+    dt('Status'),
+    dt('Limit'),
+  );
+  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') . ')';
+    $row = array(
+      $index->id,
+      $index->name,
+      $index->machine_name,
+      $server,
+      $type,
+      $index->enabled ? t('enabled') : t('disabled'),
+      $index->options['cron_limit'],
+    );
+    $rows[] = $row;
+  }
+  drush_print_table($rows);
+}
+
+/**
+ * Display index status.
+ */
+function drush_search_api_status($index_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  // See search_api_index_status()
+  $rows = array(array(
+    dt('Id'),
+    dt('Index'),
+    dt('% Complete'),
+    dt('Indexed'),
+    dt('Total'),
+  ));
+  foreach ($indexes as $index) {
+    $status = search_api_index_status($index);
+    $complete = ($status['total'] > 0) ? 100 * round($status['indexed'] / $status['total'], 3) . '%' : '-';
+    $row = array(
+      $index->id,
+      $index->name,
+      $complete,
+      $status['indexed'],
+      $status['total'],
+    );
+    $rows[] = $row;
+  }
+  drush_print_table($rows);
+}
+
+/**
+ * Index items.
+ *
+ * @param string|integer $index_id
+ *   The index name or id for which items should be indexed.
+ * @param integer $limit
+ *   Maximum number of items to index.
+ * @param integer $batch_size
+ *   Number of items to index per batch.
+ */
+function drush_search_api_index($index_id = NULL, $limit = NULL, $batch_size = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  foreach ($indexes as $index) {
+    // Get the number of remaing items to index.
+    $datasource = $index->datasource();
+    $index_status = $datasource->getIndexStatus($index);
+    $remaining = $index_status['total'] - $index_status['indexed'];
+
+    // 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;
+    }
+
+    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();
+    }
+  }
+}
+
+/**
+ * Copy of formal_plural that works with drush as 't' may not be available.
+ */
+function _search_api_drush_format_plural($count, $singular, $plural, array $args = array(), array $options = array()) {
+  $args['@count'] = $count;
+  if ($count == 1) {
+    return dt($singular, $args, $options);
+  }
+
+  // Get the plural index through the gettext formula.
+  $index = (function_exists('locale_get_plural')) ? locale_get_plural($count, isset($options['langcode']) ? $options['langcode'] : NULL) : -1;
+  // If the index cannot be computed, use the plural as a fallback (which
+  // allows for most flexiblity with the replaceable @count value).
+  if ($index < 0) {
+    return dt($plural, $args, $options);
+  }
+  else {
+    switch ($index) {
+      case "0":
+        return dt($singular, $args, $options);
+      case "1":
+        return dt($plural, $args, $options);
+      default:
+        unset($args['@count']);
+        $args['@count[' . $index . ']'] = $count;
+        return dt(strtr($plural, array('@count' => '@count[' . $index . ']')), $args, $options);
+    }
+  }
+}
+
+/**
+ * Mark for re-indexing.
+ */
+function drush_search_api_reindex($index_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  // See search_api_index_reindex()
+  foreach ($indexes as $index) {
+    $index->reindex();
+    drush_log(dt('!index was successfully marked for re-indexing.', array('!index' => $index->machine_name)), 'ok');
+  }
+}
+
+/**
+ * Clear an index.
+ */
+function drush_search_api_clear($index_id = NULL) {
+  if (search_api_drush_static(__FUNCTION__)) {
+    return;
+  }
+  $indexes = search_api_drush_get_index($index_id);
+  if (empty($indexes)) {
+    return;
+  }
+  // See search_api_index_reindex()
+  foreach ($indexes as $index) {
+    $index->clear();
+    drush_log(dt('!index was successfully cleared.', array('!index' => $index->machine_name)), 'ok');
+  }
+}
+
+/**
+ * Helper function to return an index or all indexes as an array.
+ *
+ * @param $index_id
+ *   (optional) The provided index id.
+ *
+ * @return
+ *   An array of indexes.
+ */
+function search_api_drush_get_index($index_id = NULL) {
+  $ids = isset($index_id) ? array($index_id) : FALSE;
+  $indexes = search_api_index_load_multiple($ids);
+  if (empty($indexes)) {
+    drush_set_error(dt('Invalid index_id or no indexes present. Listing all indexes:'));
+    drush_print();
+    drush_search_api_list();
+  }
+  return $indexes;
+}
+
+/**
+ * Static lookup to prevent Drush 4 from running twice.
+ *
+ * @see http://drupal.org/node/704848
+ */
+function search_api_drush_static($function) {
+  static $index = array();
+  if (isset($index[$function])) {
+    return TRUE;
+  }
+  $index[$function] = TRUE;
+}

+ 38 - 0
search_api.info

@@ -0,0 +1,38 @@
+name = Search API
+description = "Provides a generic API for modules offering search capabilites."
+dependencies[] = entity
+core = 7.x
+package = Search
+
+files[] = search_api.test
+files[] = includes/callback.inc
+files[] = includes/callback_add_aggregation.inc
+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_language_control.inc
+files[] = includes/callback_node_access.inc
+files[] = includes/callback_node_status.inc
+files[] = includes/datasource.inc
+files[] = includes/datasource_entity.inc
+files[] = includes/datasource_external.inc
+files[] = includes/exception.inc
+files[] = includes/index_entity.inc
+files[] = includes/processor.inc
+files[] = includes/processor_html_filter.inc
+files[] = includes/processor_ignore_case.inc
+files[] = includes/processor_stopwords.inc
+files[] = includes/processor_tokenizer.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"
+core = "7.x"
+project = "search_api"
+datestamp = "1357726719"
+

+ 808 - 0
search_api.install

@@ -0,0 +1,808 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the Search API module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function search_api_schema() {
+  $schema['search_api_server'] = array(
+    'description' => 'Stores all search servers created through the Search API.',
+    'fields' => array(
+      'id' => array(
+        'description' => 'The primary identifier for a server.',
+        'type' => 'serial',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'name' => array(
+        'description' => 'The displayed name for a server.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'machine_name' => array(
+        'description' => 'The machine name for a server.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'description' => array(
+        'description' => 'The displayed description for a server.',
+        'type' => 'text',
+        'not null' => FALSE,
+      ),
+      'class' => array(
+        'description' => 'The id of the service class to use for this server.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'options' => array(
+        'description' => 'The options used to configure the service object.',
+        'type' => 'text',
+        'size' => 'medium',
+        'serialize' => TRUE,
+        'not null' => TRUE,
+      ),
+      'enabled' => array(
+        'description' => 'A flag indicating whether the server is enabled.',
+        'type' => 'int',
+        'size' => 'tiny',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+      'status' => array(
+        'description' => 'The exportable status of the entity.',
+        'type' => 'int',
+        'not null' => TRUE,
+        'default' => 0x01,
+        'size' => 'tiny',
+      ),
+      'module' => array(
+        'description' => 'The name of the providing module if the entity has been defined in code.',
+        'type' => 'varchar',
+        'length' => 255,
+        'not null' => FALSE,
+      ),
+    ),
+    'indexes' => array(
+      'enabled' => array('enabled'),
+    ),
+    'unique keys' => array(
+      'machine_name' => array('machine_name'),
+    ),
+    'primary key' => array('id'),
+  );
+
+  $schema['search_api_index'] = array(
+    'description' => 'Stores all search indexes on a {search_api_server}.',
+    'fields' => array(
+      'id' => array(
+        'description' => 'An integer identifying the index.',
+        'type' => 'serial',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'name' => array(
+        'description' => 'A name to be displayed for the index.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'machine_name' => array(
+        'description' => 'The machine name of the index.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'description' => array(
+        'description' => "A string describing the index' use to users.",
+        'type' => 'text',
+        'not null' => FALSE,
+      ),
+      'server' => array(
+        'description' => 'The {search_api_server}.machine_name with which data should be indexed.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => FALSE,
+      ),
+      'item_type' => array(
+        'description' => 'The type of items stored in this index.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'options' => array(
+        'description' => 'An array of additional arguments configuring this index.',
+        'type' => 'text',
+        'size' => 'medium',
+        'serialize' => TRUE,
+        'not null' => TRUE,
+      ),
+      'enabled' => array(
+        'description' => 'A flag indicating whether this index is enabled.',
+        'type' => 'int',
+        'size' => 'tiny',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+      'read_only' => array(
+        'description' => 'A flag indicating whether to write to this index.',
+        'type' => 'int',
+        'size' => 'tiny',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'status' => array(
+        'description' => 'The exportable status of the entity.',
+        'type' => 'int',
+        'not null' => TRUE,
+        'default' => 0x01,
+        'size' => 'tiny',
+      ),
+      'module' => array(
+        'description' => 'The name of the providing module if the entity has been defined in code.',
+        'type' => 'varchar',
+        'length' => 255,
+        'not null' => FALSE,
+      ),
+    ),
+    'indexes' => array(
+      'item_type' => array('item_type'),
+      'server' => array('server'),
+      'enabled' => array('enabled'),
+    ),
+    'unique keys' => array(
+      'machine_name' => array('machine_name'),
+    ),
+    'primary key' => array('id'),
+  );
+
+  $schema['search_api_item'] = array(
+    'description' => 'Stores the items which should be indexed for each index, and their status.',
+    'fields' => array(
+      'item_id' => array(
+        'description' => "The item's entity id (e.g. {node}.nid for nodes).",
+        'type' => 'int',
+        'unsigned' => TRUE,
+        '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'),
+  );
+
+  return $schema;
+}
+
+/**
+ * Implements hook_install().
+ *
+ * Creates a default node index if the module is installed manually.
+ */
+function search_api_install() {
+  // In case the module is installed via an installation profile, a batch is
+  // active and we skip that.
+  if (batch_get()) {
+    return;
+  }
+
+  $name = t('Default node index');
+  $values = array(
+    'name' => $name,
+    'machine_name' => preg_replace('/[^a-z0-9]+/', '_', drupal_strtolower($name)),
+    'description' => t('An automatically created search index for indexing node data. Might be configured to specific needs.'),
+    'server' => NULL,
+    'item_type' => 'node',
+    'options' => array(
+      'cron_limit' => '50',
+      'data_alter_callbacks' => array(
+        'search_api_alter_node_access' => array(
+          'status' => 1,
+          'weight' => '0',
+          'settings' => array(),
+        ),
+      ),
+      'processors' => array(
+        'search_api_case_ignore' => array(
+          'status' => 1,
+          'weight' => '0',
+          'settings' => array(
+            'strings' => 0,
+          ),
+        ),
+        'search_api_html_filter' => array(
+          'status' => 1,
+          'weight' => '10',
+          'settings' => array(
+            'title' => 0,
+            'alt' => 1,
+            'tags' => "h1 = 5\n" .
+                "h2 = 3\n" .
+                "h3 = 2\n" .
+                "strong = 2\n" .
+                "b = 2\n" .
+                "em = 1.5\n" .
+                "u = 1.5",
+          ),
+        ),
+        'search_api_tokenizer' => array(
+          'status' => 1,
+          'weight' => '20',
+          'settings' => array(
+            'spaces' => '[^\\p{L}\\p{N}]',
+            'ignorable' => '[-]',
+          ),
+        ),
+      ),
+      'fields' => array(
+        'type' => array(
+          'type' => 'string',
+        ),
+        'title' => array(
+          'type' => 'text',
+          'boost' => '5.0',
+        ),
+        'promote' => array(
+          'type' => 'boolean',
+        ),
+        'sticky' => array(
+          'type' => 'boolean',
+        ),
+        'created' => array(
+          'type' => 'date',
+        ),
+        'changed' => array(
+          'type' => 'date',
+        ),
+        'author' => array(
+          'type' => 'integer',
+          'entity_type' => 'user',
+        ),
+        'comment_count' => array(
+          'type' => 'integer',
+        ),
+        'search_api_language' => array(
+          'type' => 'string',
+        ),
+        'body:value' => array(
+          'type' => 'text',
+        ),
+      ),
+    ),
+  );
+  search_api_index_insert($values);
+  drupal_set_message('The Search API module was installed. A new default node index was created.');
+}
+
+/**
+ * Implements hook_enable().
+ *
+ * Mark all items as "dirty", since we can't know whether they are.
+ */
+function search_api_enable() {
+  $types = array();
+  foreach (search_api_index_load_multiple(FALSE) as $index) {
+    if ($index->enabled) {
+      $types[$index->item_type][] = $index;
+    }
+  }
+  foreach ($types as $type => $indexes) {
+    $controller = search_api_get_datasource_controller($type);
+    $controller->startTracking($indexes);
+  }
+}
+
+/**
+ * Implements hook_disable().
+ */
+function search_api_disable() {
+  $types = array();
+  foreach (search_api_index_load_multiple(FALSE) as $index) {
+    $types[$index->item_type][] = $index;
+  }
+  foreach ($types as $type => $indexes) {
+    $controller = search_api_get_datasource_controller($type);
+    $controller->stopTracking($indexes);
+  }
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function search_api_uninstall() {
+  variable_del('search_api_tasks');
+  variable_del('search_api_index_worker_callback_runtime');
+}
+
+/**
+ * Update function that adds the machine names for servers and indexes.
+ */
+function search_api_update_7101() {
+  $tx = db_transaction();
+  try {
+    // Servers
+    $spec = array(
+      'description' => 'The machine name for a server.',
+      'type' => 'varchar',
+      'length' => 50,
+      'not null' => TRUE,
+      'default' => '',
+    );
+    db_add_field('search_api_server', 'machine_name', $spec);
+
+    $names = array();
+    $servers = db_select('search_api_server', 's')
+      ->fields('s')
+      ->execute();
+    foreach ($servers as $server) {
+      $base = $name = drupal_strtolower(preg_replace('/[^a-z0-9]+/i', '_', $server->name));
+      $i = 0;
+      while (isset($names[$name])) {
+        $name = $base . '_' . ++$i;
+      }
+      $names[$name] = TRUE;
+      db_update('search_api_server')
+        ->fields(array('machine_name' => $name))
+        ->condition('id', $server->id)
+        ->execute();
+    }
+
+    db_add_unique_key('search_api_server', 'machine_name', array('machine_name'));
+
+    //Indexes
+    $spec = array(
+      'description' => 'The machine name of the index.',
+      'type' => 'varchar',
+      'length' => 50,
+      'not null' => TRUE,
+      'default' => '',
+    );
+    db_add_field('search_api_index', 'machine_name', $spec);
+
+    $names = array();
+    $indexes = db_select('search_api_index', 'i')
+      ->fields('i')
+      ->execute();
+    foreach ($indexes as $index) {
+      $base = $name = drupal_strtolower(preg_replace('/[^a-z0-9]+/i', '_', $index->name));
+      $i = 0;
+      while (isset($names[$name])) {
+        $name = $base . '_' . ++$i;
+      }
+      $names[$name] = TRUE;
+      db_update('search_api_index')
+        ->fields(array('machine_name' => $name))
+        ->condition('id', $index->id)
+        ->execute();
+    }
+
+    db_add_unique_key('search_api_index', 'machine_name', array('machine_name'));
+  }
+  catch (Exception $e) {
+    $tx->rollback();
+    try {
+      db_drop_field('search_api_server', 'machine_name');
+      db_drop_field('search_api_index', 'machine_name');
+    }
+    catch (Exception $e1) {
+      // Ignore.
+    }
+    throw new DrupalUpdateException(t('An exception occurred during the update: @msg.', array('@msg' => $e->getMessage())));
+  }
+}
+
+/**
+ * Update replacing IDs with machine names for foreign keys.
+ * {search_api_index}.server and {search_api_item}.index_id are altered.
+ */
+function search_api_update_7102() {
+  // Update of search_api_index:
+  $indexes = array();
+  $select = db_select('search_api_index', 'i')->fields('i');
+  foreach ($select->execute() as $index) {
+    $indexes[$index->id] = $index;
+  }
+  $servers = db_select('search_api_server', 's')->fields('s', array('id', 'machine_name'))->execute()->fetchAllKeyed();
+
+  db_drop_index('search_api_index', 'server');
+  db_drop_field('search_api_index', 'server');
+  $spec = array(
+    'description' => 'The {search_api_server}.machine_name with which data should be indexed.',
+    'type' => 'varchar',
+    'length' => 50,
+    'not null' => FALSE,
+  );
+  db_add_field('search_api_index', 'server', $spec);
+
+  foreach ($indexes as $index) {
+    db_update('search_api_index')
+      ->fields(array('server' => $servers[$index->server]))
+      ->condition('id', $index->id)
+      ->execute();
+  }
+  db_add_index('search_api_index', 'server', array('server'));
+
+  // Update of search_api_item:
+  db_drop_index('search_api_item', 'indexing');
+  db_drop_primary_key('search_api_item');
+  $spec = array(
+    'description' => 'The {search_api_index}.machine_name this item belongs to.',
+    'type' => 'varchar',
+    'length' => 50,
+    'not null' => TRUE,
+  );
+  $keys_new = array(
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+  db_change_field('search_api_item', 'index_id', 'index_id', $spec, $keys_new);
+
+  foreach ($indexes as $index) {
+    // We explicitly forbid numeric machine names, therefore we don't have to
+    // worry about conflicts here.
+    db_update('search_api_item')
+      ->fields(array(
+        'index_id' => $index->machine_name,
+      ))
+      ->condition('index_id', $index->id)
+      ->execute();
+  }
+}
+
+/**
+ * Add the database fields newly required for entities by the Entity API.
+ */
+function search_api_update_7103() {
+  if (!function_exists('entity_exportable_schema_fields')) {
+    throw new DrupalUpdateException(t('Please update the Entity API module first.'));
+  }
+  foreach (array('search_api_server', 'search_api_index') as $table) {
+    foreach (entity_exportable_schema_fields() as $field => $spec) {
+      db_add_field($table, $field, $spec);
+    }
+  }
+}
+
+/**
+ * Initialize the "Fields to run on" settings for processors.
+ */
+function search_api_update_7107() {
+  $rows = db_select('search_api_index', 'i')
+    ->fields('i', array('id', 'options'))
+    ->execute()
+    ->fetchAllKeyed();
+  foreach ($rows as $id => $options) {
+    $opt = unserialize($options);
+    $processors = &$opt['processors'];
+    // Only update our own processors, don't mess with others.
+    $check_processors = array(
+      'search_api_case_ignore' => 1,
+      'search_api_html_filter' => 1,
+      'search_api_tokenizer' => 1,
+    );
+    foreach (array_intersect_key($processors, $check_processors) as $name => $info) {
+      $types = array('text');
+      if (!empty($info['settings']['strings'])) {
+        $types[] = 'string';
+        unset($processors[$name]['settings']['strings']);
+      }
+      foreach ($opt['fields'] as $field => $info) {
+        if ($info['indexed'] && search_api_is_text_type($info['type'], $types)) {
+          $processors[$name]['settings']['fields'][$field] = $field;
+        }
+      }
+    }
+    $opt = serialize($opt);
+    if ($opt != $options) {
+      db_update('search_api_index')
+        ->fields(array(
+          'options' => $opt,
+        ))
+        ->condition('id', $id)
+        ->execute();
+    }
+  }
+}
+
+/**
+ * Change {search_api_item}.index_id back to the index' numeric ID.
+ */
+function search_api_update_7104() {
+  $select = db_select('search_api_index', 'i')->fields('i');
+  foreach ($select->execute() as $index) {
+    // We explicitly forbid numeric machine names, therefore we don't have to
+    // worry about conflicts here.
+    db_update('search_api_item')
+      ->fields(array(
+        'index_id' => $index->id,
+      ))
+      ->condition('index_id', $index->machine_name)
+      ->execute();
+  }
+
+  // Update primary key and index.
+  db_drop_index('search_api_item', 'indexing');
+  db_drop_primary_key('search_api_item');
+  $spec = array(
+    'description' => 'The {search_api_index}.id this item belongs to.',
+    'type' => 'int',
+    'unsigned' => TRUE,
+    'not null' => TRUE,
+  );
+  $keys_new = array(
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+  db_change_field('search_api_item', 'index_id', 'index_id', $spec, $keys_new);
+}
+
+/**
+ * Remove all empty aggregated fields for the search_api_alter_add_fulltext data
+ * alterations.
+ */
+function search_api_update_7105() {
+  $rows = db_select('search_api_index', 'i')
+    ->fields('i', array('id', 'options'))
+    ->execute()
+    ->fetchAllKeyed();
+  foreach ($rows as $id => $options) {
+    $opt = unserialize($options);
+    if (isset($opt['data_alter_callbacks']['search_api_alter_add_fulltext']['settings']['fields'])) {
+      foreach ($opt['data_alter_callbacks']['search_api_alter_add_fulltext']['settings']['fields'] as $name => $field) {
+        if (empty($field['name']) || empty($field['fields'])) {
+          unset($opt['data_alter_callbacks']['search_api_alter_add_fulltext']['settings']['fields'][$name]);
+        }
+      }
+    }
+    $opt = serialize($opt);
+    if ($opt != $options) {
+      db_update('search_api_index')
+        ->fields(array(
+          'options' => $opt,
+        ))
+        ->condition('id', $id)
+        ->execute();
+    }
+  }
+}
+
+/**
+ * Update the settings for the "Aggregated fields" data alteration.
+ */
+function search_api_update_7106() {
+  $rows = db_select('search_api_index', 'i')
+    ->fields('i')
+    ->execute()
+    ->fetchAll();
+  foreach ($rows as $row) {
+    $opt = unserialize($row->options);
+    $callbacks = &$opt['data_alter_callbacks'];
+    if (isset($callbacks['search_api_alter_add_fulltext'])) {
+      $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) {
+          if (!isset($info['type'])) {
+            $info['type'] = 'fulltext';
+          }
+        }
+      }
+    }
+    $opt = serialize($opt);
+    if ($opt != $row->options) {
+      // Mark the entity as overridden, in case it has been defined in code
+      // only.
+      $row->status |= 0x01;
+      db_update('search_api_index')
+        ->fields(array(
+          'options' => $opt,
+          'status' => $row->status,
+        ))
+        ->condition('id', $row->id)
+        ->execute();
+    }
+  }
+}
+
+/**
+ * Add "read only" property to Search API index entities.
+ */
+function search_api_update_7108() {
+  $db_field = array(
+    'description' => 'A flag indicating whether to write to this index.',
+    'type' => 'int',
+    'size' => 'tiny',
+    'not null' => TRUE,
+    'default' => 0,
+  );
+  db_add_field('search_api_index', 'read_only', $db_field);
+  return t('Added a "read only" property to index entities.');
+}
+
+/**
+ * Clear entity info cache, as entity controller classes hae changed.
+ */
+function search_api_update_7109() {
+  cache_clear_all('entity_info:', 'cache', TRUE);
+}
+
+/**
+ * Rename the "entity_type" field to "item_type" in the {search_api_index} table.
+ */
+function search_api_update_7110() {
+  $table = 'search_api_index';
+  // This index isn't used anymore.
+  db_drop_index($table, 'entity_type');
+  // Rename the "item_type" field (and change the description).
+  $item_type = array(
+    'description' => 'The type of items stored in this index.',
+    'type' => 'varchar',
+    'length' => 50,
+    'not null' => TRUE,
+  );
+  // Also add the new "item_type" index, while we're at it.
+  $keys_new['indexes']['item_type'] = array('item_type');
+  db_change_field($table, 'entity_type', 'item_type', $item_type, $keys_new);
+  // Mark all indexes in code as "OVERRIDDEN".
+  db_update($table)
+    ->fields(array(
+      'status' => 0x03,
+    ))
+    ->condition('status', 0x02)
+    ->execute();
+  // Clear entity info caches.
+  cache_clear_all('*', 'cache', TRUE);
+}
+
+/**
+ * Change the definition of the {search_api_item}.changed field.
+ */
+function search_api_update_7111() {
+  $spec = 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,
+  );
+  db_change_field('search_api_item', 'changed', 'changed', $spec);
+}
+
+/**
+ * Changes the size of the {search_api_index}.options and {search_api_server}.options fields to "medium".
+ */
+function search_api_update_7112() {
+  $spec = array(
+    'description' => 'The options used to configure the service object.',
+    'type' => 'text',
+    'size' => 'medium',
+    'serialize' => TRUE,
+    'not null' => TRUE,
+  );
+  db_change_field('search_api_server', 'options', 'options', $spec);
+  $spec = array(
+    'description' => 'An array of additional arguments configuring this index.',
+    'type' => 'text',
+    'size' => 'medium',
+    'serialize' => TRUE,
+    'not null' => TRUE,
+  );
+  db_change_field('search_api_index', 'options', 'options', $spec);
+}
+
+/**
+ * Removes superfluous data from the stored index options.
+ */
+function search_api_update_7113() {
+  $indexes = db_select('search_api_index', 'i')
+    ->fields('i')
+    ->execute();
+  foreach ($indexes as $index) {
+    $options = unserialize($index->options);
+    // Weed out fields settings.
+    if (!empty($options['fields'])) {
+      foreach ($options['fields'] as $key => $field) {
+        if (isset($field['indexed']) && !$field['indexed']) {
+          unset($options['fields'][$key]);
+          continue;
+        }
+        unset($options['fields'][$key]['name'], $options['fields'][$key]['indexed']);
+        if (isset($field['boost']) && $field['boost'] == '1.0') {
+          unset($options['fields'][$key]['boost']);
+        }
+      }
+    }
+    // Weed out processor settings.
+    if (!empty($options['processors'])) {
+      // Only weed out settings for our own processors.
+      $processors = array('search_api_case_ignore', 'search_api_html_filter', 'search_api_tokenizer', 'search_api_stopwords');
+      foreach ($processors as $key) {
+        if (empty($options['processors'][$key])) {
+          continue;
+        }
+        $processor = $options['processors'][$key];
+        if (empty($processor['settings']['fields'])) {
+          continue;
+        }
+        $fields = array_filter($processor['settings']['fields']);
+        if ($fields) {
+          $fields = array_combine($fields, array_fill(0, count($fields), TRUE));
+        }
+        $options['processors'][$key]['settings']['fields'] = $fields;
+      }
+    }
+    // Weed out settings for the „Aggregated fields“ data alteration.
+    if (!empty($options['data_alter_callbacks']['search_api_alter_add_aggregation']['settings']['fields'])) {
+      unset($options['data_alter_callbacks']['search_api_alter_add_aggregation']['settings']['actions']);
+      $aggregated_fields = &$options['data_alter_callbacks']['search_api_alter_add_aggregation']['settings']['fields'];
+      foreach ($aggregated_fields as $key => $field) {
+        unset($aggregated_fields[$key]['actions']);
+        if (!empty($field['fields'])) {
+          $aggregated_fields[$key]['fields'] = array_values(array_filter($field['fields']));
+        }
+      }
+    }
+    $options = serialize($options);
+    if ($options != $index->options) {
+      // Mark the entity as overridden, in case it has been defined in code
+      // only.
+      $index->status |= 0x01;
+      db_update('search_api_index')
+        ->fields(array(
+          'options' => $options,
+          'status' => $index->status,
+        ))
+        ->condition('id', $index->id)
+        ->execute();
+    }
+  }
+}
+
+/**
+ * Sanitize watchdog messages.
+ */
+function search_api_update_7114() {
+  if (db_table_exists('watchdog')) {
+    try {
+      $entries = db_select('watchdog', 'w')
+        ->fields('w', array('wid', 'message'))
+        ->condition('type', 'search_api')
+        ->execute();
+      foreach ($entries as $entry) {
+        db_update('watchdog')
+          ->fields(array(
+            'message' => check_plain($entry->message),
+          ))
+          ->condition('wid', $entry->wid)
+          ->execute();
+      }
+    }
+    catch (Exception $e) {
+      throw new DrupalUpdateException(t('An exception occurred during the update: @msg.', array('@msg' => $e->getMessage())));
+    }
+  }
+}

+ 2352 - 0
search_api.module

@@ -0,0 +1,2352 @@
+<?php
+
+/**
+ * Default number of items indexed per cron batch for each enabled index.
+ */
+define('SEARCH_API_DEFAULT_CRON_LIMIT', 50);
+
+/**
+ * Implements hook_menu().
+ */
+function search_api_menu() {
+  $pre = 'admin/config/search/search_api';
+  $items[$pre] = array(
+    'title' => 'Search API',
+    'description' => 'Create and configure search engines.',
+    'page callback' => 'search_api_admin_overview',
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+  );
+  $items[$pre . '/overview'] = array(
+    'title' => 'Overview',
+    'type' => MENU_DEFAULT_LOCAL_TASK,
+    'weight' => -10,
+  );
+  $items[$pre . '/add_server'] = array(
+    'title' => 'Add server',
+    'description' => 'Create a new search server.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_add_server'),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -1,
+    'type' => MENU_LOCAL_ACTION,
+  );
+  $items[$pre . '/add_index'] = array(
+    'title' => 'Add index',
+    'description' => 'Create a new search index.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_add_index'),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'type' => MENU_LOCAL_ACTION,
+  );
+  $items[$pre . '/server/%search_api_server'] = array(
+    'title' => 'View server',
+    'title callback' => 'search_api_admin_item_title',
+    'title arguments' => array(5),
+    'description' => 'View server details.',
+    'page callback' => 'search_api_admin_server_view',
+    'page arguments' => array(5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+  );
+  $items[$pre . '/server/%search_api_server/view'] = array(
+    'title' => 'View',
+    'weight' => -10,
+    'type' => MENU_DEFAULT_LOCAL_TASK,
+  );
+  $items[$pre . '/server/%search_api_server/edit'] = array(
+    'title' => 'Edit',
+    'description' => 'Edit server details.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_server_edit', 5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -1,
+    'type' => MENU_LOCAL_TASK,
+  );
+  $items[$pre . '/server/%search_api_server/delete'] = array(
+    'title' => 'Delete',
+    'title callback' => 'search_api_title_delete_page',
+    'title arguments' => array(5),
+    'description' => 'Delete server.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_confirm', 'server', 'delete', 5),
+    'access callback' => 'search_api_access_delete_page',
+    'access arguments' => array(5),
+    'file' => 'search_api.admin.inc',
+    'type' => MENU_LOCAL_TASK,
+  );
+  $items[$pre . '/index/%search_api_index'] = array(
+    'title' => 'View index',
+    'title callback' => 'search_api_admin_item_title',
+    'title arguments' => array(5),
+    'description' => 'View index details.',
+    'page callback' => 'search_api_admin_index_view',
+    'page arguments' => array(5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+  );
+  $items[$pre . '/index/%search_api_index/view'] = array(
+    'title' => 'View',
+    'type' => MENU_DEFAULT_LOCAL_TASK,
+    'weight' => -10,
+  );
+  $items[$pre . '/index/%search_api_index/status'] = array(
+    'title' => 'Status',
+    'description' => 'Display and work on index status.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_index_status_form', 5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -8,
+    'type' => MENU_LOCAL_TASK,
+    'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+  );
+  $items[$pre . '/index/%search_api_index/edit'] = array(
+    'title' => 'Settings',
+    'description' => 'Edit index settings.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_index_edit', 5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -6,
+    'type' => MENU_LOCAL_TASK,
+    'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+  );
+  $items[$pre . '/index/%search_api_index/fields'] = array(
+    'title' => 'Fields',
+    'description' => 'Select indexed fields.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_index_fields', 5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -4,
+    'type' => MENU_LOCAL_TASK,
+    'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+  );
+  $items[$pre . '/index/%search_api_index/workflow'] = array(
+    'title' => 'Workflow',
+    'description' => 'Edit index workflow.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_index_workflow', 5),
+    'access arguments' => array('administer search_api'),
+    'file' => 'search_api.admin.inc',
+    'weight' => -2,
+    'type' => MENU_LOCAL_TASK,
+    'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+  );
+  $items[$pre . '/index/%search_api_index/delete'] = array(
+    'title' => 'Delete',
+    'title callback' => 'search_api_title_delete_page',
+    'title arguments' => array(5),
+    'description' => 'Delete index.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('search_api_admin_confirm', 'index', 'delete', 5),
+    'access callback' => 'search_api_access_delete_page',
+    'access arguments' => array(5),
+    'file' => 'search_api.admin.inc',
+    'type' => MENU_LOCAL_TASK,
+  );
+
+  return $items;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function search_api_theme() {
+  $themes['search_api_server'] = array(
+    'variables' => array(
+      'id' => NULL,
+      'name' => '',
+      'machine_name' => '',
+      'description' => NULL,
+      'enabled' => NULL,
+      'class_name' => NULL,
+      'class_description' => NULL,
+      'options' => array(),
+      'status' => ENTITY_CUSTOM,
+    ),
+    'file' => 'search_api.admin.inc',
+  );
+  $themes['search_api_index'] = array(
+    'variables' => array(
+      'id' => NULL,
+      'name' => '',
+      'machine_name' => '',
+      'description' => NULL,
+      'item_type' => NULL,
+      'enabled' => NULL,
+      'server' => NULL,
+      'options' => array(),
+      'fields' => array(),
+      'indexed_items' => 0,
+      'total_items' => 0,
+      'status' => ENTITY_CUSTOM,
+      'read_only' => 0,
+    ),
+    'file' => 'search_api.admin.inc',
+  );
+  $themes['search_api_admin_item_order'] = array(
+    'render element' => 'element',
+    'file' => 'search_api.admin.inc',
+  );
+  $themes['search_api_admin_fields_table'] = array(
+    'render element' => 'element',
+    'file' => 'search_api.admin.inc',
+  );
+
+  return $themes;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function search_api_permission() {
+  return array(
+    'administer search_api' => array(
+      'title' => t('Administer Search API'),
+      'description' => t('Create and configure Search API servers and indexes.'),
+    ),
+  );
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Will index $options['cron-limit'] items for each enabled index.
+ */
+function search_api_cron() {
+  $queue = DrupalQueue::get('search_api_indexing_queue');
+  foreach (search_api_index_load_multiple(FALSE, array('enabled' => TRUE, 'read_only' => 0)) as $index) {
+    $limit = isset($index->options['cron_limit'])
+        ? $index->options['cron_limit']
+        : SEARCH_API_DEFAULT_CRON_LIMIT;
+    if ($limit) {
+      try {
+        $task = array('index' => $index->machine_name);
+        $ids = search_api_get_items_to_index($index, -1);
+        if (!$ids) {
+          continue;
+        }
+        $batches = $limit > 0 ? array_chunk($ids, $limit, TRUE) : array($ids);
+        foreach ($batches as $batch) {
+          $task['items'] = $batch;
+          $queue->createItem($task);
+        }
+        // Mark items as queued so they won't be inserted into the queue again
+        // on the next cron run.
+        search_api_track_item_queued($index, $ids);
+      }
+      catch (SearchApiException $e) {
+        watchdog_exception('search_api', $e);
+      }
+    }
+  }
+}
+
+/**
+ * Implements hook_cron_queue_info().
+ *
+ * Defines a queue for saved searches that should be checked for new items.
+ */
+function search_api_cron_queue_info() {
+  return array(
+    'search_api_indexing_queue' => array(
+      'worker callback' => '_search_api_indexing_queue_process',
+      'time' => variable_get('search_api_index_worker_callback_runtime', 15),
+    ),
+  );
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function search_api_entity_info() {
+  $info['search_api_server'] = array(
+    'label' => t('Search server'),
+    'controller class' => 'EntityAPIControllerExportable',
+    'metadata controller class' => FALSE,
+    'entity class' => 'SearchApiServer',
+    'base table' => 'search_api_server',
+    'uri callback' => 'search_api_server_url',
+    'module' => 'search_api',
+    'exportable' => TRUE,
+    'entity keys' => array(
+      'id' => 'id',
+      'label' => 'name',
+      'name' => 'machine_name',
+    ),
+  );
+  $info['search_api_index'] = array(
+    'label' => t('Search index'),
+    'controller class' => 'EntityAPIControllerExportable',
+    'metadata controller class' => FALSE,
+    'entity class' => 'SearchApiIndex',
+    'base table' => 'search_api_index',
+    'uri callback' => 'search_api_index_url',
+    'module' => 'search_api',
+    'exportable' => TRUE,
+    'entity keys' => array(
+      'id' => 'id',
+      'label' => 'name',
+      'name' => 'machine_name',
+    ),
+  );
+
+  return $info;
+}
+
+/**
+ * Implements hook_entity_property_info().
+ */
+function search_api_entity_property_info() {
+  $info['search_api_server']['properties'] = array(
+    'id' => array(
+      'label' => t('ID'),
+      'type' => 'integer',
+      'description' => t('The primary identifier for a server.'),
+      'schema field' => 'id',
+      'validation callback' => 'entity_metadata_validate_integer_positive',
+    ),
+    'name' => array(
+      'label' => t('Name'),
+      'type' => 'text',
+      'description' => t('The displayed name for a server.'),
+      'schema field' => 'name',
+      'required' => TRUE,
+    ),
+    'machine_name' => array(
+      'label' => t('Machine name'),
+      'type' => 'token',
+      'description' => t('The internally used machine name for a server.'),
+      'schema field' => 'machine_name',
+      'required' => TRUE,
+    ),
+    'description' => array(
+      'label' => t('Description'),
+      'type' => 'text',
+      'description' => t('The displayed description for a server.'),
+      'schema field' => 'description',
+      'sanitize' => 'filter_xss',
+    ),
+    'class' => array(
+      'label' => t('Service class'),
+      'type' => 'text',
+      'description' => t('The ID of the service class to use for this server.'),
+      'schema field' => 'class',
+      'required' => TRUE,
+    ),
+    'enabled' => array(
+      'label' => t('Enabled'),
+      'type' => 'boolean',
+      'description' => t('A flag indicating whether the server is enabled.'),
+      'schema field' => 'enabled',
+    ),
+  );
+  $info['search_api_index']['properties'] = array(
+    'id' => array(
+      'label' => t('ID'),
+      'type' => 'integer',
+      'description' => t('An integer identifying the index.'),
+      'schema field' => 'id',
+      'validation callback' => 'entity_metadata_validate_integer_positive',
+    ),
+    'name' => array(
+      'label' => t('Name'),
+      'type' => 'text',
+      'description' => t('A name to be displayed for the index.'),
+      'schema field' => 'name',
+      'required' => TRUE,
+    ),
+    'machine_name' => array(
+      'label' => t('Machine name'),
+      'type' => 'token',
+      'description' => t('The internally used machine name for an index.'),
+      'schema field' => 'machine_name',
+      'required' => TRUE,
+    ),
+    'description' => array(
+      'label' => t('Description'),
+      'type' => 'text',
+      'description' => t("A string describing the index' use to users."),
+      'schema field' => 'description',
+      'sanitize' => 'filter_xss',
+    ),
+    'server' => array(
+      'label' => t('Server ID'),
+      'type' => 'token',
+      'description' => t('The machine name of the search_api_server with which data should be indexed.'),
+      'schema field' => 'server',
+    ),
+    'server_entity' => array(
+      'label' => t('Server'),
+      'type' => 'search_api_server',
+      'description' => t('The search_api_server with which data should be indexed.'),
+      'getter callback' => 'search_api_index_get_server',
+    ),
+    'item_type' => array(
+      'label' => t('Item type'),
+      'type' => 'token',
+      'description' => t('The type of items stored in this index.'),
+      'schema field' => 'item_type',
+      'required' => TRUE,
+    ),
+    'enabled' => array(
+      'label' => t('Enabled'),
+      'type' => 'boolean',
+      'description' => t('A flag indicating whether the index is enabled.'),
+      'schema field' => 'enabled',
+    ),
+    'read_only' => array(
+      'label' => t('Read only'),
+      'type' => 'boolean',
+      'description' => t('A flag indicating whether the index is read-only.'),
+      'schema field' => 'read_only',
+    ),
+  );
+
+  return $info;
+}
+
+/**
+ * Implements hook_search_api_server_insert().
+ *
+ * Calls the postCreate() method for the server.
+ */
+function search_api_search_api_server_insert(SearchApiServer $server) {
+  $server->postCreate();
+}
+
+/**
+ * Implements hook_search_api_server_update().
+ *
+ * Calls the server's postUpdate() method and marks all of this server's indexes
+ * for reindexing, if necessary.
+ */
+function search_api_search_api_server_update(SearchApiServer $server) {
+  if ($server->postUpdate()) {
+    foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+      $index->reindex();
+    }
+  }
+  if ($server->enabled != $server->original->enabled) {
+    if ($server->enabled) {
+      // Were there any changes in the server's indexes while it was disabled?
+      $tasks = variable_get('search_api_tasks', array());
+      if (isset($tasks[$server->machine_name])) {
+        foreach ($tasks[$server->machine_name] as $index_id => $index_tasks) {
+          $index = search_api_index_load($index_id);
+          foreach ($index_tasks as $task) {
+            switch ($task) {
+              case 'add':
+                $server->addIndex($index);
+                break;
+              case 'clear':
+                $server->deleteItems('all', $index);
+                break;
+              case 'clear all':
+                // Would normally be used with a fake index ID of "", since it doesn't matter.
+                $server->deleteItems('all');
+                break;
+              case 'fields':
+                if ($server->fieldsUpdated($index)) {
+                  _search_api_index_reindex($index);
+                }
+                break;
+              case 'remove':
+                $server->removeIndex($index ? $index : $index_id);
+                break;
+              default:
+                if (substr($task, 0, 7) == 'delete-') {
+                  $id = substr($task, 7);
+                  $server->deleteItems(array($id), $index);
+                }
+                else {
+                  watchdog('search_api', t('Unknown task "@task" for server "@name".', array('@task' => $task, '@name' => $server->machine_name)), NULL, WATCHDOG_WARNING);
+                }
+            }
+          }
+        }
+        unset($tasks[$server->machine_name]);
+        variable_set('search_api_tasks', $tasks);
+      }
+    }
+    else {
+      foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name, 'enabled' => 1)) as $index) {
+        $index->update(array('enabled' => 0));
+      }
+    }
+  }
+}
+
+/**
+ * Implements hook_search_api_server_delete().
+ *
+ * Calls the preDelete() method for the server.
+ */
+function search_api_search_api_server_delete(SearchApiServer $server) {
+  $server->preDelete();
+
+
+  // Only react on real delete, not revert.
+  if (!$server->hasStatus(ENTITY_IN_CODE)) {
+    foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+      $index->update(array('server' => NULL, 'enabled' => FALSE));
+    }
+  }
+
+  $tasks = variable_get('search_api_tasks', array());
+  unset($tasks[$server->machine_name]);
+  variable_set('search_api_tasks', $tasks);
+}
+
+/**
+ * Implements hook_search_api_index_insert().
+ *
+ * Adds the index to its server (if any) and starts tracking indexed items (if
+ * the index is enabled).
+ */
+function search_api_search_api_index_insert(SearchApiIndex $index) {
+  $index->postCreate();
+}
+
+/**
+ * Implements hook_search_api_index_update().
+ */
+function search_api_search_api_index_update(SearchApiIndex $index) {
+  // If the server was changed, we have to call the appropriate service class
+  // hook methods.
+  if ($index->server != $index->original->server) {
+    // Server changed - inform old and new ones.
+    if ($index->original->server) {
+      $old_server = search_api_server_load($index->original->server);
+      // The server might have changed because the old one was deleted:
+      if ($old_server) {
+        if ($old_server->enabled) {
+          $old_server->removeIndex($index);
+        }
+        else {
+          $tasks = variable_get('search_api_tasks', array());
+          // When we add or remove an index, we can ignore all other tasks.
+          $tasks[$old_server->machine_name][$index->machine_name] = array('remove');
+          variable_set('search_api_tasks', $tasks);
+        }
+      }
+    }
+
+    if ($index->server) {
+      $new_server = $index->server(TRUE);
+      // If the server is enabled, we call addIndex(); otherwise, we save the task.
+      if ($new_server->enabled) {
+        $new_server->addIndex($index);
+      }
+      else {
+        $tasks = variable_get('search_api_tasks', array());
+        // When we add or remove an index, we can ignore all other tasks.
+        $tasks[$new_server->machine_name][$index->machine_name] = array('add');
+        variable_set('search_api_tasks', $tasks);
+        unset($new_server);
+      }
+    }
+
+    // We also have to re-index all content
+    _search_api_index_reindex($index);
+  }
+
+  // If the fields were changed, call the appropriate service class hook method
+  // and re-index the content, if necessary.
+  $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 ($old_fields != $new_fields) {
+    if ($index->server && $index->server()->fieldsUpdated($index)) {
+      _search_api_index_reindex($index);
+    }
+  }
+
+  // If the index's enabled or read-only status is being changed, queue or
+  // dequeue items for indexing.
+  if (!$index->read_only && $index->enabled != $index->original->enabled) {
+    if ($index->enabled) {
+      $index->queueItems();
+    }
+    else {
+      $index->dequeueItems();
+    }
+  }
+  elseif ($index->read_only != $index->original->read_only) {
+    if ($index->read_only) {
+      $index->dequeueItems();
+    }
+    else {
+      $index->queueItems();
+    }
+  }
+
+  // If the cron batch size changed, empty the cron queue for this index.
+  $old_cron = $index->original->options + array('cron_limit' => NULL);
+  $old_cron = $old_cron['cron_limit'];
+  $new_cron = $index->options + array('cron_limit' => NULL);
+  $new_cron = $new_cron['cron_limit'];
+  if ($old_cron !== $new_cron) {
+    _search_api_empty_cron_queue($index, TRUE);
+  }
+}
+
+/**
+ * Implements hook_search_api_index_delete().
+ *
+ * Removes all data for indexes not available any more.
+ */
+function search_api_search_api_index_delete(SearchApiIndex $index) {
+  $index->postDelete();
+}
+
+/**
+ * Implements hook_features_export_alter().
+ *
+ * Adds dependency information for exported servers.
+ */
+function search_api_features_export_alter(&$export, $module_name) {
+  if (isset($export['features']['search_api_server'])) {
+    // Get a list of the modules that provide storage engines.
+    $hook = 'search_api_service_info';
+    $classes = array();
+    foreach (module_implements('search_api_service_info') as $module) {
+      $function = $module . '_' . $hook;
+      $engines = $function();
+      foreach ($engines as $service => $specs) {
+        $classes[$service] = $module;
+      }
+    }
+
+    // Check all of the exported server specifications.
+    foreach ($export['features']['search_api_server'] as $server_name) {
+      // Load the server's object.
+      $server = search_api_server_load($server_name);
+      $module = $classes[$server->class];
+
+      // Ensure that the module responsible for the server object is listed as
+      // a dependency.
+      if (!isset($export['dependencies'][$module])) {
+        $export['dependencies'][$module] = $module;
+      }
+    }
+
+    // Ensure the dependencies list is still sorted alphabetically.
+    ksort($export['dependencies']);
+  }
+}
+
+/**
+ * Implements hook_entity_insert().
+ *
+ * Marks the new item as to-index for all indexes on entities of the specified
+ * type.
+ *
+ * @param $entity
+ *   The new entity.
+ * @param $type
+ *   The entity's type.
+ */
+function search_api_entity_insert($entity, $type) {
+  // When inserting a new search index, the new index was already inserted into
+  // the tracking table. This would lead to a duplicate-key issue, if we would
+  // continue.
+  // We also only react on entity operations for types with property
+  // information, as we don't provide search integration for the others.
+  if ($type == 'search_api_index' || !entity_get_property_info($type)) {
+    return;
+  }
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_insert($type, array($id));
+  }
+}
+
+/**
+ * Implements hook_entity_update().
+ *
+ * Marks the item as changed for all indexes on entities of the specified type.
+ *
+ * @param $entity
+ *   The updated entity.
+ * @param $type
+ *   The entity's type.
+ */
+function search_api_entity_update($entity, $type) {
+  // We only react on entity operations for types with property information, as
+  // we don't provide search integration for the others.
+  if (!entity_get_property_info($type)) {
+    return;
+  }
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_change($type, array($id));
+  }
+}
+
+/**
+ * Implements hook_entity_delete().
+ *
+ * Removes the item from the tracking table and deletes it from all indexes.
+ *
+ * @param $entity
+ *   The updated entity.
+ * @param $type
+ *   The entity's type.
+ */
+function search_api_entity_delete($entity, $type) {
+  // We only react on entity operations for types with property information, as
+  // we don't provide search integration for the others.
+  if (!entity_get_property_info($type)) {
+    return;
+  }
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_delete($type, array($id));
+  }
+}
+
+/**
+ * Implements hook_search_api_item_type_info().
+ *
+ * Adds item types for all entity types with property information.
+ */
+function search_api_search_api_item_type_info() {
+  $types = array();
+
+  foreach (entity_get_property_info() as $type => $property_info) {
+    if ($info = entity_get_info($type)) {
+      $types[$type] = array(
+        'name' => $info['label'],
+        'datasource controller' => 'SearchApiEntityDataSourceController',
+      );
+    }
+  }
+
+  return $types;
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function search_api_modules_enabled(array $modules) {
+  // New modules might offer additional entity types, invalidating the cached
+  // item type information.
+  drupal_static_reset('search_api_get_item_type_info');
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function search_api_modules_disabled(array $modules) {
+  // The disabled modules might have offered entity types, which are now
+  // invalid. Therefore, clear the cached item type informaiton.
+  drupal_static_reset('search_api_get_item_type_info');
+}
+
+/**
+ * Implements hook_search_api_alter_callback_info().
+ */
+function search_api_search_api_alter_callback_info() {
+  $callbacks['search_api_alter_bundle_filter'] = array(
+    'name' => t('Bundle filter'),
+    'description' => t('Exclude items from indexing based on their bundle (content type, vocabulary, …).'),
+    'class' => 'SearchApiAlterBundleFilter',
+    // Filters should be executed first.
+    'weight' => -10,
+  );
+  $callbacks['search_api_alter_add_url'] = array(
+    'name' => t('URL field'),
+    'description' => t("Adds the item's URL to the indexed data."),
+    'class' => 'SearchApiAlterAddUrl',
+  );
+  $callbacks['search_api_alter_add_aggregation'] = array(
+    'name' => t('Aggregated fields'),
+    'description' => t('Gives you the ability to define additional fields, containing data from one or more other fields.'),
+    'class' => 'SearchApiAlterAddAggregation',
+  );
+  $callbacks['search_api_alter_add_viewed_entity'] = array(
+    'name' => t('Complete entity view'),
+    'description' => t('Adds an additional field containing the whole HTML content of the entity when viewed.'),
+    'class' => 'SearchApiAlterAddViewedEntity',
+  );
+  $callbacks['search_api_alter_add_hierarchy'] = array(
+    'name' => t('Index hierarchy'),
+    'description' => t('Allows to index hierarchical fields along with all their ancestors.'),
+    'class' => 'SearchApiAlterAddHierarchy',
+  );
+  $callbacks['search_api_alter_language_control'] = array(
+    'name' => t('Language control'),
+    'description' => t('Lets you determine the language of items in the index.'),
+    'class' => 'SearchApiAlterLanguageControl',
+  );
+  $callbacks['search_api_alter_node_access'] = array(
+    'name' => t('Node access'),
+    'description' => t('Add node access information to the index.'),
+    'class' => 'SearchApiAlterNodeAccess',
+  );
+  $callbacks['search_api_alter_node_status'] = array(
+    'name' => t('Exclude unpublished nodes'),
+    'description' => t('Exclude unpublished nodes from the index.'),
+    'class' => 'SearchApiAlterNodeStatus',
+  );
+
+  return $callbacks;
+}
+
+/**
+ * Implements hook_search_api_processor_info().
+ */
+function search_api_search_api_processor_info() {
+  $processors['search_api_case_ignore'] = array(
+    'name' => t('Ignore case'),
+    'description' => t('This processor will make searches case-insensitive for all fulltext fields (and, optionally, also for filters on string fields).'),
+    'class' => 'SearchApiIgnoreCase',
+  );
+  $processors['search_api_html_filter'] = array(
+    'name' => t('HTML filter'),
+    'description' => t('Strips HTML tags from fulltext fields and decodes HTML entities. ' .
+        'Use this processor when indexing HTML data, e.g., node bodies for certain text formats.<br />' .
+        'The processor also allows to boost (or ignore) the contents of specific elements.'),
+    'class' => 'SearchApiHtmlFilter',
+    'weight' => 10,
+  );
+  $processors['search_api_tokenizer'] = array(
+    'name' => t('Tokenizer'),
+    'description' => t('Tokenizes fulltext data by stripping whitespace. ' .
+        'This processor allows you to specify which characters make up words and which characters should be ignored, using regular expression syntax. ' .
+        'Otherwise it is up to the search server implementation to decide how to split indexed fulltext data.'),
+    'class' => 'SearchApiTokenizer',
+    'weight' => 20,
+  );
+  $processors['search_api_stopwords'] = array(
+    'name' => t('Stopwords'),
+    'description' => t('This processor prevents certain words from being indexed and removes them from search terms. ' .
+        'For best results, it should only be executed after tokenizing.'),
+    'class' => 'SearchApiStopWords',
+    'weight' => 30,
+  );
+
+  return $processors;
+}
+
+/**
+ * Inserts new unindexed items for all indexes on the specified type.
+ *
+ * @param $type
+ *   The item type of the new items.
+ * @param array $item_id
+ *   The IDs of the new items.
+ */
+function search_api_track_item_insert($type, array $item_ids) {
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  $indexes = search_api_index_load_multiple(FALSE, $conditions);
+  if (!$indexes) {
+    return;
+  }
+
+  search_api_get_datasource_controller($type)->trackItemInsert($item_ids, $indexes);
+
+  foreach ($indexes as $index) {
+    if (!empty($index->options['index_directly'])) {
+      $indexed = search_api_index_specific_items_delayed($index, $item_ids);
+    }
+  }
+}
+
+/**
+ * Mark the items with the specified IDs as "dirty", i.e., as needing to be reindexed.
+ *
+ * For indexes for which items should be indexed immediately, the items are
+ * indexed directly, instead.
+ *
+ * @param $type
+ *   The type of items, specific to the data source.
+ * @param array $item_ids
+ *   The IDs of the items to be marked dirty.
+ */
+function search_api_track_item_change($type, array $item_ids) {
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  $indexes = search_api_index_load_multiple(FALSE, $conditions);
+  if (!$indexes) {
+    return;
+  }
+  search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
+  foreach ($indexes as $index) {
+    if (!empty($index->options['index_directly'])) {
+      // For indexes with the index_directly option set, queue the items to be
+      // indexed at the end of the request.
+      try {
+        search_api_index_specific_items_delayed($index, $item_ids);
+      }
+      catch (SearchApiException $e) {
+        watchdog_exception('search_api', $e);
+      }
+    }
+  }
+}
+
+/**
+ * Marks items as queued for indexing for the specified index.
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items were queued.
+ * @param array $item_ids
+ *   The ids of the queued items.
+ */
+function search_api_track_item_queued(SearchApiIndex $index, array $item_ids) {
+  $index->datasource()->trackItemQueued($item_ids, $index);
+}
+
+/**
+ * Marks items as successfully indexed for the specified index.
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items were indexed.
+ * @param array $item_ids
+ *   The ids of the indexed items.
+ */
+function search_api_track_item_indexed(SearchApiIndex $index, array $item_ids) {
+  $index->datasource()->trackItemIndexed($item_ids, $index);
+}
+
+/**
+ * Removes items from all indexes.
+ *
+ * @param $type
+ *   The type of the items.
+ * @param array $item_ids
+ *   The IDs of the deleted items.
+ */
+function search_api_track_item_delete($type, array $item_ids) {
+  // First, delete the item from the tracking table.
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  $indexes = search_api_index_load_multiple(FALSE, $conditions);
+  if ($indexes) {
+    search_api_get_datasource_controller($type)->trackItemDelete($item_ids, $indexes);
+  }
+
+  // Then, delete it from all servers. Servers of disabled indexes have to be
+  // considered, too!
+  unset($conditions['enabled']);
+  foreach (search_api_index_load_multiple(FALSE, $conditions) as $index) {
+    if ($index->server) {
+      $server = $index->server();
+      if ($server->enabled) {
+        $server->deleteItems($item_ids, $index);
+      }
+      else {
+        $tasks = variable_get('search_api_tasks', array());
+        foreach ($item_ids as $id) {
+          $tasks[$server->machine_name][$index->machine_name][] = 'delete-' . $id;
+        }
+        variable_set('search_api_tasks', $tasks);
+      }
+    }
+  }
+}
+
+/**
+ * Indexes items for the specified index. Only items marked as changed are
+ * indexed, in their order of change (if known).
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items should be indexed.
+ * @param $limit
+ *   The number of items which should be indexed at most. -1 means no limit.
+ *
+ * @throws SearchApiException
+ *   If any error occurs during indexing.
+ *
+ * @return
+ *   Number of successfully indexed items.
+ */
+function search_api_index_items(SearchApiIndex $index, $limit = -1) {
+  // Don't try to index read-only indexes.
+  if ($index->read_only) {
+    return 0;
+  }
+
+  $queue = DrupalQueue::get('search_api_indexing_queue');
+  $queue->createQueue();
+  $indexed = 0;
+  $unlimited = $limit < 0;
+  $release_items = array();
+  while (($unlimited || $indexed < $limit) && ($item = $queue->claimItem(30))) {
+    if ($item->data['index'] === $index->machine_name) {
+      $indexed += _search_api_indexing_queue_process($item->data);
+      $queue->deleteItem($item);
+    }
+    else {
+      $release_items[] = $item;
+    }
+  }
+
+  foreach ($release_items as $item) {
+    $queue->releaseItem($item);
+  }
+
+  if ($unlimited || $indexed < $limit) {
+    $ids = search_api_get_items_to_index($index, $unlimited ? -1 : $limit - $indexed);
+    if ($ids) {
+      $indexed += count(search_api_index_specific_items($index, $ids));
+    }
+  }
+
+  return $indexed;
+}
+
+/**
+ * Indexes the specified items on the given index.
+ *
+ * Items which were successfully indexed are marked as such afterwards.
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items should be indexed.
+ * @param array $ids
+ *   The IDs of the items which should be indexed.
+ *
+ * @throws SearchApiException
+ *   If any error occurs during indexing.
+ *
+ * @return
+ *   The IDs of all successfully indexed items.
+ */
+function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
+  $items = $index->loadItems($ids);
+  // Clone items because data alterations may alter them.
+  $cloned_items = array();
+  foreach ($items as $id => $item) {
+    $cloned_items[$id] = clone $item;
+  }
+  $indexed = $items ? $index->index($cloned_items) : array();
+  if ($indexed) {
+    search_api_track_item_indexed($index, $indexed);
+    // If some items could not be indexed, we don't want to try re-indexing
+    // them right away, so we mark them as "freshly" changed. Sadly, there is
+    // no better way than to mark them as indexed first...
+    if (count($indexed) < count($ids)) {
+      // Believe it or not but this is actually quite faster than the equivalent
+      // $diff = array_diff($ids, $indexed);
+      $diff = array_keys(array_diff_key(array_flip($ids), array_flip($indexed)));
+      $index->datasource()->trackItemIndexed($diff, $index);
+      $index->datasource()->trackItemChange($diff, array($index));
+    }
+  }
+  return $indexed;
+}
+
+/**
+ * Queues items for indexing at the end of the page request.
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items should be indexed.
+ * @param array $ids
+ *   The IDs of the items which should be indexed.
+ *
+ * @return array
+ *   The current contents of the queue, as a reference.
+ *
+ * @see search_api_index_specific_items()
+ * @see _search_api_index_queued_items()
+ */
+function &search_api_index_specific_items_delayed(SearchApiIndex $index = NULL, array $ids = array()) {
+  // We cannot use drupal_static() here because the static cache is reset during
+  // batch processing, which breaks batch handling.
+  static $queue = array();
+  static $registered = FALSE;
+
+  // Only register the shutdown function once.
+  if (empty($registered)) {
+    drupal_register_shutdown_function('_search_api_index_queued_items');
+    $registered = TRUE;
+  }
+
+  // Allow for empty call to just retrieve the queue.
+  if ($index && $ids) {
+    $index_id = $index->machine_name;
+    $queue += array($index_id => array());
+    $queue[$index_id] += drupal_map_assoc($ids);
+  }
+
+  return $queue;
+}
+
+/**
+ * Returns a list of items that need to be indexed for the specified index.
+ *
+ * @param SearchApiIndex $index
+ *   The index for which items should be retrieved.
+ * @param $limit
+ *   The maximum number of items to retrieve. -1 means no limit.
+ *
+ * @return array
+ *   An array of IDs of items that need to be indexed.
+ */
+function search_api_get_items_to_index(SearchApiIndex $index, $limit = -1) {
+  if ($limit == 0) {
+    return array();
+  }
+  return $index->datasource()->getChangedItems($index, $limit);
+}
+
+/**
+ * Creates a search query on a specified search index.
+ *
+ * @param $id
+ *   The ID or machine name of the index to execute the search on.
+ * @param $options
+ *   An associative array of options. The following are recognized:
+ *   - filters: Either a SearchApiQueryFilterInterface object or an array of
+ *     filters used to filter the search.
+ *   - sort: An array of sort directives of the form $field => $order, where
+ *     $order is either 'ASC' or 'DESC'.
+ *   - offset: The position of the first returned search results relative to the
+ *     whole result in the index.
+ *   - limit: The maximum number of search results to return. -1 means no limit.
+ *   - 'query class': The query class to use. Must be a subtype of
+ *     SearchApiQueryInterface.
+ *   - conjunction: The type of conjunction to use for this query - either
+ *     'AND' or 'OR'. 'AND' by default.
+ *   - 'parse mode': The mode with which to parse the $keys variable, if it
+ *     is set and not already an array. See SearchApiQuery::parseModes() for
+ *     parse modes recognized by the SearchApiQuery class.
+ *     Subclasses might define additional modes.
+ *
+ * @return SearchApiQueryInterface
+ *   An object for searching on the specified index.
+ */
+function search_api_query($id, array $options = array()) {
+  $index = search_api_index_load($id);
+  if (!$index) {
+    throw new SearchApiException(t('Unknown index with ID @id.', array('@id' => $id)));
+  }
+  return $index->query($options);
+}
+
+/**
+ * Static store for the searches executed on the current page. Can either be
+ * used to store an executed search, or to retrieve a previously stored
+ * search.
+ *
+ * @param $search_id
+ *   For pages displaying multiple searches, an optional ID identifying the
+ *   search in questions. When storing a search, this is filled automatically,
+ *   unless it is manually set.
+ * @param SearchApiQuery $query
+ *   When storing an executed search, the query that was executed. NULL
+ *   otherwise.
+ * @param array $results
+ *   When storing an executed search, the returned results as specified by
+ *   SearchApiQueryInterface::execute(). An empty array, otherwise.
+ *
+ * @return array
+ *   If a search with the specified ID was executed, an array containing
+ *   ($query, $results) as used in this function's parameters. If $search_id is
+ *   NULL, an array of all executed searches will be returned, keyed by ID.
+ */
+function search_api_current_search($search_id = NULL, SearchApiQuery $query = NULL, array $results = array()) {
+  $searches = &drupal_static(__FUNCTION__, array());
+
+  if (isset($query)) {
+    if (!isset($search_id)) {
+      $search_id = $query->getOption('search id');
+    }
+    $base = $search_id;
+    $i = 0;
+    while (isset($searches[$search_id])) {
+      $search_id = $base . '-' . ++$i;
+    }
+    $searches[$search_id] = array($query, $results);
+  }
+
+  if (isset($search_id)) {
+    return isset($searches[$search_id]) ? $searches[$search_id] : NULL;
+  }
+  return $searches;
+}
+
+/**
+ * Returns all field types recognized by the Search API framework.
+ *
+ * @return array
+ *   An associative array with all recognized types as keys, mapped to their
+ *   translated display names.
+ *
+ * @see search_api_default_field_types()
+ * @see search_api_get_data_type_info()
+ */
+function search_api_field_types() {
+  $types = search_api_default_field_types();
+  foreach (search_api_get_data_type_info() as $id => $type) {
+    $types[$id] = $type['name'];
+  }
+  return $types;
+}
+
+/**
+ * Returns the default field types recognized by the Search API framework.
+ *
+ * @return array
+ *   An associative array with the default types as keys, mapped to their
+ *   translated display names.
+ */
+function search_api_default_field_types() {
+  return array(
+    'text' => t('Fulltext'),
+    'string' => t('String'),
+    'integer' => t('Integer'),
+    'decimal' => t('Decimal'),
+    'date' => t('Date'),
+    'duration' => t('Duration'),
+    'boolean' => t('Boolean'),
+    'uri' => t('URI'),
+  );
+}
+
+/**
+ * Returns either all custom field type definitions, or a specific one.
+ *
+ * @param $type
+ *   If specified, the type whose definition should be returned.
+ *
+ * @return array
+ *   If $type was not given, an array containing all custom data types, in the
+ *   format specified by hook_search_api_data_type_info().
+ *   Otherwise, the definition for the given type, or NULL if it is unknown.
+ *
+ * @see hook_search_api_data_type_info()
+ */
+function search_api_get_data_type_info($type = NULL) {
+  $types = &drupal_static(__FUNCTION__);
+  if (!isset($types)) {
+    $default_types = search_api_default_field_types();
+    $types = module_invoke_all('search_api_data_type_info');
+    $types = $types ? $types : array();
+    foreach ($types as &$type_info) {
+      if (!isset($type_info['fallback']) || !isset($default_types[$type_info['fallback']])) {
+        $type_info['fallback'] = 'string';
+      }
+    }
+    drupal_alter('search_api_data_type_info', $types);
+  }
+  if (isset($type)) {
+    return isset($types[$type]) ? $types[$type] : NULL;
+  }
+  return $types;
+}
+
+/**
+ * Returns either a list of all available service infos, or a specific one.
+ *
+ * @see hook_search_api_service_info()
+ *
+ * @param $id
+ *   The ID of the service info to retrieve.
+ *
+ * @return array
+ *   If $id was not specified, an array of all available service classes.
+ *   Otherwise, either the service info with the specified id (if it exists),
+ *   or NULL.
+ */
+function search_api_get_service_info($id = NULL) {
+  $services = &drupal_static(__FUNCTION__);
+
+  if (!isset($services)) {
+    $services = module_invoke_all('search_api_service_info');
+
+    // Allow other modules to alter definitions
+    drupal_alter('search_api_service_info', $services);
+  }
+
+  if (isset($id)) {
+    return isset($services[$id]) ? $services[$id] : NULL;
+  }
+  return $services;
+}
+
+/**
+ * Returns information for either all item types, or a specific one.
+ *
+ * @param $type
+ *   If set, the item type whose information should be returned.
+ *
+ * @return
+ *   If $type is given, either an array containing the information of that item
+ *   type, or NULL if it is unknown. Otherwise, an array keyed by type IDs
+ *   containing the information for all item types. Item type information is
+ *   formatted as specified by hook_search_api_item_type_info(), and has all
+ *   optional fields filled with the defaults.
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_get_item_type_info($type = NULL) {
+  $types = &drupal_static(__FUNCTION__);
+
+  if (!isset($types)) {
+    $types = module_invoke_all('search_api_item_type_info');
+    drupal_alter('search_api_item_type_info', $types);
+  }
+
+  if (isset($type)) {
+    return isset($types[$type]) ? $types[$type] : NULL;
+  }
+  return $types;
+}
+
+/**
+ * Get a data source controller object for the specified type.
+ *
+ * @param $type
+ *   The type whose data source controller should be returned.
+ *
+ * @return SearchApiDataSourceControllerInterface
+ *   The type's data source controller.
+ *
+ * @throws SearchApiException
+ *   If the type is unknown or specifies an invalid data source controller.
+ */
+function search_api_get_datasource_controller($type) {
+  $datasources = &drupal_static(__FUNCTION__, array());
+  if (empty($datasources[$type])) {
+    $info = search_api_get_item_type_info($type);
+    if (isset($info['datasource controller']) && class_exists($info['datasource controller'])) {
+      $datasources[$type] = new $info['datasource controller']($type);
+    }
+    if (empty($datasources[$type]) || !($datasources[$type] instanceof SearchApiDataSourceControllerInterface)) {
+      unset($datasources[$type]);
+      throw new SearchApiException(t('Unknown or invalid item type @type.', array('@type' => $type)));
+    }
+  }
+  return $datasources[$type];
+}
+
+/**
+ * Returns a list of all available data alter callbacks.
+ *
+ * @see hook_search_api_alter_callback_info()
+ *
+ * @return array
+ *   An array of all available data alter callbacks, keyed by function name.
+ */
+function search_api_get_alter_callbacks() {
+  $callbacks = &drupal_static(__FUNCTION__);
+
+  if (!isset($callbacks)) {
+    $callbacks = module_invoke_all('search_api_alter_callback_info');
+
+    // Initialization of optional entries with default values
+    foreach ($callbacks as $id => $callback) {
+      $callbacks[$id] += array('enabled' => TRUE, 'weight' => 0);
+    }
+  }
+
+  return $callbacks;
+}
+
+/**
+ * Returns a list of all available pre- and post-processors.
+ *
+ * @see hook_search_api_processor_info()
+ *
+ * @return array
+ *   An array of all available processors, keyed by id.
+ */
+function search_api_get_processors() {
+  $processors = &drupal_static(__FUNCTION__);
+
+  if (!isset($processors)) {
+    $processors = module_invoke_all('search_api_processor_info');
+
+    // Initialization of optional entries with default values
+    foreach ($processors as $id => $processor) {
+      $processors[$id] += array('enabled pre' => TRUE, 'enabled post' => TRUE, 'weight' => 0);
+    }
+  }
+
+  return $processors;
+}
+
+/**
+ * Implements hook_search_api_query_alter().
+ *
+ * Adds node access to the query, if enabled.
+ *
+ * @param SearchApiQueryInterface $query
+ *   The SearchApiQueryInterface object representing the search query.
+ */
+function search_api_search_api_query_alter(SearchApiQueryInterface $query) {
+  $index = $query->getIndex();
+  // Only add node access if the necessary fields are indexed in the index, and
+  // unless disabled explicitly by the query.
+  $fields = $index->options['fields'];
+  if (!empty($fields['search_api_access_node']) && !empty($fields['status']) && !empty($fields['author']) && !$query->getOption('search_api_bypass_access')) {
+    $account = $query->getOption('search_api_access_account', $GLOBALS['user']);
+    if (is_numeric($account)) {
+      $account = user_load($account);
+    }
+    if (is_object($account)) {
+      try {
+        _search_api_query_add_node_access($account, $query);
+      }
+      catch (SearchApiException $e) {
+        watchdog_exception('search_api', $e);
+      }
+    }
+    else {
+      watchdog('search_api', 'An illegal user UID was given for node access: @uid.', array('@uid' => $query->getOption('search_api_access_account', $GLOBALS['user'])), WATCHDOG_WARNING);
+    }
+  }
+}
+
+/**
+  * Build a node access subquery.
+  *
+  * @param $account
+  *   The user object, who searches.
+  *
+  * @return SearchApiQueryFilter
+  */
+function _search_api_query_add_node_access($account, SearchApiQueryInterface $query) {
+  if (!user_access('access content', $account)) {
+    // Simple hack for returning no results.
+    $query->condition('status', 0);
+    $query->condition('status', 1);
+    watchdog('search_api', 'User @name tried to execute a search, but cannot access content.', array('@name' => theme('username', array('account' => $account))), WATCHDOG_NOTICE);
+    return;
+  }
+
+  // Only filter for user which don't have full node access.
+  if (!user_access('bypass node access', $account)) {
+    // Filter by node "published" status.
+    if (user_access('view own unpublished content')) {
+      $filter = $query->createFilter('OR');
+      $filter->condition('status', NODE_PUBLISHED);
+      $filter->condition('author', $account->uid);
+      $query->filter($filter);
+    }
+    else {
+      $query->condition('status', NODE_PUBLISHED);
+    }
+    // Filter by node access grants.
+    $filter = $query->createFilter('OR');
+    $grants = node_access_grants('view', $account);
+    foreach ($grants as $realm => $gids) {
+      foreach ($gids as $gid) {
+        $filter->condition('search_api_access_node', "node_access_$realm:$gid");
+      }
+    }
+    $filter->condition('search_api_access_node', 'node_access__all');
+    $query->filter($filter);
+  }
+}
+
+/**
+ * Utility function for determining whether a field of the given type contains
+ * text data.
+ *
+ * @param $type
+ *   A string containing the type to check.
+ * @param array $allowed
+ *   Optionally, an array of allowed types.
+ *
+ * @return
+ *   TRUE if $type is either one of the specified types, or a list of such
+ *   values. FALSE otherwise.
+ */
+function search_api_is_text_type($type, array $allowed = array('text')) {
+  return array_search(search_api_extract_inner_type($type), $allowed) !== FALSE;
+}
+
+/**
+ * Utility function for determining whether a field of the given type contains
+ * a list of any kind.
+ *
+ * @param $type
+ *   A string containing the type to check.
+ *
+ * @return
+ *   TRUE iff $type is a list type ("list<*>").
+ */
+function search_api_is_list_type($type) {
+  return substr($type, 0, 5) == 'list<';
+}
+
+/**
+ * Utility function for determining the nesting level of a list type.
+ *
+ * @param $type
+ *   A string containing the type to check.
+ *
+ * @return
+ *   The nesting level of the type. 0 for singular types, 1 for lists of
+ *   singular types, etc.
+ */
+function search_api_list_nesting_level($type) {
+  $level = 0;
+  while (search_api_is_list_type($type)) {
+    $type = substr($type, 5, -1);
+    ++$level;
+  }
+  return $level;
+}
+
+/**
+ * Utility function for nesting a type to the same level as another type.
+ * I.e., after <code>$t = search_api_nest_type($type, $nested_type);</code> is
+ * executed, the following statements will always be true:
+ * @code
+ * search_api_list_nesting_level($t) == search_api_list_nesting_level($nested_type);
+ * search_api_extract_inner_type($t) == search_api_extract_inner_type($type);
+ * @endcode
+ *
+ * @param $type
+ *   The type to wrap.
+ * @param $nested_type
+ *   Another type, determining the nesting level.
+ *
+ * @return
+ *   A list version of $type, as specified above.
+ */
+function search_api_nest_type($type, $nested_type) {
+  while (search_api_is_list_type($nested_type)) {
+    $nested_type = substr($nested_type, 5, -1);
+    $type = "list<$type>";
+  }
+  return $type;
+}
+
+/**
+ * Utility function for extracting the contained primitive type of a list type.
+ *
+ * @param $type
+ *   A string containing the list type to process.
+ *
+ * @return
+ *   A string containing the primitive type contained within the list, e.g.
+ *   "text" for "list<text>" (or for "list<list<text>>"). If $type is no list
+ *   type, it is returned unchanged.
+ */
+function search_api_extract_inner_type($type) {
+  while (search_api_is_list_type($type)) {
+    $type = substr($type, 5, -1);
+  }
+  return $type;
+}
+
+/**
+ * Utility function for extracting specific fields from an EntityMetadataWrapper
+ * object.
+ *
+ * @param EntityMetadataWrapper $wrapper
+ *   The wrapper from which to extract fields.
+ * @param array $fields
+ *   The fields to extract, as stored in an index. I.e., the array keys are
+ *   field names, the values are arrays with the keys "name", "type", "boost"
+ *   and "indexed" (although only "type" is used by this function).
+ * @param array $value_options
+ *   An array of options that should be passed to the
+ *   EntityMetadataWrapper::value() method (see there).
+ *
+ * @return
+ *   The $fields array with additional "value" and "original_type" keys set.
+ */
+function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields, array $value_options = array()) {
+  // If $wrapper is a list of entities, we have to aggregate their field values.
+  $wrapper_info = $wrapper->info();
+  if (search_api_is_list_type($wrapper_info['type'])) {
+    foreach ($fields as $field => &$info) {
+      $info['value'] = array();
+      $info['original_type'] = $info['type'];
+    }
+    unset($info);
+    try {
+      foreach ($wrapper as $i => $w) {
+        $nested_fields = search_api_extract_fields($w, $fields, $value_options);
+        foreach ($nested_fields as $field => $info) {
+          if (isset($info['value'])) {
+            $fields[$field]['value'][] = $info['value'];
+          }
+          if (isset($info['original_type'])) {
+            $fields[$field]['original_type'] = $info['original_type'];
+          }
+        }
+      }
+    }
+    catch (EntityMetadataWrapperException $e) {
+      // Catch exceptions caused by not set list values.
+    }
+    return $fields;
+  }
+
+  $nested = array();
+  $entity_infos = entity_get_info();
+  foreach ($fields as $field => &$info) {
+    $pos = strpos($field, ':');
+    if ($pos === FALSE) {
+      // Set "defaults" in case an error occurs later.
+      $info['value'] = NULL;
+      $info['original_type'] = $info['type'];
+      try {
+        $info['value'] = $wrapper->$field->value($value_options);
+        // For fulltext fields with options, also include the option labels.
+        if (search_api_is_text_type($info['type']) && $wrapper->$field->optionsList('view')) {
+          _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
+        }
+        $property_info = $wrapper->$field->info();
+        $info['original_type'] = $property_info['type'];
+        // For entities, we extract the entity ID instead of the whole object.
+        // @todo Use 'identifier' => TRUE instead of always loading the object.
+        $t = search_api_extract_inner_type($property_info['type']);
+        if (isset($entity_infos[$t])) {
+          // If no object is set, set this field to NULL.
+          $info['value'] = $info['value'] ? _search_api_extract_entity_value($wrapper->$field, search_api_is_text_type($info['type'])) : NULL;
+        }
+      }
+      catch (EntityMetadataWrapperException $e) {
+        // This might happen for entity-typed properties that are NULL, e.g.,
+        // for comments without parent.
+      }
+    }
+    else {
+      list($prefix, $key) = explode(':', $field, 2);
+      $nested[$prefix][$key] = $info;
+    }
+  }
+  unset($info);
+
+  foreach ($nested as $prefix => $nested_fields) {
+    if (isset($wrapper->$prefix)) {
+      $nested_fields = search_api_extract_fields($wrapper->$prefix, $nested_fields, $value_options);
+      foreach ($nested_fields as $field => $info) {
+        $fields["$prefix:$field"] = $info;
+      }
+    }
+    else {
+      foreach ($nested_fields as $field => &$info) {
+        $info['value'] = NULL;
+        $info['original_type'] = $info['type'];
+      }
+    }
+  }
+  return $fields;
+}
+
+/**
+ * Helper method for adding additional text data to fields with an option list.
+ */
+function _search_api_add_option_values(&$value, array $options) {
+  if (is_array($value)) {
+    foreach ($value as &$v) {
+      _search_api_add_option_values($v, $options);
+    }
+    return;
+  }
+  if (is_scalar($value) && isset($options[$value])) {
+    $value .= ' ' . $options[$value];
+  }
+}
+
+/**
+ * Helper method for extracting the ID (and possibly label) of an entity-valued field.
+ */
+function _search_api_extract_entity_value(EntityMetadataWrapper $wrapper, $fulltext = FALSE) {
+  $v = $wrapper->value();
+  if (is_array($v)) {
+    $ret = array();
+    foreach ($wrapper as $item) {
+      $values = _search_api_extract_entity_value($item, $fulltext);
+      if ($values) {
+        $ret[] = $values;
+      }
+    }
+    return $ret;
+  }
+  if ($v) {
+    $ret = $wrapper->getIdentifier();
+    if ($fulltext && ($label = $wrapper->label())) {
+      $ret .= ' ' . $label;
+    }
+    return $ret;
+  }
+  return NULL;
+}
+
+/**
+ * Load the search server with the specified id.
+ *
+ * @param $id
+ *   The search server's id.
+ * @param $reset
+ *   Whether to reset the internal cache.
+ *
+ * @return SearchApiServer
+ *   An object representing the server with the specified id.
+ */
+function search_api_server_load($id, $reset = FALSE) {
+  $ret = search_api_server_load_multiple(array($id), array(), $reset);
+  return $ret ? reset($ret) : FALSE;
+}
+
+/**
+ * Load multiple servers at once, determined by IDs or machine names, or by
+ * other conditions.
+ *
+ * @see entity_load()
+ *
+ * @param $ids
+ *   An array of server IDs or machine names, or FALSE to load all servers.
+ * @param $conditions
+ *   An array of conditions on the {search_api_server} table in the form
+ *   'field' => $value.
+ * @param $reset
+ *   Whether to reset the internal entity_load cache.
+ *
+ * @return array
+ *   An array of server objects keyed by machine name.
+ */
+function search_api_server_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
+  return entity_load_multiple_by_name('search_api_server', $ids, $conditions, $reset);
+}
+
+/**
+ * Entity uri callback.
+ */
+function search_api_server_url(SearchApiServer $server) {
+  return array(
+    'path' => 'admin/config/search/search_api/server/' . $server->machine_name,
+    'options' => array(),
+  );
+}
+
+/**
+ * Title callback for determining which title should be displayed for the
+ * "delete" local task.
+ *
+ * @param Entity $entity
+ *   The server or index for which the menu link is displayed.
+ *
+ * @return string
+ *   A translated version of either "Delete" or "Revert".
+ */
+function search_api_title_delete_page(Entity $entity) {
+  return $entity->hasStatus(ENTITY_OVERRIDDEN) ? t('Revert') : t('Delete');
+}
+
+/**
+ * Access callback for determining if a server's or index' "delete" page should
+ * be accessible.
+ *
+ * @param Entity $entity
+ *   The server or index for which the access to the delete page is checked.
+ *
+ * @return
+ *   TRUE if the delete page can be accessed by the user, FALSE otherwise.
+ */
+function search_api_access_delete_page(Entity $entity) {
+  return user_access('administer search_api') && $entity->hasStatus(ENTITY_CUSTOM);
+}
+
+/**
+ * Inserts a new search server into the database.
+ *
+ * @param array $values
+ *   An array containing the values to be inserted.
+ *
+ * @return
+ *   The newly inserted server's id, or FALSE on error.
+ */
+function search_api_server_insert(array $values) {
+  $server = entity_create('search_api_server', $values);
+  $server->is_new = TRUE;
+  $server->save();
+  return $server->id;
+}
+
+/**
+ * Changes a server's settings.
+ *
+ * @param $id
+ *   The ID or machine name of the server whose values should be changed.
+ * @param array $fields
+ *   The new field values to set. The enabled field can't be set this way, use
+ *   search_api_server_enable() and search_api_server_disable() instead.
+ *
+ * @return
+ *   1 if fields were changed, 0 if the fields already had the desired values.
+ *   FALSE on failure.
+ */
+function search_api_server_edit($id, array $fields) {
+  $server = search_api_server_load($id, TRUE);
+  $ret = $server->update($fields);
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Enables a search server. Will also check for remembered tasks for this server
+ * and execute them.
+ *
+ * @param $id
+ *   The ID or machine name of the server to enable.
+ *
+ * @return
+ *   1 on success, 0 or FALSE on failure.
+ */
+function search_api_server_enable($id) {
+  $server = search_api_server_load($id, TRUE);
+  $ret = $server->update(array('enabled' => 1));
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Disables a search server, along with all associated indexes.
+ *
+ * @param $id
+ *   The ID or machine name of the server to disable.
+ *
+ * @return
+ *   1 on success, 0 or FALSE on failure.
+ */
+function search_api_server_disable($id) {
+  $server = search_api_server_load($id, TRUE);
+  $ret = $server->update(array('enabled' => 0));
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Deletes a search server and disables all associated indexes.
+ *
+ * @param $id
+ *   The ID or machine name of the server to delete.
+ *
+ * @return
+ *   1 on success, 0 or FALSE on failure.
+ */
+function search_api_server_delete($id) {
+  $server = search_api_server_load($id, TRUE);
+  $server->delete();
+  return 1;
+}
+
+/**
+ * Loads the Search API index with the specified id.
+ *
+ * @param $id
+ *   The index' id.
+ * @param $reset
+ *   Whether to reset the internal cache.
+ *
+ * @return SearchApiIndex
+ *   A completely loaded index object, or NULL if no such index exists.
+ */
+function search_api_index_load($id, $reset = FALSE) {
+  $ret = search_api_index_load_multiple(array($id), array(), $reset);
+  return $ret ? reset($ret) : FALSE;
+}
+
+/**
+ * Load multiple indexes at once, determined by IDs or machine names, or by
+ * other conditions.
+ *
+ * @see entity_load()
+ *
+ * @param $ids
+ *   An array of index IDs or machine names, or FALSE to load all indexes.
+ * @param $conditions
+ *   An array of conditions on the {search_api_index} table in the form
+ *   'field' => $value.
+ * @param $reset
+ *   Whether to reset the internal entity_load cache.
+ *
+ * @return array
+ *   An array of index objects keyed by machine name.
+ */
+function search_api_index_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
+  // This line is a workaround for a weird PDO bug in PHP 5.2.
+  // See http://drupal.org/node/889286.
+  new SearchApiIndex();
+  return entity_load_multiple_by_name('search_api_index', $ids, $conditions, $reset);
+}
+
+/**
+ * Determines a search index' indexing status.
+ *
+ * @param SearchApiIndex $index
+ *   The index whose indexing status should be determined.
+ *
+ * @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.
+ */
+function search_api_index_status(SearchApiIndex $index) {
+  return $index->datasource()->getIndexStatus($index);
+}
+
+/**
+ * Entity uri callback.
+ */
+function search_api_index_url(SearchApiIndex $index) {
+  return array(
+    'path' => 'admin/config/search/search_api/index/' . $index->machine_name,
+    'options' => array(),
+  );
+}
+
+/**
+ * Property callback.
+ *
+ * @return SearchApiServer
+ *   The server this index currently resides on, or NULL if the index
+ *   is currently unassigned.
+ */
+function search_api_index_get_server(SearchApiIndex $index) {
+  return $index->server();
+}
+
+/**
+ * Inserts a new search index into the database.
+ *
+ * @param array $values
+ *   An array containing the values to be inserted.
+ *
+ * @return
+ *   The newly inserted index' id, or FALSE on error.
+ */
+function search_api_index_insert(array $values) {
+  $index = entity_create('search_api_index', $values);
+  $index->is_new = TRUE;
+  $index->save();
+  return $index->id;
+}
+
+/**
+ * Changes an index' settings.
+ *
+ * @param $id
+ *   The edited index' id.
+ * @param array $fields
+ *   The new field values to set.
+ *
+ * @return
+ *   1 if fields were changed, 0 if the fields already had the desired values.
+ *   FALSE on failure.
+ */
+function search_api_index_edit($id, array $fields) {
+  $index = search_api_index_load($id, TRUE);
+  $ret = $index->update($fields);
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Changes an index' indexed field settings.
+ *
+ * @param $id
+ *   The ID or machine name of the index whose fields should be changed.
+ * @param array $fields
+ *   The new indexed field settings.
+ *
+ * @return
+ *   1 if the field settings were changed, 0 if they already had the desired
+ *   values. FALSE on failure.
+ */
+function search_api_index_edit_fields($id, array $fields) {
+  $index = search_api_index_load($id, TRUE);
+  $options = $index->options;
+  $options['fields'] = $fields;
+  $ret = $index->update(array('options' => $options));
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Enables a search index.
+ *
+ * @param $id
+ *   The ID or machine name of the index to enable.
+ *
+ * @throws SearchApiException
+ *   If the index' server isn't enabled.
+ *
+ * @return
+ *   1 on success, 0 or FALSE on failure.
+ */
+function search_api_index_enable($id) {
+  $index = search_api_index_load($id, TRUE);
+  $ret = $index->update(array('enabled' => 1));
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Disables a search index.
+ *
+ * @param $id
+ *   The ID or machine name of the index to disable.
+ *
+ * @return
+ *   1 on success, 0 or FALSE on failure.
+ */
+function search_api_index_disable($id) {
+  $index = search_api_index_load($id, TRUE);
+  $ret = $index->update(array('enabled' => 0));
+  return $ret ? 1 : $ret;
+}
+
+/**
+ * Schedules a search index for re-indexing.
+ *
+ * @param $id
+ *   The ID or machine name of the index to re-index.
+ *
+ * @return
+ *   TRUE on success, FALSE on failure.
+ */
+function search_api_index_reindex($id) {
+  $index = search_api_index_load($id);
+  return $index->reindex();
+}
+
+/**
+ * Helper method for marking all items on an index as needing re-indexing.
+ *
+ * @param SearchApiIndex $index
+ *   The index whose items should be re-indexed.
+ */
+function _search_api_index_reindex(SearchApiIndex $index) {
+  $index->datasource()->trackItemChange(FALSE, array($index), TRUE);
+  _search_api_empty_cron_queue($index);
+}
+
+/**
+ * Helper method for removing all of an index's jobs from the cron queue.
+ *
+ * @param SearchApiIndex $index
+ *   The index whose jobs should be removed.
+ * @param $mark_changed
+ *   If TRUE, mark all items in the queue as "changed" again. Defaults to FALSE.
+ */
+function _search_api_empty_cron_queue(SearchApiIndex $index, $mark_changed = FALSE) {
+  $index_id = $index->machine_name;
+  $queue = DrupalQueue::get('search_api_indexing_queue');
+  $queue->createQueue();
+  $ids = array();
+  $release_items = array();
+  while ($item = $queue->claimItem()) {
+    if ($item->data['index'] === $index_id) {
+      $queue->deleteItem($item);
+      if ($mark_changed) {
+        $ids = array_merge($ids, $item->data['items']);
+      }
+    }
+    else {
+      $release_items[] = $item;
+    }
+  }
+
+  foreach ($release_items as $item) {
+    $queue->releaseItem($item);
+  }
+
+  if ($ids) {
+    $index->datasource()->trackItemChange($ids, array($index), TRUE);
+  }
+}
+
+/**
+ * Clears a search index and schedules all of its items for re-indexing.
+ *
+ * @param $id
+ *   The ID or machine name of the index to clear.
+ *
+ * @return
+ *   TRUE on success, FALSE on failure.
+ */
+function search_api_index_clear($id) {
+  $index = search_api_index_load($id);
+  return $index->clear();
+}
+
+/**
+ * Deletes a search index.
+ *
+ * @param $id
+ *   The ID or machine name of the index to delete.
+ *
+ * @return
+ *   TRUE on success, FALSE on failure.
+ */
+function search_api_index_delete($id) {
+  $index = search_api_index_load($id);
+  if (!$index) {
+    return FALSE;
+  }
+  $index->delete();
+  return TRUE;
+}
+
+/**
+ * Options list callback for search indexes.
+ *
+ * @return array
+ *   An array of search index machine names mapped to their human-readable
+ *   names.
+ */
+function search_api_index_options_list() {
+  $ret = array(
+    NULL => '- ' . t('All') . ' -',
+  );
+  foreach (search_api_index_load_multiple(FALSE) as $id => $index) {
+    $ret[$id] = $index->name;
+  }
+  return $ret;
+}
+
+/**
+ * Cron queue worker callback for indexing some items.
+ *
+ * @param array $task
+ *   An associative array containing:
+ *   - index: The ID of the index on which items should be indexed.
+ *   - items: The items that should be indexed.
+ *
+ * @return
+ *   The number of successfully indexed items.
+ */
+function _search_api_indexing_queue_process(array $task) {
+  $index = search_api_index_load($task['index']);
+  try {
+    if ($index && $index->enabled && !$index->read_only && $task['items']) {
+      $indexed = search_api_index_specific_items($index, $task['items']);
+      $num = count($indexed);
+      // If some items couldn't be indexed, mark them as dirty again.
+      if ($num < count($task['items'])) {
+        // Believe it or not but this is actually quite faster than the equivalent
+        // $diff = array_diff($task['items'], $indexed);
+        $diff = array_keys(array_diff_key(array_flip($task['items']), array_flip($indexed)));
+        // Mark the items as dirty again.
+        $index->datasource()->trackItemChange($diff, array($index), TRUE);
+      }
+      if ($num) {
+        watchdog('search_api', t('Indexed @num items for index @name', array('@num' => $num, '@name' => $index->name)), NULL, WATCHDOG_INFO);
+      }
+      return $num;
+    }
+  }
+  catch (SearchApiException $e) {
+    watchdog_exception('search_api', $e);
+  }
+}
+
+/**
+ * Shutdown function which indexes all queued items, if any.
+ */
+function _search_api_index_queued_items() {
+  $queue = &search_api_index_specific_items_delayed();
+
+  try {
+    if ($queue) {
+      $indexes = search_api_index_load_multiple(array_keys($queue));
+      foreach ($indexes as $index_id => $index) {
+        search_api_index_specific_items($index, $queue[$index_id]);
+      }
+    }
+
+    // Reset the queue so we don't index the items twice by accident.
+    $queue = array();
+  }
+  catch (SearchApiException $e) {
+    watchdog_exception('search_api', $e);
+  }
+}
+
+/**
+ * Helper function to be used as a "property info alter" callback.
+ *
+ * If a wrapped entity is passed to this function, all its available properties
+ * and fields, regardless of bundle, are added to the wrapper.
+ */
+function _search_api_wrapper_add_all_properties(EntityMetadataWrapper $wrapper, array $property_info) {
+  if ($properties = entity_get_all_property_info($wrapper->type())) {
+    $property_info['properties'] = $properties;
+  }
+  return $property_info;
+}
+
+/**
+ * Helper function for converting data to a custom type.
+ */
+function _search_api_convert_custom_type($callback, $value, $original_type, $type, $nesting_level) {
+  if ($nesting_level == 0) {
+    return call_user_func($callback, $value, $original_type, $type);
+  }
+  if (!is_array($value)) {
+    return NULL;
+  }
+  --$nesting_level;
+  $values = array();
+  foreach ($value as $v) {
+    $v = _search_api_convert_custom_type($callback, $v, $original_type, $type, $nesting_level);
+    if (isset($v) && !(is_array($v) && !$v)) {
+      $values[] = $v;
+    }
+  }
+  return $values;
+}
+
+/**
+ * Create and set a batch for indexing items.
+ *
+ * @param SearchApiIndex $index
+ *   The index for which items should be indexed.
+ * @param $batch_size
+ *   Number of items to index per batch.
+ * @param $limit
+ *   Maximum number of items to index.
+ * @param $remaining
+ *   Remaining items to index.
+ * @param $drush
+ *   Boolean specifying whether this was called from drush or not.
+ */
+function _search_api_batch_indexing_create(SearchApiIndex $index, $batch_size, $limit, $remaining, $drush = FALSE) {
+  if ($limit !== 0 && $batch_size !== 0) {
+    $t = !empty($drush) ? 'dt' : 't';
+
+    if ($limit < 0 || $limit > $remaining) {
+      $limit = $remaining;
+    }
+    if ($batch_size < 0) {
+      $batch_size = $remaining;
+    }
+    $batch = array(
+      'title' => $t('Indexing items'),
+      'operations' => array(
+        array('_search_api_batch_indexing_callback', array($index, $batch_size, $limit, $drush)),
+      ),
+      'progress_message' => $t('Completed about @percentage% of the indexing operation.'),
+      'finished' => '_search_api_batch_indexing_finished',
+      'file' => drupal_get_path('module', 'search_api') . '/search_api.module',
+    );
+    batch_set($batch);
+    return TRUE;
+  }
+  return FALSE;
+}
+
+/**
+ * Batch API callback for the indexing functionality.
+ *
+ * @param SearchApiIndex $index
+ *   The index for which items should be indexed.
+ * @param integer $batch_size
+ *   Number of items to index per batch.
+ * @param integer $limit
+ *   Maximum number of items to index.
+ * @param boolean $drush
+ *   Boolean specifying whether this was called from drush or not.
+ * @param array $context
+ *   The batch context.
+ */
+function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size, $limit, $drush = FALSE, array &$context) {
+  // Persistent data among batch runs.
+  if (!isset($context['sandbox']['limit'])) {
+    $context['sandbox']['limit'] = $limit;
+    $context['sandbox']['batch_size'] = $batch_size;
+    $context['sandbox']['progress'] = 0;
+  }
+
+  // Persistent data for results.
+  if (!isset($context['results']['indexed'])) {
+    $context['results']['indexed'] = 0;
+    $context['results']['not indexed'] = 0;
+    $context['results']['drush'] = $drush;
+  }
+
+  // Number of items to index for this run.
+  $to_index = min($context['sandbox']['limit'] - $context['sandbox']['progress'], $context['sandbox']['batch_size']);
+
+  // Index the items.
+  $indexed = search_api_index_items($index, $to_index);
+  $context['results']['indexed'] += $indexed;
+
+  // Display progress message.
+  if ($indexed > 0) {
+    $format_plural = $context['results']['drush'] === TRUE ? '_search_api_drush_format_plural' : 'format_plural';
+    $context['message'] = $format_plural($context['results']['indexed'], 'Successfully indexed 1 item.', 'Successfully indexed @count items.');
+  }
+
+  // Some items couldn't be indexed.
+  if ($indexed !== $to_index) {
+    $context['results']['not indexed'] += $to_index - $indexed;
+  }
+
+  $context['sandbox']['progress'] += $to_index;
+
+  // Everything has been indexed.
+  if ($indexed === 0 || $context['sandbox']['progress'] >= $context['sandbox']['limit']) {
+    $context['finished'] = 1;
+  }
+  else {
+    $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['limit'];
+  }
+}
+
+/**
+ * Batch API finishing callback for the indexing functionality.
+ *
+ * @param boolean $success
+ *   Result of the batch operation.
+ * @param array $results
+ *   Results.
+ * @param array $operations
+ *   Remaining batch operation to process.
+ */
+function _search_api_batch_indexing_finished($success, $results, $operations) {
+  // Check if called from drush.
+  if (!empty($results['drush'])) {
+    $drupal_set_message = 'drush_log';
+    $format_plural = '_search_api_drush_format_plural';
+    $t = 'dt';
+    $success_message = 'success';
+  }
+  else {
+    $drupal_set_message = 'drupal_set_message';
+    $format_plural = 'format_plural';
+    $t = 't';
+    $success_message = 'status';
+  }
+
+  // Display result messages.
+  if ($success) {
+    if (!empty($results['indexed'])) {
+      $drupal_set_message($format_plural($results['indexed'], 'Successfully indexed 1 item.', 'Successfully indexed @count items.'), $success_message);
+
+      if (!empty($results['not indexed'])) {
+       $drupal_set_message($format_plural($results['not indexed'], '1 item could not be indexed. Check the logs for details.', '@count items could not be indexed. Check the logs for details.'), 'warning');
+      }
+    }
+    else {
+      $drupal_set_message($t("Couldn't index items. Check the logs for details."), 'error');
+    }
+  }
+  else {
+    $drupal_set_message($t("An error occurred while trying to index items. Check the logs for details."), 'error');
+  }
+
+}

+ 90 - 0
search_api.rules.inc

@@ -0,0 +1,90 @@
+<?php
+
+/**
+ * @file
+ * Search API Rules integration.
+ */
+
+
+/**
+ * Implements hook_rules_action_info().
+ */
+function search_api_rules_action_info() {
+  $items['search_api_index'] = array (
+   'parameter' => array(
+      'entity' => array(
+        'type' => 'entity',
+        'label' => t('Entity'),
+        'description' => t('The item to index.'),
+      ),
+      'index' => array(
+        'type' => 'search_api_index',
+        'label' => t('Index'),
+        'description' => t('The index on which the item should be indexed. Leave blank to index on all indexes for this item type.'),
+        'optional' => TRUE,
+        'options list' => 'search_api_index_options_list',
+      ),
+      'index_immediately' => array(
+        'type' => 'boolean',
+        'label' => t('Index immediately'),
+        'description' => t('Activate for indexing the item right away, otherwise it will only be marked as dirty and indexed during the next cron run.'),
+        'optional' => TRUE,
+        'default value' => TRUE,
+        'restriction' => 'input',
+      ),
+    ),
+    'group' => t('Search API'),
+    'access callback' => '_search_api_rules_access',
+    'label' => t('Index an entity'),
+    'base' => '_search_api_rules_action_index',
+  );
+  return $items;
+}
+
+/**
+ * Rules access callback for search api actions.
+ */
+function _search_api_rules_access() {
+  return user_access('administer search_api');
+}
+
+/**
+ * Rules action for indexing an item.
+ */
+function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiIndex $index = NULL, $index_immediately = TRUE) {
+  $type = $wrapper->type();
+  $item_ids = array($wrapper->getIdentifier());
+
+  if (empty($index) && !$index_immediately) {
+    search_api_track_item_change($type, $item_ids);
+    return;
+  }
+
+
+  if ($index) {
+    $indexes = array($index);
+  }
+  else {
+    $conditions = array(
+      'enabled' => 1,
+      'item_type' => $type,
+      'read_only' => 0,
+    );
+    $indexes = search_api_index_load_multiple(FALSE, $conditions);
+    if (!$indexes) {
+      return;
+    }
+  }
+  if ($index_immediately) {
+    foreach ($indexes as $index) {
+      $indexed = search_api_index_specific_items_delayed($index, $item_ids);
+    }
+  }
+  else {
+    search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
+  }
+}
+
+function _search_api_rules_action_index_help() {
+  return t('Queues an item for reindexing. If "index immediately" is disabled then the item will be indexed during the next cron run.');
+}

+ 700 - 0
search_api.test

@@ -0,0 +1,700 @@
+<?php
+
+/**
+ * Class for testing Search API web functionality.
+ */
+class SearchApiWebTest extends DrupalWebTestCase {
+
+  protected $server_id;
+  protected $index_id;
+
+  protected function assertText($text, $message = '', $group = 'Other') {
+    return parent::assertText($text, $message ? $message : $text, $group);
+  }
+
+  protected function drupalGet($path, array $options = array(), array $headers = array()) {
+    $ret = parent::drupalGet($path, $options, $headers);
+    $this->assertResponse(200, t('HTTP code 200 returned.'));
+    return $ret;
+  }
+
+  protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
+    $ret = parent::drupalPost($path, $edit, $submit, $options, $headers, $form_html_id, $extra_post);
+    $this->assertResponse(200, t('HTTP code 200 returned.'));
+    return $ret;
+  }
+
+  public static function getInfo() {
+    return array(
+      'name' => 'Test search API framework',
+      'description' => 'Tests basic functions of the Search API, like creating, editing and deleting servers and indexes.',
+      'group' => 'Search API',
+    );
+  }
+
+  public function setUp() {
+    parent::setUp('entity', 'search_api', 'search_api_test');
+  }
+
+  public function testFramework() {
+    $this->drupalLogin($this->drupalCreateUser(array('administer search_api')));
+    // @todo Why is there no default index?
+    //$this->deleteDefaultIndex();
+    $this->insertItems();
+    $this->checkOverview1();
+    $this->createIndex();
+    $this->insertItems(5);
+    $this->createServer();
+    $this->checkOverview2();
+    $this->enableIndex();
+    $this->searchNoResults();
+    $this->indexItems();
+    $this->searchSuccess();
+    $this->editServer();
+    $this->clearIndex();
+    $this->searchNoResults();
+    $this->deleteServer();
+  }
+
+  protected function deleteDefaultIndex() {
+    $this->drupalPost('admin/config/search/search_api/index/default_node_index/delete', array(), t('Confirm'));
+  }
+
+  protected function insertItems($offset = 0) {
+    $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField();
+    $this->insertItem(array(
+      'id' => $offset + 1,
+      'title' => 'Title 1',
+      'body' => 'Body text 1.',
+      'type' => 'Item',
+    ));
+    $this->insertItem(array(
+      'id' => $offset + 2,
+      'title' => 'Title 2',
+      'body' => 'Body text 2.',
+      'type' => 'Item',
+    ));
+    $this->insertItem(array(
+      'id' => $offset + 3,
+      'title' => 'Title 3',
+      'body' => 'Body text 3.',
+      'type' => 'Item',
+    ));
+    $this->insertItem(array(
+      'id' => $offset + 4,
+      'title' => 'Title 4',
+      'body' => 'Body text 4.',
+      'type' => 'Page',
+    ));
+    $this->insertItem(array(
+      'id' => $offset + 5,
+      'title' => 'Title 5',
+      'body' => 'Body text 5.',
+      'type' => 'Page',
+    ));
+    $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count;
+    $this->assertEqual($count, 5, t('@count items inserted.', array('@count' => $count)));
+  }
+
+  protected function insertItem($values) {
+    $this->drupalPost('search_api_test/insert', $values, t('Save'));
+  }
+
+  protected function checkOverview1() {
+    // This test fails for no apparent reason for drupal.org test bots.
+    // Commenting them out for now.
+    //$this->drupalGet('admin/config/search/search_api');
+    //$this->assertText(t('There are no search servers or indexes defined yet.'), t('"No servers" message is displayed.'));
+  }
+
+  protected function createIndex() {
+    $values = array(
+      'name' => '',
+      'item_type' => '',
+      'enabled' => 1,
+      'description' => 'An index used for testing.',
+      'server' => '',
+      'options[cron_limit]' => 5,
+    );
+    $this->drupalPost('admin/config/search/search_api/add_index', $values, t('Create index'));
+    $this->assertText(t('!name field is required.', array('!name' => t('Index name'))));
+    $this->assertText(t('!name field is required.', array('!name' => t('Item type'))));
+
+    $this->index_id = $id = 'test_index';
+    $values = array(
+      'name' => 'Search API test index',
+      'machine_name' => $id,
+      'item_type' => 'search_api_test',
+      'enabled' => 1,
+      'description' => 'An index used for testing.',
+      'server' => '',
+      'options[cron_limit]' => 1,
+    );
+    $this->drupalPost(NULL, $values, t('Create index'));
+
+    $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), t('The index was successfully created.'));
+    $found = strpos($this->getUrl(), 'admin/config/search/search_api/index/' . $id) !== FALSE;
+    $this->assertTrue($found, t('Correct redirect.'));
+    $index = search_api_index_load($id, TRUE);
+    $this->assertEqual($index->name, $values['name'], t('Name correctly inserted.'));
+    $this->assertEqual($index->item_type, $values['item_type'], t('Index item type correctly inserted.'));
+    $this->assertFalse($index->enabled, t('Status correctly inserted.'));
+    $this->assertEqual($index->description, $values['description'], t('Description correctly inserted.'));
+    $this->assertNull($index->server, t('Index server correctly inserted.'));
+    $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], t('Cron batch size correctly inserted.'));
+
+    $values = array(
+      'additional[field]' => 'parent',
+    );
+    $this->drupalPost("admin/config/search/search_api/index/$id/fields", $values, t('Add fields'));
+    $this->assertText(t('The available fields were successfully changed.'), t('Successfully added fields.'));
+    $this->assertText('Parent » ID', t('!field displayed.', array('!field' => t('Added fields are'))));
+
+    $values = array(
+      'fields[id][type]' => 'integer',
+      'fields[id][boost]' => '1.0',
+      'fields[id][indexed]' => 1,
+      'fields[title][type]' => 'text',
+      'fields[title][boost]' => '5.0',
+      'fields[title][indexed]' => 1,
+      'fields[body][type]' => 'text',
+      'fields[body][boost]' => '1.0',
+      'fields[body][indexed]' => 1,
+      'fields[type][type]' => 'string',
+      'fields[type][boost]' => '1.0',
+      'fields[type][indexed]' => 1,
+      'fields[parent:id][type]' => 'integer',
+      'fields[parent:id][boost]' => '1.0',
+      'fields[parent:id][indexed]' => 1,
+      'fields[parent:title][type]' => 'text',
+      'fields[parent:title][boost]' => '5.0',
+      'fields[parent:title][indexed]' => 1,
+      'fields[parent:body][type]' => 'text',
+      'fields[parent:body][boost]' => '1.0',
+      'fields[parent:body][indexed]' => 1,
+      'fields[parent:type][type]' => 'string',
+      'fields[parent:type][boost]' => '1.0',
+      'fields[parent:type][indexed]' => 1,
+    );
+    $this->drupalPost(NULL, $values, t('Save changes'));
+    $this->assertText(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.'), t('Field settings saved.'));
+
+    $values = array(
+      'callbacks[search_api_alter_add_url][status]' => 1,
+      'callbacks[search_api_alter_add_url][weight]' => 0,
+      'callbacks[search_api_alter_add_aggregation][status]' => 1,
+      'callbacks[search_api_alter_add_aggregation][weight]' => 10,
+      'processors[search_api_case_ignore][status]' => 1,
+      'processors[search_api_case_ignore][weight]' => 0,
+      'processors[search_api_case_ignore][settings][fields][title]' => 1,
+      'processors[search_api_case_ignore][settings][fields][body]' => 1,
+      'processors[search_api_case_ignore][settings][fields][parent:title]' => 1,
+      'processors[search_api_case_ignore][settings][fields][parent:body]' => 1,
+      'processors[search_api_tokenizer][status]' => 1,
+      'processors[search_api_tokenizer][weight]' => 20,
+      'processors[search_api_tokenizer][settings][spaces]' => '[^\p{L}\p{N}]',
+      'processors[search_api_tokenizer][settings][ignorable]' => '[-]',
+      'processors[search_api_tokenizer][settings][fields][title]' => 1,
+      'processors[search_api_tokenizer][settings][fields][body]' => 1,
+      'processors[search_api_tokenizer][settings][fields][parent:title]' => 1,
+      'processors[search_api_tokenizer][settings][fields][parent:body]' => 1,
+    );
+    $this->drupalPost(NULL, $values, t('Add new field'));
+    $values = array(
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][name]' => 'Test fulltext field',
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][type]' => 'fulltext',
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][title]' => 1,
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][body]' => 1,
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][parent:title]' => 1,
+      'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][parent:body]' => 1,
+    );
+    $this->drupalPost(NULL, $values, t('Save configuration'));
+    $this->assertText(t("The search index' workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), t('Workflow successfully edited.'));
+
+    $this->drupalGet("admin/config/search/search_api/index/$id");
+    $this->assertTitle('Search API test index | Drupal', t('Correct title when viewing index.'));
+    $this->assertText('An index used for testing.', t('!field displayed.', array('!field' => t('Description'))));
+    $this->assertText('Search API test entity', t('!field displayed.', array('!field' => t('Item type'))));
+    $this->assertText(format_plural(1, '1 item per cron batch.', '@count items per cron batch.'), t('!field displayed.', array('!field' => t('Cron batch size'))));
+
+    $this->drupalGet("admin/config/search/search_api/index/$id/status");
+    $this->assertText(t('The index is currently disabled.'), t('"Disabled" status displayed.'));
+  }
+
+  protected function createServer() {
+    $values = array(
+      'name' => '',
+      'enabled' => 1,
+      'description' => 'A server used for testing.',
+      'class' => '',
+    );
+    $this->drupalPost('admin/config/search/search_api/add_server', $values, t('Create server'));
+    $this->assertText(t('!name field is required.', array('!name' => t('Server name'))));
+    $this->assertText(t('!name field is required.', array('!name' => t('Service class'))));
+
+    $this->server_id = $id = 'test_server';
+    $values = array(
+      'name' => 'Search API test server',
+      'machine_name' => $id,
+      'enabled' => 1,
+      'description' => 'A server used for testing.',
+      'class' => 'search_api_test_service',
+    );
+    $this->drupalPost(NULL, $values, t('Create server'));
+
+    $values2 = array(
+      'options[form][test]' => 'search_api_test foo bar',
+    );
+    $this->drupalPost(NULL, $values2, t('Create server'));
+
+    $this->assertText(t('The server was successfully created.'));
+    $found = strpos($this->getUrl(), 'admin/config/search/search_api/server/' . $id) !== FALSE;
+    $this->assertTrue($found, t('Correct redirect.'));
+    $server = search_api_server_load($id, TRUE);
+    $this->assertEqual($server->name, $values['name'], t('Name correctly inserted.'));
+    $this->assertTrue($server->enabled, t('Status correctly inserted.'));
+    $this->assertEqual($server->description, $values['description'], t('Description correctly inserted.'));
+    $this->assertEqual($server->class, $values['class'], t('Service class correctly inserted.'));
+    $this->assertEqual($server->options['test'], $values2['options[form][test]'], t('Service options correctly inserted.'));
+    $this->assertTitle('Search API test server | Drupal', t('Correct title when viewing server.'));
+    $this->assertText('A server used for testing.', t('!field displayed.', array('!field' => t('Description'))));
+    $this->assertText('search_api_test_service', t('!field displayed.', array('!field' => t('Service name'))));
+    $this->assertText('search_api_test_service description', t('!field displayed.', array('!field' => t('Service description'))));
+    $this->assertText('search_api_test foo bar', t('!field displayed.', array('!field' => t('Service options'))));
+  }
+
+  protected function checkOverview2() {
+    $this->drupalGet('admin/config/search/search_api');
+    $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
+    $this->assertText('Search API test index', t('!field displayed.', array('!field' => t('Index'))));
+    $this->assertNoText(t('There are no search servers or indexes defined yet.'), t('"No servers" message not displayed.'));
+  }
+
+  protected function enableIndex() {
+    $values = array(
+      'server' => $this->server_id,
+    );
+    $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/edit", $values, t('Save settings'));
+    $this->assertText(t('The search index was successfully edited.'));
+    $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
+
+    $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/enable");
+    $this->assertText(t('The index was successfully enabled.'));
+  }
+
+  protected function searchNoResults() {
+    $this->drupalGet('search_api_test/query/' . $this->index_id);
+    $this->assertText('result count = 0', t('No search results returned without indexing.'));
+    $this->assertText('results = ()', t('No search results returned without indexing.'));
+  }
+
+  protected function indexItems() {
+    $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
+    $this->assertText(t('The index is currently enabled.'), t('"Enabled" status displayed.'));
+    $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
+    $this->assertText(t('Index now'), t('"Index now" button found.'));
+    $this->assertText(t('Clear index'), t('"Clear index" button found.'));
+    $this->assertNoText(t('Re-index content'), t('"Re-index" button not found.'));
+
+    // Here we test the indexing + the warning message when some items
+    // can not be indexed.
+    // The server refuses (for test purpose) to index items with IDs that are
+    // multiples of 8 unless the "search_api_test_index_all" variable is set.
+    $values = array(
+      'limit' => 8,
+    );
+    $this->drupalPost(NULL, $values, t('Index now'));
+    $this->assertText(t('Successfully indexed @count items.', array('@count' => 7)));
+    $this->assertText(t('1 item could not be indexed. Check the logs for details.'), t('Index errors warning is displayed.'));
+    $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("Index error isn't displayed."));
+    $this->assertText(t('About @percentage% of all items have been indexed in their latest version (@indexed / @total).', array('@indexed' => 7, '@total' => 10, '@percentage' => 70)), t('!field displayed.', array('!field' => t('Correct index status'))));
+    $this->assertText(t('Re-indexing'), t('"Re-index" button found.'));
+
+    // Here we're testing the error message when no item could be indexed.
+    // The item with ID 8 is still not indexed.
+    $values = array(
+      'limit' => 1,
+    );
+    $this->drupalPost(NULL, $values, t('Index now'));
+    $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', t('No items could be indexed.'));
+    $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), t("Index errors warning isn't displayed."));
+    $this->assertText(t("Couldn't index items. Check the logs for details."), t('Index error is displayed.'));
+
+    // Here we test the indexing of all the remaining items.
+    variable_set('search_api_test_index_all', TRUE);
+    $values = array(
+      'limit' => -1,
+    );
+    $this->drupalPost(NULL, $values, t('Index now'));
+    $this->assertText(t('Successfully indexed @count items.', array('@count' => 3)));
+    $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), t("Index errors warning isn't displayed."));
+    $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("Index error isn't displayed."));
+    $this->assertText(t('All items have been indexed (@indexed / @total).', array('@indexed' => 10, '@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
+    $this->assertNoText(t('Index now'), t('"Index now" button no longer displayed.'));
+  }
+
+  protected function searchSuccess() {
+    $this->drupalGet('search_api_test/query/' . $this->index_id);
+    $this->assertText('result count = 10', t('Correct search result count returned after indexing.'));
+    $this->assertText('results = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', t('Correct search results returned after indexing.'));
+
+    $this->drupalGet('search_api_test/query/' . $this->index_id . '/foo/2/4');
+    $this->assertText('result count = 10', t('Correct search result count with ranged query.'));
+    $this->assertText('results = (3, 4, 5, 6)', t('Correct search results with ranged query.'));
+  }
+
+  protected function editServer() {
+    $values = array(
+      'name' => 'test-name-foo',
+      'description' => 'test-description-bar',
+      'options[form][test]' => 'test-test-baz',
+    );
+    $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/edit", $values, t('Save settings'));
+    $this->assertText(t('The search server was successfully edited.'));
+    $this->assertText('test-name-foo', t('!field changed.', array('!field' => t('Name'))));
+    $this->assertText('test-description-bar', t('!field changed.', array('!field' => t('Description'))));
+    $this->assertText('test-test-baz', t('!field changed.', array('!field' => t('Service options'))));
+  }
+
+  protected function clearIndex() {
+    $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/status", array(), t('Clear index'));
+    $this->assertText(t('The index was successfully cleared.'));
+    $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
+  }
+
+  protected function deleteServer() {
+    $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/delete", array(), t('Confirm'));
+    $this->assertNoText('test-name-foo', t('Server no longer listed.'));
+    $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
+    $this->assertText(t('The index is currently disabled.'), t('The index was disabled and removed from the server.'));
+  }
+
+}
+
+/**
+ * Class with unit tests testing small fragments of the Search API.
+ *
+ * Due to severe limitations for "real" unit tests, this still has to be a
+ * subclass of DrupalWebTestCase.
+ */
+class SearchApiUnitTest extends DrupalWebTestCase {
+
+  protected $index;
+
+  protected function assertEqual($first, $second, $message = '', $group = 'Other') {
+    if (is_array($first) && is_array($second)) {
+      return $this->assertTrue($this->deepEquals($first, $second), $message, $group);
+    }
+    else {
+      return parent::assertEqual($first, $second, $message, $group);
+    }
+  }
+
+  protected function deepEquals($first, $second) {
+    if (!is_array($first) || !is_array($second)) {
+      return $first == $second;
+    }
+    $first  = array_merge($first);
+    $second = array_merge($second);
+    foreach ($first as $key => $value) {
+      if (!array_key_exists($key, $second) || !$this->deepEquals($value, $second[$key])) {
+        return FALSE;
+      }
+      unset($second[$key]);
+    }
+    return empty($second);
+  }
+
+  public static function getInfo() {
+    return array(
+      'name' => 'Test search API components',
+      'description' => 'Tests some independent components of the Search API, like the processors.',
+      'group' => 'Search API',
+    );
+  }
+
+  public function setUp() {
+    parent::setUp('entity', 'search_api');
+    $this->index = entity_create('search_api_index', array(
+      'id' => 1,
+      'name' => 'test',
+      'enabled' => 1,
+      'item_type' => 'user',
+      'options' => array(
+        'fields' => array(
+          'name' => array(
+            'type' => 'text',
+          ),
+          'mail' => array(
+            'type' => 'string',
+          ),
+          'search_api_language' => array(
+            'type' => 'string',
+          ),
+        ),
+      ),
+    ));
+  }
+
+  public function testUnits() {
+    $this->checkQueryParseKeys();
+    $this->checkIgnoreCaseProcessor();
+    $this->checkTokenizer();
+    $this->checkHtmlFilter();
+  }
+
+  public function checkQueryParseKeys() {
+    $options['parse mode'] = 'direct';
+    $mode = &$options['parse mode'];
+    $num = 1;
+    $query = new SearchApiQuery($this->index, $options);
+    $modes = $query->parseModes();
+
+    $query->keys('foo');
+    $this->assertEqual($query->getKeys(), 'foo', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('foo bar');
+    $this->assertEqual($query->getKeys(), 'foo bar', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('(foo bar) OR "bar baz"');
+    $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+
+    $mode = 'single';
+    $num = 1;
+    $query = new SearchApiQuery($this->index, $options);
+
+    $query->keys('foo');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('foo bar');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('(foo bar) OR "bar baz"');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+
+    $mode = 'terms';
+    $num = 1;
+    $query = new SearchApiQuery($this->index, $options);
+
+    $query->keys('foo');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('foo bar');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $query->keys('(foo bar) OR "bar baz"');
+    $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    // http://drupal.org/node/1468678
+    $query->keys('"Münster"');
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+  }
+
+  public function checkIgnoreCaseProcessor() {
+    $types = search_api_field_types();
+    $orig = 'Foo bar BaZ, ÄÖÜÀÁ<>»«.';
+    $processed = drupal_strtolower($orig);
+    $items = array(
+      1 => array(
+        'name' => array(
+          'type' => 'text',
+          'original_type' => 'text',
+          'value' => $orig,
+        ),
+        'mail' => array(
+          'type' => 'string',
+          'original_type' => 'text',
+          'value' => $orig,
+        ),
+        'search_api_language' => array(
+          'type' => 'string',
+          'original_type' => 'string',
+          'value' => LANGUAGE_NONE,
+        ),
+      ),
+    );
+    $keys1 = $keys2 = array(
+      'foo',
+      'bar baz',
+      'foobar1',
+      '#conjunction' => 'AND',
+    );
+    $filters1 = array(
+      array('name', 'foo', '='),
+      array('mail', 'BAR', '='),
+    );
+    $filters2 = array(
+      array('name', 'foo', '='),
+      array('mail', 'bar', '='),
+    );
+
+    $processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name')));
+    $tmp = $items;
+    $processor->preprocessIndexItems($tmp);
+    $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
+    $this->assertEqual($tmp[1]['mail']['value'], $orig, t("!type field wasn't processed.", array('!type' => 'mail')));
+
+    $query = new SearchApiQuery($this->index);
+    $query->keys('Foo "baR BaZ" fOObAr1');
+    $query->condition('name', 'FOO');
+    $query->condition('mail', 'BAR');
+    $processor->preprocessSearchQuery($query);
+    $this->assertEqual($query->getKeys(), $keys1, t('Search keys were processed correctly.'));
+    $this->assertEqual($query->getFilter()->getFilters(), $filters1, t('Filters were processed correctly.'));
+
+    $processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name', 'mail' => 'mail')));
+    $tmp = $items;
+    $processor->preprocessIndexItems($tmp);
+    $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
+    $this->assertEqual($tmp[1]['mail']['value'], $processed, t('!type field was processed.', array('!type' => 'mail')));
+
+    $query = new SearchApiQuery($this->index);
+    $query->keys('Foo "baR BaZ" fOObAr1');
+    $query->condition('name', 'FOO');
+    $query->condition('mail', 'BAR');
+    $processor->preprocessSearchQuery($query);
+    $this->assertEqual($query->getKeys(), $keys2, t('Search keys were processed correctly.'));
+    $this->assertEqual($query->getFilter()->getFilters(), $filters2, t('Filters were processed correctly.'));
+  }
+
+  public function checkTokenizer() {
+    $orig = 'Foo bar1 BaZ,  La-la-la.';
+    $processed1 = array(
+      array(
+        'value' => 'Foo',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'bar1',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'BaZ',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'Lalala',
+        'score' => 1,
+      ),
+    );
+    $processed2 = array(
+      array(
+        'value' => 'Foob',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'r1B',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'Z,L',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'l',
+        'score' => 1,
+      ),
+      array(
+        'value' => 'l',
+        'score' => 1,
+      ),
+      array(
+        'value' => '.',
+        'score' => 1,
+      ),
+    );
+    $items = array(
+      1 => array(
+        'name' => array(
+          'type' => 'text',
+          'original_type' => 'text',
+          'value' => $orig,
+        ),
+        'search_api_language' => array(
+          'type' => 'string',
+          'original_type' => 'string',
+          'value' => LANGUAGE_NONE,
+        ),
+      ),
+    );
+
+    $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[^\p{L}\p{N}]', 'ignorable' => '[-]'));
+    $tmp = $items;
+    $processor->preprocessIndexItems($tmp);
+    $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Value was correctly tokenized with default settings.'));
+
+    $query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
+    $query->keys("foo \"bar-baz\" \n\t foobar1");
+    $processor->preprocessSearchQuery($query);
+    $this->assertEqual($query->getKeys(), 'foo barbaz foobar1', t('Search keys were processed correctly.'));
+
+    $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[-a]', 'ignorable' => '\s'));
+    $tmp = $items;
+    $processor->preprocessIndexItems($tmp);
+    $this->assertEqual($tmp[1]['name']['value'], $processed2, t('Value was correctly tokenized with custom settings.'));
+
+    $query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
+    $query->keys("foo \"bar-baz\" \n\t foobar1");
+    $processor->preprocessSearchQuery($query);
+    $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', t('Search keys were processed correctly.'));
+  }
+
+  public function checkHtmlFilter() {
+    $orig = <<<END
+This is <em lang="en" title =
+"something">a test</em>.
+How to write <strong>links to <em>other sites</em></strong>: &lt;a href="URL" title="MOUSEOVER TEXT"&gt;TEXT&lt;/a&gt;.
+&lt; signs can be <A HREF="http://example.com/topic/html-escapes" TITLE =  'HTML &quot;escapes&quot;'
+TARGET = '_blank'>escaped</A> with "&amp;lt;".
+<img src = "foo.png" alt = "someone's image" />
+END;
+    $tags = <<<END
+em = 1.5
+strong = 2
+END;
+    $processed1 = array(
+      array('value' => 'This', 'score' => 1),
+      array('value' => 'is', 'score' => 1),
+      array('value' => 'something', 'score' => 1.5),
+      array('value' => 'a', 'score' => 1.5),
+      array('value' => 'test', 'score' => 1.5),
+      array('value' => 'How', 'score' => 1),
+      array('value' => 'to', 'score' => 1),
+      array('value' => 'write', 'score' => 1),
+      array('value' => 'links', 'score' => 2),
+      array('value' => 'to', 'score' => 2),
+      array('value' => 'other', 'score' => 3),
+      array('value' => 'sites', 'score' => 3),
+      array('value' => '<a', 'score' => 1),
+      array('value' => 'href="URL"', 'score' => 1),
+      array('value' => 'title="MOUSEOVER', 'score' => 1),
+      array('value' => 'TEXT">TEXT</a>', 'score' => 1),
+      array('value' => '<', 'score' => 1),
+      array('value' => 'signs', 'score' => 1),
+      array('value' => 'can', 'score' => 1),
+      array('value' => 'be', 'score' => 1),
+      array('value' => 'HTML', 'score' => 1),
+      array('value' => '"escapes"', 'score' => 1),
+      array('value' => 'escaped', 'score' => 1),
+      array('value' => 'with', 'score' => 1),
+      array('value' => '"&lt;"', 'score' => 1),
+      array('value' => 'someone\'s', 'score' => 1),
+      array('value' => 'image', 'score' => 1),
+    );
+    $items = array(
+      1 => array(
+        'name' => array(
+          'type' => 'text',
+          'original_type' => 'text',
+          'value' => $orig,
+        ),
+        'search_api_language' => array(
+          'type' => 'string',
+          'original_type' => 'string',
+          'value' => LANGUAGE_NONE,
+        ),
+      ),
+    );
+
+    $tmp = $items;
+    $processor = new SearchApiHtmlFilter($this->index, array('fields' => array('name' => 'name'), 'title' => TRUE, 'alt' => TRUE, 'tags' => $tags));
+    $processor->preprocessIndexItems($tmp);
+    $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[\s.:]', 'ignorable' => ''));
+    $processor->preprocessIndexItems($tmp);
+    $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Text was correctly processed.'));
+  }
+
+}

+ 18 - 0
tests/search_api_test.info

@@ -0,0 +1,18 @@
+
+name = Search API test
+description = "Some dummy implementations for testing the Search API."
+core = 7.x
+package = Search
+
+dependencies[] = 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"
+core = "7.x"
+project = "search_api"
+datestamp = "1357726719"
+

+ 43 - 0
tests/search_api_test.install

@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the Search API test module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function search_api_test_schema() {
+  $schema['search_api_test'] = array(
+    'description' => 'Stores instances of a test entity.',
+    'fields' => array(
+      'id' => array(
+        'description' => 'The primary identifier for an item.',
+        'type' => 'serial',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'title' => array(
+        'description' => 'The title of the item.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+      'body' => array(
+        'description' => 'A text belonging to the item.',
+        'type' => 'text',
+        'not null' => FALSE,
+      ),
+      'type' => array(
+        'description' => 'A string identifying the type of item.',
+        'type' => 'varchar',
+        'length' => 50,
+        'not null' => TRUE,
+      ),
+    ),
+    'primary key' => array('id'),
+  );
+
+  return $schema;
+}

+ 316 - 0
tests/search_api_test.module

@@ -0,0 +1,316 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function search_api_test_menu() {
+  return array(
+    'search_api_test/insert' => array(
+      'title' => 'Insert item',
+      'page callback' => 'drupal_get_form',
+      'page arguments' => array('search_api_test_insert_item'),
+      'access callback' => TRUE,
+    ),
+    'search_api_test/%search_api_test' => array(
+      'title' => 'View item',
+      'page callback' => 'search_api_test_view',
+      'page arguments' => array(1),
+      'access callback' => TRUE,
+    ),
+    'search_api_test/query/%search_api_index' => array(
+      'title' => 'Search query',
+      'page callback' => 'search_api_test_query',
+      'page arguments' => array(2),
+      'access callback' => TRUE,
+    ),
+  );
+}
+
+/**
+ * Form callback for inserting an item.
+ */
+function search_api_test_insert_item(array $form, array &$form_state) {
+  return array(
+    'id' => array(
+      '#type' => 'textfield',
+    ),
+    'title' => array(
+      '#type' => 'textfield',
+    ),
+    'body' => array(
+      '#type' => 'textarea',
+    ),
+    'type' => array(
+      '#type' => 'textfield',
+    ),
+    'submit' => array(
+      '#type' => 'submit',
+      '#value' => t('Save'),
+    ),
+  );
+}
+
+/**
+ * Submit callback for search_api_test_insert_item().
+ */
+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();
+  module_invoke_all('entity_insert', search_api_test_load($form_state['values']['id']), 'search_api_test');
+}
+
+/**
+ * Load handler for search_api_test entities.
+ */
+function search_api_test_load($id) {
+  $ret = entity_load('search_api_test', array($id));
+  return $ret ? array_shift($ret) : NULL;
+}
+
+/**
+ * Menu callback for displaying search_api_test entities.
+ */
+function search_api_test_view($entity) {
+  return array('text' => nl2br(check_plain(print_r($entity, TRUE))));
+}
+
+/**
+ * Menu callback for executing a search.
+ */
+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();
+
+  $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;
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function search_api_test_entity_info() {
+  return array(
+    'search_api_test' => array(
+      'label' => 'Search API test entity',
+      'base table' => 'search_api_test',
+      'uri callback' => 'search_api_test_uri',
+      'entity keys' => array(
+        'id' => 'id',
+      ),
+    ),
+  );
+}
+
+/**
+ * Implements hook_entity_property_info().
+ */
+function search_api_test_entity_property_info() {
+  $info['search_api_test']['properties'] = array(
+    'id' => array(
+      'label' => 'ID',
+      'type' => 'integer',
+      'description' => 'The primary identifier for a server.',
+    ),
+    'title' => array(
+      'label' => 'Title',
+      'type' => 'text',
+      'description' => 'The title of the item.',
+      'required' => TRUE,
+    ),
+    'body' => array(
+      'label' => 'Body',
+      'type' => 'text',
+      'description' => 'A text belonging to the item.',
+      'sanitize' => 'filter_xss',
+      'required' => TRUE,
+    ),
+    'type' => array(
+      'label' => 'Type',
+      'type' => 'text',
+      'description' => 'A string identifying the type of item.',
+      'required' => TRUE,
+    ),
+    'parent' => array(
+      'label' => 'Parent',
+      'type' => 'search_api_test',
+      'description' => "The item's parent.",
+      'getter callback' => 'search_api_test_parent',
+    ),
+  );
+
+  return $info;
+}
+
+/**
+ * URI callback for test entity.
+ */
+function search_api_test_uri($entity) {
+  return array(
+    'path' => 'search_api_test/' . $entity->id,
+  );
+}
+
+/**
+ * Parent callback.
+ */
+function search_api_test_parent($entity) {
+  return search_api_test_load($entity->id - 1);
+}
+
+/**
+ * Implements hook_search_api_service_info().
+ */
+function search_api_test_search_api_service_info() {
+  $services['search_api_test_service'] = array(
+    'name' => 'search_api_test_service',
+    'description' => 'search_api_test_service description',
+    'class' => 'SearchApiTestService',
+  );
+  return $services;
+}
+
+/**
+ * Test service class.
+ */
+class SearchApiTestService extends SearchApiAbstractService {
+
+  public function configurationForm(array $form, array &$form_state) {
+    $form = array(
+      'test' => array(
+        '#type' => 'textfield',
+        '#title' => 'Test option',
+      ),
+    );
+
+    if (!empty($this->options)) {
+      $form['test']['#default_value'] = $this->options['test'];
+    }
+
+    return $form;
+  }
+
+  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();
+    foreach ($items as $id => $item) {
+      if ($id % 8) {
+        $ret[] = $id;
+      }
+    }
+    return $this->index($index, $ret);
+  }
+
+  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]);
+    $this->server->save();
+    return $ids;
+  }
+
+  /**
+   * Override 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() {}
+
+  public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
+    if ($ids == 'all') {
+      if ($index) {
+        $this->options['indexes'][$index->machine_name] = array();
+      }
+      else {
+        $this->options['indexes'] = array();
+      }
+    }
+    else {
+      foreach ($ids as $id) {
+        unset($this->options['indexes'][$index->machine_name][$id]);
+      }
+    }
+    $this->server->save();
+  }
+
+  public function search(SearchApiQueryInterface $query) {
+    $options = $query->getOptions();
+    $ret = array();
+    $index_id = $query->getIndex()->machine_name;
+    if (empty($this->options['indexes'][$index_id])) {
+      return array(
+        'result count' => 0,
+        'results' => array(),
+      );
+    }
+    $items = $this->options['indexes'][$index_id];
+    $min = isset($options['offset']) ? $options['offset'] : 0;
+    $max = $min + (isset($options['limit']) ? $options['limit'] : count($items));
+    $i = 0;
+    $ret['result count'] = count($items);
+    $ret['results'] = array();
+    foreach ($items as $id) {
+      ++$i;
+      if ($i > $max) {
+        break;
+      }
+      if ($i > $min) {
+        $ret['results'][$id] = array(
+          'id' => $id,
+          'score' => 1,
+        );
+      }
+    }
+    return $ret;
+  }
+
+  public function fieldsUpdated(SearchApiIndex $index) {
+    return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+  }
+
+}