commit 9cc5ba4cfa197738b652aa6af308242ddab89251 Author: bachy Date: Fri Mar 15 17:32:30 2013 +0100 first import 7.x-1.4 diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 00000000..15956747 --- /dev/null +++ b/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 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/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. + + + Copyright (C) + + 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. + + , 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. diff --git a/README.txt b/README.txt new file mode 100644 index 00000000..a05bfa34 --- /dev/null +++ b/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 diff --git a/contrib/search_api_facetapi/README.txt b/contrib/search_api_facetapi/README.txt new file mode 100644 index 00000000..89ebf6a0 --- /dev/null +++ b/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") diff --git a/contrib/search_api_facetapi/example_service.php b/contrib/search_api_facetapi/example_service.php new file mode 100644 index 00000000..be3760d8 --- /dev/null +++ b/contrib/search_api_facetapi/example_service.php @@ -0,0 +1,209 @@ + 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; + } + +} diff --git a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc new file mode 100644 index 00000000..ee272013 --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc @@ -0,0 +1,242 @@ +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(), + ); + } + } +} diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc new file mode 100644 index 00000000..2574ba80 --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc @@ -0,0 +1,196 @@ +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; + } +} diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc new file mode 100644 index 00000000..1b2037d2 --- /dev/null +++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc @@ -0,0 +1,149 @@ +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; + } + +} diff --git a/contrib/search_api_facetapi/search_api_facetapi.api.php b/contrib/search_api_facetapi/search_api_facetapi.api.php new file mode 100644 index 00000000..eb5c890c --- /dev/null +++ b/contrib/search_api_facetapi/search_api_facetapi.api.php @@ -0,0 +1,31 @@ + $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); +} diff --git a/contrib/search_api_views/README.txt b/contrib/search_api_views/README.txt new file mode 100644 index 00000000..e04a7a3d --- /dev/null +++ b/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. diff --git a/contrib/search_api_views/includes/display_facet_block.inc b/contrib/search_api_views/includes/display_facet_block.inc new file mode 100644 index 00000000..c7b1e3aa --- /dev/null +++ b/contrib/search_api_views/includes/display_facet_block.inc @@ -0,0 +1,262 @@ + ''); + $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; + } + +} diff --git a/contrib/search_api_views/includes/handler_argument.inc b/contrib/search_api_views/includes/handler_argument.inc new file mode 100644 index 00000000..123481c9 --- /dev/null +++ b/contrib/search_api_views/includes/handler_argument.inc @@ -0,0 +1,122 @@ + 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)); + } + +} diff --git a/contrib/search_api_views/includes/handler_argument_fulltext.inc b/contrib/search_api_views/includes/handler_argument_fulltext.inc new file mode 100644 index 00000000..f53f59cc --- /dev/null +++ b/contrib/search_api_views/includes/handler_argument_fulltext.inc @@ -0,0 +1,84 @@ + 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; + } + +} diff --git a/contrib/search_api_views/includes/handler_argument_more_like_this.inc b/contrib/search_api_views/includes/handler_argument_more_like_this.inc new file mode 100644 index 00000000..b50ab4e7 --- /dev/null +++ b/contrib/search_api_views/includes/handler_argument_more_like_this.inc @@ -0,0 +1,77 @@ + 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); + } +} diff --git a/contrib/search_api_views/includes/handler_argument_text.inc b/contrib/search_api_views/includes/handler_argument_text.inc new file mode 100644 index 00000000..cc7ae241 --- /dev/null +++ b/contrib/search_api_views/includes/handler_argument_text.inc @@ -0,0 +1,17 @@ + $this->definition['title'], '@arg' => $this->argument)); + } + +} diff --git a/contrib/search_api_views/includes/handler_filter.inc b/contrib/search_api_views/includes/handler_filter.inc new file mode 100644 index 00000000..85c66745 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter.inc @@ -0,0 +1,105 @@ + 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']); + } + } + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_boolean.inc b/contrib/search_api_views/includes/handler_filter_boolean.inc new file mode 100644 index 00000000..b3b21727 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_boolean.inc @@ -0,0 +1,30 @@ +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 : '', + ); + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_date.inc b/contrib/search_api_views/includes/handler_filter_date.inc new file mode 100644 index 00000000..896eda41 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_date.inc @@ -0,0 +1,86 @@ + 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 PHP. 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']); + } + } + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_fulltext.inc b/contrib/search_api_views/includes/handler_filter_fulltext.inc new file mode 100644 index 00000000..b7f2e1e8 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_fulltext.inc @@ -0,0 +1,131 @@ + '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; + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_language.inc b/contrib/search_api_views/includes/handler_filter_language.inc new file mode 100644 index 00000000..f95ddaf9 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_language.inc @@ -0,0 +1,55 @@ + 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(); + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_options.inc b/contrib/search_api_views/includes/handler_filter_options.inc new file mode 100644 index 00000000..eda43445 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_options.inc @@ -0,0 +1,205 @@ + 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']); + } + } + } + } + } + +} diff --git a/contrib/search_api_views/includes/handler_filter_text.inc b/contrib/search_api_views/includes/handler_filter_text.inc new file mode 100644 index 00000000..a14d4a44 --- /dev/null +++ b/contrib/search_api_views/includes/handler_filter_text.inc @@ -0,0 +1,15 @@ + t('contains'), '<>' => t("doesn't contain")); + } + +} diff --git a/contrib/search_api_views/includes/handler_sort.inc b/contrib/search_api_views/includes/handler_sort.inc new file mode 100644 index 00000000..463e6555 --- /dev/null +++ b/contrib/search_api_views/includes/handler_sort.inc @@ -0,0 +1,30 @@ +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']); + } + +} diff --git a/contrib/search_api_views/includes/query.inc b/contrib/search_api_views/includes/query.inc new file mode 100755 index 00000000..a6ab7e74 --- /dev/null +++ b/contrib/search_api_views/includes/query.inc @@ -0,0 +1,564 @@ +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; + } + +} diff --git a/contrib/search_api_views/search_api_views.info b/contrib/search_api_views/search_api_views.info new file mode 100644 index 00000000..e0afa1c5 --- /dev/null +++ b/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" + diff --git a/contrib/search_api_views/search_api_views.install b/contrib/search_api_views/search_api_views.install new file mode 100644 index 00000000..804d3079 --- /dev/null +++ b/contrib/search_api_views/search_api_views.install @@ -0,0 +1,97 @@ + $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'); +} diff --git a/contrib/search_api_views/search_api_views.module b/contrib/search_api_views/search_api_views.module new file mode 100644 index 00000000..7a65d730 --- /dev/null +++ b/contrib/search_api_views/search_api_views.module @@ -0,0 +1,52 @@ + '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'); + } +} diff --git a/contrib/search_api_views/search_api_views.views.inc b/contrib/search_api_views/search_api_views.views.inc new file mode 100644 index 00000000..3bb0fc01 --- /dev/null +++ b/contrib/search_api_views/search_api_views.views.inc @@ -0,0 +1,196 @@ +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; +} diff --git a/disabled.png b/disabled.png new file mode 100644 index 00000000..22477650 Binary files /dev/null and b/disabled.png differ diff --git a/enabled.png b/enabled.png new file mode 100644 index 00000000..95f8730e Binary files /dev/null and b/enabled.png differ diff --git a/includes/callback.inc b/includes/callback.inc new file mode 100644 index 00000000..ae4dbf08 --- /dev/null +++ b/includes/callback.inc @@ -0,0 +1,220 @@ +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(); + } + +} diff --git a/includes/callback_add_aggregation.inc b/includes/callback_add_aggregation.inc new file mode 100644 index 00000000..15246863 --- /dev/null +++ b/includes/callback_add_aggregation.inc @@ -0,0 +1,313 @@ +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('

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.

' . + '

To add a new aggregated field, click the "Add new field" button and then fill out the form.

' . + '

To remove a previously defined field, click the "Remove field" button.

' . + '

You can also change the names or contained fields of existing aggregated fields.

'), + ); + $form['fields']['#prefix'] = '
'; + $form['fields']['#suffix'] = '
'; + if (isset($this->changes)) { + $form['fields']['#prefix'] .= '
All changes in the form will not be saved until the Save configuration button at the form bottom is clicked.
'; + } + 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']; +} diff --git a/includes/callback_add_hierarchy.inc b/includes/callback_add_hierarchy.inc new file mode 100644 index 00000000..4c0a4411 --- /dev/null +++ b/includes/callback_add_hierarchy.inc @@ -0,0 +1,243 @@ +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); + } + } + } + +} diff --git a/includes/callback_add_url.inc b/includes/callback_add_url.inc new file mode 100644 index 00000000..097fd41e --- /dev/null +++ b/includes/callback_add_url.inc @@ -0,0 +1,29 @@ + &$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', + ), + ); + } + +} diff --git a/includes/callback_add_viewed_entity.inc b/includes/callback_add_viewed_entity.inc new file mode 100644 index 00000000..bd0f8d82 --- /dev/null +++ b/includes/callback_add_viewed_entity.inc @@ -0,0 +1,98 @@ +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' => '

' . t('Entities of type %type have only a single view mode. ' . + 'Therefore, no selection needs to be made.', array('%type' => $info['label'])) . '

', + ); + } + else { + $form['note'] = array( + '#markup' => '

' . 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'])) . '

', + ); + } + } + 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', + ), + ); + } + +} diff --git a/includes/callback_bundle_filter.inc b/includes/callback_bundle_filter.inc new file mode 100644 index 00000000..4c758c3c --- /dev/null +++ b/includes/callback_bundle_filter.inc @@ -0,0 +1,72 @@ +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' => '

' . t("Items indexed by this index don't have bundles and therefore cannot be filtered here.") . '

', + ), + ); + } + 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']); + } + +} diff --git a/includes/callback_language_control.inc b/includes/callback_language_control.inc new file mode 100644 index 00000000..0ac481f7 --- /dev/null +++ b/includes/callback_language_control.inc @@ -0,0 +1,155 @@ + '', + '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]); + } + } + } + } + +} diff --git a/includes/callback_node_access.inc b/includes/callback_node_access.inc new file mode 100644 index 00000000..ce671d79 --- /dev/null +++ b/includes/callback_node_access.inc @@ -0,0 +1,109 @@ +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', + ), + ); + } + + /** + * 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); + } + +} diff --git a/includes/callback_node_status.inc b/includes/callback_node_status.inc new file mode 100644 index 00000000..bfe35e2d --- /dev/null +++ b/includes/callback_node_status.inc @@ -0,0 +1,45 @@ +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]); + } + } + } + +} diff --git a/includes/datasource.inc b/includes/datasource.inc new file mode 100755 index 00000000..21a6157e --- /dev/null +++ b/includes/datasource.inc @@ -0,0 +1,698 @@ +") 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); + } + } + +} diff --git a/includes/datasource_entity.inc b/includes/datasource_entity.inc new file mode 100644 index 00000000..641a9396 --- /dev/null +++ b/includes/datasource_entity.inc @@ -0,0 +1,206 @@ +") 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)); + } + +} diff --git a/includes/datasource_external.inc b/includes/datasource_external.inc new file mode 100644 index 00000000..3dbb17cb --- /dev/null +++ b/includes/datasource_external.inc @@ -0,0 +1,268 @@ +") 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, + ); + } + +} diff --git a/includes/exception.inc b/includes/exception.inc new file mode 100644 index 00000000..4e7a0c83 --- /dev/null +++ b/includes/exception.inc @@ -0,0 +1,29 @@ +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(); + } + +} diff --git a/includes/processor.inc b/includes/processor.inc new file mode 100644 index 00000000..4cfaa4c1 --- /dev/null +++ b/includes/processor.inc @@ -0,0 +1,418 @@ +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) { + + } + +} diff --git a/includes/processor_html_filter.inc b/includes/processor_html_filter.inc new file mode 100644 index 00000000..f636e2f2 --- /dev/null +++ b/includes/processor_html_filter.inc @@ -0,0 +1,137 @@ +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 INI file format. ' . + '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 <@tag> can't be an array.", array('@tag' => $key)); + } + elseif (!is_numeric($value)) { + $errors[] = t("Boost value for tag <@tag> must be numeric.", array('@tag' => $key)); + } + elseif ($value < 0) { + $errors[] = t('Boost value for tag <@tag> must be non-negative.', array('@tag' => $key)); + } + } + if ($errors) { + form_error($form['tags'], implode("
\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('/]+\balt\s*=\s*("([^"]+)"|\'([^\']+)\')[^>]*>/i', ' $2$3 ', $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; + } + +} diff --git a/includes/processor_ignore_case.inc b/includes/processor_ignore_case.inc new file mode 100644 index 00000000..fd5d7e9b --- /dev/null +++ b/includes/processor_ignore_case.inc @@ -0,0 +1,12 @@ + array( + '#markup' => '

' . 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"))) . '

', + ), + '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 public://stopwords/stopwords.txt or http://example.com/stopwords.txt or private://stopwords.txt.'), + ), + '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; + } +} \ No newline at end of file diff --git a/includes/processor_tokenizer.inc b/includes/processor_tokenizer.inc new file mode 100644 index 00000000..ba04d1de --- /dev/null +++ b/includes/processor_tokenizer.inc @@ -0,0 +1,95 @@ + 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 PCRE character class. ' . + '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']); + } + } + +} diff --git a/includes/query.inc b/includes/query.inc new file mode 100644 index 00000000..bba27fc0 --- /dev/null +++ b/includes/query.inc @@ -0,0 +1,1066 @@ +", "<", "<=", ">=", ">". 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; + } + +} diff --git a/includes/server_entity.inc b/includes/server_entity.inc new file mode 100644 index 00000000..0436171f --- /dev/null +++ b/includes/server_entity.inc @@ -0,0 +1,228 @@ + 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); + } + +} diff --git a/includes/service.inc b/includes/service.inc new file mode 100644 index 00000000..29fc637c --- /dev/null +++ b/includes/service.inc @@ -0,0 +1,469 @@ + + * 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
+ * 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 .= '
' . check_plain($name) . '
' . "\n"; + $output .= '
' . nl2br(check_plain(print_r($value, TRUE))) . '
' . "\n"; + } + + return $output ? "
\n$output
" : ''; + } + + /** + * 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); + } + +} diff --git a/search_api.admin.css b/search_api.admin.css new file mode 100644 index 00000000..0c49e057 --- /dev/null +++ b/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; +} + diff --git a/search_api.admin.inc b/search_api.admin.inc new file mode 100644 index 00000000..5fbc8d8a --- /dev/null +++ b/search_api.admin.inc @@ -0,0 +1,1970 @@ +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) . + ''; + $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) . + ''; + $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'] = '
'; + $form['options']['#suffix'] = '
'; + + $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 .= '

' . check_plain($name) . '

' . "\n"; + + $output .= '
' . "\n"; + + $output .= '
' . t('Status') . '
' . "\n"; + $output .= '
'; + 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 .= '
' . "\n"; + + $output .= '
' . t('Machine name') . '
' . "\n"; + $output .= '
' . check_plain($machine_name) . '
' . "\n"; + + if (!empty($description)) { + $output .= '
' . t('Description') . '
' . "\n"; + $output .= '
' . nl2br(check_plain($description)) . '
' . "\n"; + } + + if (!empty($class_name)) { + $output .= '
' . t('Service class') . '
' . "\n"; + $output .= '
' . check_plain($class_name) . ''; + if (!empty($class_description)) { + $output .= '

' . $class_description . '

'; + } + $output .= '
' . "\n"; + } + + if (!empty($options)) { + $output .= '
' . t('Service options') . '
' . "\n"; + $output .= '
' . "\n"; + $output .= render($options); + $output .= '
' . "\n"; + } + + $output .= '
' . t('Configuration status') . '
' . "\n"; + $output .= '
' . "\n"; + $output .= theme('entity_status', array('status' => $status)); + $output .= '
' . "\n"; + + $output .= '
'; + + 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 .= '

' . check_plain($name) . '

' . "\n"; + + $output .= '
' . "\n"; + + $output .= '
' . t('Status') . '
' . "\n"; + $output .= '
'; + 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 .= '
' . "\n"; + + $output .= '
' . t('Machine name') . '
' . "\n"; + $output .= '
' . check_plain($machine_name) . '
' . "\n"; + + $output .= '
' . t('Item type') . '
' . "\n"; + $type = search_api_get_item_type_info($item_type); + $type = $type['name']; + $output .= '
' . check_plain($type) . '
' . "\n"; + + if (!empty($description)) { + $output .= '
' . t('Description') . '
' . "\n"; + $output .= '
' . nl2br(check_plain($description)) . '
' . "\n"; + } + + if (!empty($server)) { + $output .= '
' . t('Server') . '
' . "\n"; + $output .= '
' . l($server->name, 'admin/config/search/search_api/server/' . $server->machine_name); + if (!empty($server->description)) { + $output .= '

' . nl2br(check_plain($server->description)) . '

'; + } + $output .= '
' . "\n"; + } + + if (!$read_only && !empty($options)) { + $output .= '
' . t('Index options') . '
' . "\n"; + $output .= '
' . "\n"; + $output .= '
' . t('Cron batch size') . '
' . "\n"; + if (empty($options['cron_limit'])) { + $output .= '
' . t("Don't index during cron runs") . '
' . "\n"; + } + elseif ($options['cron_limit'] < 0) { + $output .= '
' . t('Unlimited') . '
' . "\n"; + } + else { + $output .= '
' . format_plural($options['cron_limit'], '1 item per cron batch.', '@count items per cron batch.') . '
' . "\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 .= '
' . t('Indexed fields') . '
' . "\n"; + $output .= '
' . implode(', ', $fields_list) . '
' . "\n"; + } + } + + $output .= '
' . "\n"; + } + elseif ($read_only) { + $output .= '
' . t('Read only') . '
' . "\n"; + $output .= '
' . t('This index is read-only.') . '
' . "\n"; + } + + $output .= '
' . t('Configuration status') . '
' . "\n"; + $output .= '
' . "\n"; + $output .= theme('entity_status', array('status' => $status)); + $output .= '
' . "\n"; + + $output .= '
'; + + 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.
' . + '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' => '
', + '#suffix' => '
', + ); + 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' => '
', + '#suffix' => '
', + ); + 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('

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.

' . + '

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.

'), + ); + if ($index->server) { + $form['description']['#description'] .= '

' . t('Check the ' . "server's service class description for details.", + array('@server-url' => url('admin/config/search/search_api/server/' . $index->server))) . '

'; + } + 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'] .= '

' . + 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)) . '

'; + $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' => ' ', + ); + } + 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.') . '
', + '#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 = "

{$text[1]}

{$text[2]}

"; + 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"; +} diff --git a/search_api.admin.js b/search_api.admin.js new file mode 100644 index 00000000..d841ec16 --- /dev/null +++ b/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); diff --git a/search_api.api.php b/search_api.api.php new file mode 100644 index 00000000..f7506f3a --- /dev/null +++ b/search_api.api.php @@ -0,0 +1,535 @@ + 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; +} diff --git a/search_api.drush.inc b/search_api.drush.inc new file mode 100644 index 00000000..66525543 --- /dev/null +++ b/search_api.drush.inc @@ -0,0 +1,311 @@ + '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; +} diff --git a/search_api.info b/search_api.info new file mode 100644 index 00000000..2f7b357b --- /dev/null +++ b/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" + diff --git a/search_api.install b/search_api.install new file mode 100644 index 00000000..8fbb0e4a --- /dev/null +++ b/search_api.install @@ -0,0 +1,808 @@ + '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()))); + } + } +} diff --git a/search_api.module b/search_api.module new file mode 100644 index 00000000..bba06819 --- /dev/null +++ b/search_api.module @@ -0,0 +1,2352 @@ + '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.
' . + '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 $t = search_api_nest_type($type, $nested_type); 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" (or for "list>"). 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'); + } + +} diff --git a/search_api.rules.inc b/search_api.rules.inc new file mode 100644 index 00000000..575d28c9 --- /dev/null +++ b/search_api.rules.inc @@ -0,0 +1,90 @@ + 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.'); +} diff --git a/search_api.test b/search_api.test new file mode 100644 index 00000000..e65cc7a7 --- /dev/null +++ b/search_api.test @@ -0,0 +1,700 @@ +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 = <<a test. +How to write links to other sites: <a href="URL" title="MOUSEOVER TEXT">TEXT</a>. +< signs can be escaped with "&lt;". +someone's image +END; + $tags = << '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' => ' 1), + array('value' => 'href="URL"', 'score' => 1), + array('value' => 'title="MOUSEOVER', 'score' => 1), + array('value' => 'TEXT">TEXT', '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' => '"<"', '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.')); + } + +} diff --git a/tests/search_api_test.info b/tests/search_api_test.info new file mode 100644 index 00000000..f6d07064 --- /dev/null +++ b/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" + diff --git a/tests/search_api_test.install b/tests/search_api_test.install new file mode 100644 index 00000000..f06f37d4 --- /dev/null +++ b/tests/search_api_test.install @@ -0,0 +1,43 @@ + '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; +} diff --git a/tests/search_api_test.module b/tests/search_api_test.module new file mode 100644 index 00000000..998598ef --- /dev/null +++ b/tests/search_api_test.module @@ -0,0 +1,316 @@ + 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'] . '
'; + $ret .= 'results = (' . (empty($result['results']) ? '' : implode(', ', array_keys($result['results']))) . ')
'; + $ret .= 'warnings = (' . (empty($result['warnings']) ? '' : '"' . implode('", "', $result['warnings']) . '"') . ')
'; + $ret .= 'ignored = (' . (empty($result['ignored']) ? '' : implode(', ', $result['ignored'])) . ')
'; + $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; + } + +}