diff --git a/config/staging/layout.layout.search_layouts2.json b/config/staging/layout.layout.search_layouts2.json
new file mode 100644
index 000000000..787ac557c
--- /dev/null
+++ b/config/staging/layout.layout.search_layouts2.json
@@ -0,0 +1,220 @@
+{
+ "_config_name": "layout.layout.search_layouts2",
+ "path": "layouts2",
+ "name": "search_layouts2",
+ "title": "Search layouts2",
+ "description": null,
+ "renderer_name": "standard",
+ "module": null,
+ "weight": -4,
+ "storage": 1,
+ "layout_template": "boxton",
+ "disabled": false,
+ "settings": {
+ "title": "",
+ "title_display": "default",
+ "title_block": null
+ },
+ "positions": {
+ "header": [
+ "f6c5b732-e4d2-42ad-b332-5096a247a7d4"
+ ],
+ "top": [
+ "f6099747-9f00-43a7-9b05-5bfd4b583349",
+ "6ac03569-d9b9-47e5-bc42-66c68f5686bd",
+ "1beafdfa-3324-493e-9300-e449e0c82f00"
+ ],
+ "content": [
+ "6f63e2ed-228e-4aa3-9803-3e6e4cf719d9",
+ "2458ddea-7dd0-45a3-ae3c-60277385a855"
+ ],
+ "footer": [
+ "b02b064f-29aa-4395-b307-7f2f831c56ce"
+ ]
+ },
+ "contexts": [],
+ "relationships": [],
+ "content": {
+ "f6c5b732-e4d2-42ad-b332-5096a247a7d4": {
+ "plugin": "copy_blocks:region_copy",
+ "data": {
+ "status": 1,
+ "module": "copy_blocks",
+ "delta": "region_copy",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": "",
+ "source_region": "default:header"
+ },
+ "uuid": "f6c5b732-e4d2-42ad-b332-5096a247a7d4",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "f6099747-9f00-43a7-9b05-5bfd4b583349": {
+ "plugin": "system:page_components:title",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "page_components",
+ "settings": {
+ "title_display": "none",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "title_tag": "h1",
+ "title_classes": "page-title",
+ "tab_type": "both"
+ },
+ "uuid": "f6099747-9f00-43a7-9b05-5bfd4b583349",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "6ac03569-d9b9-47e5-bc42-66c68f5686bd": {
+ "plugin": "layout:custom_block",
+ "data": {
+ "status": 1,
+ "module": "layout",
+ "delta": "custom_block",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "content": "
\r\n Layout templates divide pages on your site into different regions where content can be placed.\r\n
",
+ "format": "filtered_html",
+ "admin_label": "",
+ "admin_description": ""
+ },
+ "uuid": "6ac03569-d9b9-47e5-bc42-66c68f5686bd",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "1beafdfa-3324-493e-9300-e449e0c82f00": {
+ "plugin": "views:-exp-modules-layouts",
+ "data": {
+ "status": 1,
+ "module": "views",
+ "delta": "-exp-modules-layouts",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "1beafdfa-3324-493e-9300-e449e0c82f00",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "6f63e2ed-228e-4aa3-9803-3e6e4cf719d9": {
+ "plugin": "system:main",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "main",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "6f63e2ed-228e-4aa3-9803-3e6e4cf719d9",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "2458ddea-7dd0-45a3-ae3c-60277385a855": {
+ "plugin": "borg_blocks:rss",
+ "data": {
+ "status": 1,
+ "module": "borg_blocks",
+ "delta": "rss",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "2458ddea-7dd0-45a3-ae3c-60277385a855",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "b02b064f-29aa-4395-b307-7f2f831c56ce": {
+ "plugin": "copy_blocks:region_copy",
+ "data": {
+ "status": 1,
+ "module": "copy_blocks",
+ "delta": "region_copy",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": "",
+ "source_region": "default:footer"
+ },
+ "uuid": "b02b064f-29aa-4395-b307-7f2f831c56ce",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/config/staging/layout.layout.search_modules2.json b/config/staging/layout.layout.search_modules2.json
new file mode 100644
index 000000000..f704a07eb
--- /dev/null
+++ b/config/staging/layout.layout.search_modules2.json
@@ -0,0 +1,282 @@
+{
+ "_config_name": "layout.layout.search_modules2",
+ "path": "modules2",
+ "name": "search_modules2",
+ "title": "Search Modules2",
+ "description": null,
+ "renderer_name": "standard",
+ "module": null,
+ "weight": -5,
+ "storage": 1,
+ "layout_template": "boxton",
+ "disabled": false,
+ "settings": {
+ "title": "",
+ "title_display": "default",
+ "title_block": null
+ },
+ "positions": {
+ "header": [
+ "a44f407e-003d-4ef0-8704-4ab1efb11b05",
+ "787e555d-f7da-4a45-882e-4603047550bc",
+ "6aeecb36-2e2c-410f-8509-9aa5a21a498f"
+ ],
+ "top": [
+ "30952f9d-fd30-43dd-ad3c-607d25a99cf7",
+ "94011233-d1ca-494f-8b05-5e2ee8aed0d5",
+ "d3a3ea1c-26c2-4db4-8900-dc80201fdd5e"
+ ],
+ "content": [
+ "7a4a7661-c0ec-4802-8100-c9b50d4dfee6",
+ "37f01eeb-376c-4c8e-9c00-e1aa0ad52063"
+ ],
+ "bottom": [],
+ "footer": [
+ "ccb8967d-a8ee-4c3e-b000-aa076a08836d"
+ ],
+ "title": []
+ },
+ "contexts": [],
+ "relationships": [],
+ "content": {
+ "a44f407e-003d-4ef0-8704-4ab1efb11b05": {
+ "plugin": "borg_blocks:branding",
+ "data": {
+ "status": 1,
+ "module": "borg_blocks",
+ "delta": "branding",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "a44f407e-003d-4ef0-8704-4ab1efb11b05",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "787e555d-f7da-4a45-882e-4603047550bc": {
+ "plugin": "system:user-menu",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "user-menu",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": {
+ "menu_name": "user-menu",
+ "style": "tree",
+ "level": "1",
+ "depth": "1",
+ "expand_all": 0,
+ "toggle": 0
+ },
+ "contexts": []
+ },
+ "uuid": "787e555d-f7da-4a45-882e-4603047550bc",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "6aeecb36-2e2c-410f-8509-9aa5a21a498f": {
+ "plugin": "system:main-menu",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "main-menu",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": {
+ "menu_name": "main-menu",
+ "style": "dropdown",
+ "level": "1",
+ "depth": "2",
+ "toggle": 1
+ },
+ "contexts": []
+ },
+ "uuid": "6aeecb36-2e2c-410f-8509-9aa5a21a498f",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "30952f9d-fd30-43dd-ad3c-607d25a99cf7": {
+ "plugin": "system:page_components:title",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "page_components",
+ "settings": {
+ "title_display": "none",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "title_tag": "h1",
+ "title_classes": "page-title",
+ "tab_type": "both"
+ },
+ "uuid": "30952f9d-fd30-43dd-ad3c-607d25a99cf7",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "94011233-d1ca-494f-8b05-5e2ee8aed0d5": {
+ "plugin": "layout:custom_block",
+ "data": {
+ "status": 1,
+ "module": "layout",
+ "delta": "custom_block",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "content": "Modules can add new features to your Backdrop site, or extend and improve existing functionality.
\r\n",
+ "format": "filtered_html"
+ },
+ "uuid": "94011233-d1ca-494f-8b05-5e2ee8aed0d5",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "d3a3ea1c-26c2-4db4-8900-dc80201fdd5e": {
+ "plugin": "views:-exp-project_listings-modules",
+ "data": {
+ "status": 1,
+ "module": "views",
+ "delta": "-exp-project_listings-modules",
+ "settings": {
+ "title_display": "none",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": ""
+ },
+ "uuid": "d3a3ea1c-26c2-4db4-8900-dc80201fdd5e",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "7a4a7661-c0ec-4802-8100-c9b50d4dfee6": {
+ "plugin": "system:main",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "main",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "7a4a7661-c0ec-4802-8100-c9b50d4dfee6",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "37f01eeb-376c-4c8e-9c00-e1aa0ad52063": {
+ "plugin": "borg_blocks:rss",
+ "data": {
+ "status": 1,
+ "module": "borg_blocks",
+ "delta": "rss",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "37f01eeb-376c-4c8e-9c00-e1aa0ad52063",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "ccb8967d-a8ee-4c3e-b000-aa076a08836d": {
+ "plugin": "copy_blocks:region_copy",
+ "data": {
+ "status": 1,
+ "module": "copy_blocks",
+ "delta": "region_copy",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": "",
+ "source_region": "default:footer"
+ },
+ "uuid": "ccb8967d-a8ee-4c3e-b000-aa076a08836d",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/config/staging/layout.layout.search_themes2.json b/config/staging/layout.layout.search_themes2.json
new file mode 100644
index 000000000..f960a8223
--- /dev/null
+++ b/config/staging/layout.layout.search_themes2.json
@@ -0,0 +1,222 @@
+{
+ "_config_name": "layout.layout.search_themes2",
+ "path": "themes2",
+ "name": "search_themes2",
+ "title": "Search Themes2",
+ "description": null,
+ "renderer_name": "standard",
+ "module": null,
+ "weight": -2,
+ "storage": 1,
+ "layout_template": "boxton",
+ "disabled": false,
+ "settings": {
+ "title": "",
+ "title_display": "default",
+ "title_block": null
+ },
+ "positions": {
+ "header": [
+ "46901d2a-a22f-4a71-9d0e-145c11ca8c91"
+ ],
+ "top": [
+ "7935cc9b-980c-4d20-8661-97b606fc424f",
+ "660e6487-599c-496c-ac3d-61a32cfaa79b",
+ "2fd7c1ed-48e1-4e81-9200-b8af9c30eb8d"
+ ],
+ "content": [
+ "3cd4d84b-5e33-45a1-bb10-1640c2dc5b79",
+ "5839359f-2740-47a2-8e00-abb8217f13fd"
+ ],
+ "bottom": [],
+ "footer": [
+ "39c1a59b-5a80-4b22-9d0e-14b0c3821559"
+ ],
+ "title": []
+ },
+ "contexts": [],
+ "relationships": [],
+ "content": {
+ "46901d2a-a22f-4a71-9d0e-145c11ca8c91": {
+ "plugin": "copy_blocks:region_copy",
+ "data": {
+ "status": 1,
+ "module": "copy_blocks",
+ "delta": "region_copy",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": "",
+ "source_region": "default:header"
+ },
+ "uuid": "46901d2a-a22f-4a71-9d0e-145c11ca8c91",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "7935cc9b-980c-4d20-8661-97b606fc424f": {
+ "plugin": "system:page_components:title",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "page_components",
+ "settings": {
+ "title_display": "none",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "title_tag": "h1",
+ "title_classes": "page-title",
+ "tab_type": "both"
+ },
+ "uuid": "7935cc9b-980c-4d20-8661-97b606fc424f",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "660e6487-599c-496c-ac3d-61a32cfaa79b": {
+ "plugin": "layout:custom_block",
+ "data": {
+ "status": 1,
+ "module": "layout",
+ "delta": "custom_block",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "content": "Themes are skins for your site that allow you to change the look, feel, and general appearance.
\r\n",
+ "format": "filtered_html"
+ },
+ "uuid": "660e6487-599c-496c-ac3d-61a32cfaa79b",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "2fd7c1ed-48e1-4e81-9200-b8af9c30eb8d": {
+ "plugin": "views:-exp-project_listings-themes",
+ "data": {
+ "status": 1,
+ "module": "views",
+ "delta": "-exp-project_listings-themes",
+ "settings": {
+ "title_display": "none",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": ""
+ },
+ "uuid": "2fd7c1ed-48e1-4e81-9200-b8af9c30eb8d",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "3cd4d84b-5e33-45a1-bb10-1640c2dc5b79": {
+ "plugin": "system:main",
+ "data": {
+ "status": 1,
+ "module": "system",
+ "delta": "main",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "3cd4d84b-5e33-45a1-bb10-1640c2dc5b79",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "5839359f-2740-47a2-8e00-abb8217f13fd": {
+ "plugin": "borg_blocks:rss",
+ "data": {
+ "status": 1,
+ "module": "borg_blocks",
+ "delta": "rss",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": []
+ },
+ "uuid": "5839359f-2740-47a2-8e00-abb8217f13fd",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ },
+ "39c1a59b-5a80-4b22-9d0e-14b0c3821559": {
+ "plugin": "copy_blocks:region_copy",
+ "data": {
+ "status": 1,
+ "module": "copy_blocks",
+ "delta": "region_copy",
+ "settings": {
+ "title_display": "default",
+ "title": "",
+ "style": "default",
+ "block_settings": [],
+ "contexts": [],
+ "admin_label": "",
+ "admin_description": "",
+ "source_region": "default:footer"
+ },
+ "uuid": "39c1a59b-5a80-4b22-9d0e-14b0c3821559",
+ "style": {
+ "plugin": "default",
+ "data": {
+ "settings": {
+ "classes": ""
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/config/staging/layout.menu_item.search_layouts2.json b/config/staging/layout.menu_item.search_layouts2.json
new file mode 100644
index 000000000..36ffc8bbd
--- /dev/null
+++ b/config/staging/layout.menu_item.search_layouts2.json
@@ -0,0 +1,20 @@
+{
+ "_config_name": "layout.menu_item.search_layouts2",
+ "name": "search_layouts2",
+ "title": "",
+ "path": "layouts2",
+ "menu": {
+ "title": null,
+ "weight": 0,
+ "type": "none",
+ "name": "main-menu",
+ "parent": {
+ "title": null,
+ "weight": 0,
+ "type": "none",
+ "name": "main-menu"
+ }
+ },
+ "conditions": [],
+ "arguments": []
+}
diff --git a/config/staging/search_api.settings.json b/config/staging/search_api.settings.json
new file mode 100644
index 000000000..65a5dc1a2
--- /dev/null
+++ b/config/staging/search_api.settings.json
@@ -0,0 +1,4 @@
+{
+ "_config_name": "search_api.settings",
+ "search_api_index_worker_callback_runtime": "15"
+}
diff --git a/config/staging/search_api_db.settings.json b/config/staging/search_api_db.settings.json
new file mode 100644
index 000000000..9c9f4e600
--- /dev/null
+++ b/config/staging/search_api_db.settings.json
@@ -0,0 +1,4 @@
+{
+ "_config_name": "search_api_db.settings",
+ "autocomplete_max_occurrences": 0.9
+}
diff --git a/config/staging/system.extensions.json b/config/staging/system.extensions.json
index e6352ccc6..8c7efa86f 100644
--- a/config/staging/system.extensions.json
+++ b/config/staging/system.extensions.json
@@ -42,6 +42,7 @@
"dblog": true,
"entity": true,
"entityreference": true,
+ "entity_plus": true,
"field": true,
"field_group": true,
"field_sql_storage": true,
@@ -86,6 +87,9 @@
"restrict_abusive_words": true,
"rp_api": true,
"search": true,
+ "search_api": true,
+ "search_api_db": true,
+ "search_api_views": true,
"search_config": false,
"seckit": true,
"smtp": true,
diff --git a/config/staging/views.view.project_listings.json b/config/staging/views.view.project_listings.json
new file mode 100644
index 000000000..f135f1fcf
--- /dev/null
+++ b/config/staging/views.view.project_listings.json
@@ -0,0 +1,489 @@
+{
+ "_config_name": "views.view.project_listings",
+ "name": "project_listings",
+ "description": "",
+ "tag": "",
+ "disabled": false,
+ "base_table": "search_api_index_projects",
+ "human_name": "Project listings",
+ "core": "1.32.1",
+ "display": {
+ "default": {
+ "display_title": "Default",
+ "display_plugin": "default",
+ "display_options": {
+ "query": {
+ "type": "views_query",
+ "options": []
+ },
+ "access": {
+ "type": "perm",
+ "perm": "access content"
+ },
+ "cache": {
+ "type": "none"
+ },
+ "exposed_form": {
+ "type": "basic"
+ },
+ "pager": {
+ "type": "full",
+ "options": {
+ "items_per_page": "25"
+ }
+ },
+ "style_plugin": "list",
+ "row_plugin": "fields",
+ "fields": {
+ "rendered_entity": {
+ "id": "rendered_entity",
+ "table": "views_entity_node",
+ "field": "rendered_entity",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "label": "",
+ "exclude": 0,
+ "alter": {
+ "alter_text": 0,
+ "text": "",
+ "make_link": 0,
+ "path": "",
+ "absolute": 0,
+ "external": 0,
+ "replace_spaces": 0,
+ "path_case": "none",
+ "trim_whitespace": 0,
+ "alt": "",
+ "rel": "",
+ "link_class": "",
+ "prefix": "",
+ "suffix": "",
+ "target": "",
+ "nl2br": 0,
+ "max_length": "",
+ "word_boundary": 1,
+ "ellipsis": 1,
+ "more_link": 0,
+ "more_link_text": "",
+ "more_link_path": "",
+ "strip_tags": 0,
+ "trim": 0,
+ "preserve_tags": "",
+ "html": 0
+ },
+ "element_type": "",
+ "element_class": "",
+ "element_label_type": "",
+ "element_label_class": "",
+ "element_label_colon": false,
+ "element_wrapper_type": "",
+ "element_wrapper_class": "",
+ "element_default_classes": 1,
+ "empty": "",
+ "hide_empty": 0,
+ "empty_zero": 0,
+ "hide_alter_empty": 1,
+ "link_to_entity": 0,
+ "display": "view",
+ "view_mode": "project_search",
+ "bypass_access": 0
+ }
+ },
+ "filters": {
+ "type": {
+ "id": "type",
+ "table": "search_api_index_projects",
+ "field": "type",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "=",
+ "value": {
+ "project_module": "project_module"
+ },
+ "group": "1",
+ "exposed": false,
+ "expose": {
+ "operator_id": false,
+ "label": "",
+ "description": "",
+ "use_operator": false,
+ "operator": "",
+ "identifier": "",
+ "required": false,
+ "remember": false,
+ "multiple": false,
+ "remember_roles": {
+ "authenticated": "authenticated"
+ },
+ "reduce": false
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ }
+ },
+ "search_api_views_fulltext": {
+ "id": "search_api_views_fulltext",
+ "table": "search_api_index_projects",
+ "field": "search_api_views_fulltext",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "AND",
+ "value": "",
+ "group": "1",
+ "exposed": true,
+ "expose": {
+ "operator_id": "search_api_views_fulltext_op",
+ "label": "",
+ "description": "",
+ "use_operator": 0,
+ "operator": "search_api_views_fulltext_op",
+ "identifier": "search",
+ "required": 0,
+ "remember": 0,
+ "multiple": 0,
+ "remember_roles": {
+ "authenticated": "authenticated",
+ "anonymous": 0,
+ "4": 0,
+ "civi": 0,
+ "3": 0
+ }
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ },
+ "mode": "keys",
+ "min_length": "3",
+ "fields": {
+ "body:value": "body:value",
+ "field_project_maintainers_github": "field_project_maintainers_github",
+ "title": "title"
+ }
+ }
+ },
+ "sorts": {
+ "search_api_relevance": {
+ "id": "search_api_relevance",
+ "table": "search_api_index_projects",
+ "field": "search_api_relevance",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "order": "DESC",
+ "exposed": false,
+ "expose": {
+ "label": ""
+ }
+ },
+ "created": {
+ "id": "created",
+ "table": "search_api_index_projects",
+ "field": "created",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "order": "DESC",
+ "exposed": false,
+ "expose": {
+ "label": ""
+ }
+ },
+ "field_download_count": {
+ "id": "field_download_count",
+ "table": "search_api_index_projects",
+ "field": "field_download_count",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "order": "DESC",
+ "exposed": false,
+ "expose": {
+ "label": ""
+ }
+ }
+ },
+ "title": "Modules for Backdrop CMS",
+ "style_options": {
+ "grouping": [],
+ "row_class": "result",
+ "default_row_class": 0,
+ "row_class_special": 1,
+ "type": "ol",
+ "wrapper_class": "project-search__results-wrapper l-content-wrapper",
+ "class": "project-search__results"
+ },
+ "relationships": []
+ }
+ },
+ "modules": {
+ "display_title": "Modules",
+ "display_plugin": "page",
+ "display_options": {
+ "query": {
+ "type": "views_query",
+ "options": []
+ },
+ "path": "modules2",
+ "display_description": "",
+ "exposed_block": true
+ }
+ },
+ "themes": {
+ "display_title": "Themes",
+ "display_plugin": "page",
+ "display_options": {
+ "query": {
+ "type": "views_query",
+ "options": []
+ },
+ "path": "themes2",
+ "display_description": "",
+ "exposed_block": true,
+ "filters": {
+ "type": {
+ "id": "type",
+ "table": "search_api_index_projects",
+ "field": "type",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "=",
+ "value": {
+ "project_theme": "project_theme"
+ },
+ "group": "1",
+ "exposed": false,
+ "expose": {
+ "operator_id": false,
+ "label": "",
+ "description": "",
+ "use_operator": false,
+ "operator": "",
+ "identifier": "",
+ "required": false,
+ "remember": false,
+ "multiple": false,
+ "remember_roles": {
+ "authenticated": "authenticated"
+ },
+ "reduce": false
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ }
+ },
+ "search_api_views_fulltext": {
+ "id": "search_api_views_fulltext",
+ "table": "search_api_index_projects",
+ "field": "search_api_views_fulltext",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "AND",
+ "value": "",
+ "group": "1",
+ "exposed": true,
+ "expose": {
+ "operator_id": "search_api_views_fulltext_op",
+ "label": "",
+ "description": "",
+ "use_operator": 0,
+ "operator": "search_api_views_fulltext_op",
+ "identifier": "search",
+ "required": 0,
+ "remember": 0,
+ "multiple": 0,
+ "remember_roles": {
+ "authenticated": "authenticated",
+ "anonymous": 0,
+ "4": 0,
+ "civi": 0,
+ "3": 0
+ }
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ },
+ "mode": "keys",
+ "min_length": "3",
+ "fields": {
+ "body:value": "body:value",
+ "field_project_maintainers_github": "field_project_maintainers_github",
+ "title": "title"
+ }
+ }
+ },
+ "defaults": {
+ "filters": false,
+ "filter_groups": false
+ },
+ "filter_groups": {
+ "operator": "AND",
+ "groups": {
+ "1": "AND"
+ }
+ }
+ }
+ },
+ "layouts": {
+ "display_title": "Layouts",
+ "display_plugin": "page",
+ "display_options": {
+ "query": {
+ "type": "views_query",
+ "options": []
+ },
+ "path": "layouts2",
+ "display_description": "",
+ "exposed_block": true,
+ "filters": {
+ "type": {
+ "id": "type",
+ "table": "search_api_index_projects",
+ "field": "type",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "=",
+ "value": {
+ "project_layout": "project_layout"
+ },
+ "group": "1",
+ "exposed": false,
+ "expose": {
+ "operator_id": false,
+ "label": "",
+ "description": "",
+ "use_operator": false,
+ "operator": "",
+ "identifier": "",
+ "required": false,
+ "remember": false,
+ "multiple": false,
+ "remember_roles": {
+ "authenticated": "authenticated"
+ },
+ "reduce": false
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ }
+ },
+ "search_api_views_fulltext": {
+ "id": "search_api_views_fulltext",
+ "table": "search_api_index_projects",
+ "field": "search_api_views_fulltext",
+ "relationship": "none",
+ "group_type": "group",
+ "ui_name": "",
+ "operator": "AND",
+ "value": "",
+ "group": "1",
+ "exposed": true,
+ "expose": {
+ "operator_id": "search_api_views_fulltext_op",
+ "label": "",
+ "description": "",
+ "use_operator": 0,
+ "operator": "search_api_views_fulltext_op",
+ "identifier": "search",
+ "required": 0,
+ "remember": 0,
+ "multiple": 0,
+ "remember_roles": {
+ "authenticated": "authenticated",
+ "anonymous": 0,
+ "4": 0,
+ "civi": 0,
+ "3": 0
+ }
+ },
+ "is_grouped": false,
+ "group_info": {
+ "label": "",
+ "description": "",
+ "identifier": "",
+ "optional": true,
+ "widget": "select",
+ "multiple": false,
+ "remember": 0,
+ "default_group": "All",
+ "default_group_multiple": [],
+ "group_items": []
+ },
+ "mode": "keys",
+ "min_length": "3",
+ "fields": {
+ "body:value": "body:value",
+ "field_project_maintainers_github": "field_project_maintainers_github",
+ "title": "title"
+ }
+ }
+ },
+ "defaults": {
+ "filters": false,
+ "filter_groups": false
+ },
+ "filter_groups": {
+ "operator": "AND",
+ "groups": {
+ "1": "AND"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/www/modules/contrib/entity_plus/LICENSE.txt b/www/modules/contrib/entity_plus/LICENSE.txt
new file mode 100644
index 000000000..d159169d1
--- /dev/null
+++ b/www/modules/contrib/entity_plus/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/www/modules/contrib/entity_plus/README.md b/www/modules/contrib/entity_plus/README.md
new file mode 100644
index 000000000..9c2200d0d
--- /dev/null
+++ b/www/modules/contrib/entity_plus/README.md
@@ -0,0 +1,41 @@
+Entity Plus
+=================
+
+This module wraps in a variety of entity-related functionality from various
+sources, including:
+
+ - The `Entity Metadata Wrapper` module from Drupal 7.
+ - Various bits from the `Entity API` module from Drupal 7 which have not (yet)
+ been merged into core. Note that this module renames several functions from
+ the `entity_xxx()` format to `entity_plus_xxx()` format to prevent conflict
+ in case some of these functions are eventually merged into core.
+
+This is an API module. You only need to enable it if a module depends on it or
+you are interested in using it for development.
+
+Installation and Usage:
+---------------
+- Install this module using the [official Backdrop CMS instructions](https://backdropcms.org/guide/modules)
+- Usage instructions can be [viewed and edited in the Wiki](https://github.com/backdrop-contrib/entity_plus/wiki).
+- The [Basic Entity Plus Example](https://github.com/backdrop-contrib/basic_entity_plus_example)
+ module provides an example of a custom entity using Entity Plus and Entity UI.
+
+Current Maintainers
+---------------
+
+- [Laryn Kragt Bakker](https://github.com/laryn)
+- [Joseph Flatt](https://github.com/hosef)
+- [Alejandro Cremaschi](https://github.com/argiepiano)
+- Seeking co-maintainers
+
+Credits
+---------------
+
+- Ported to Backdrop by [docwilmot](https://github.com/docwilmot)
+- Original Drupal version by [Wolfgang Ziegler](https://www.drupal.org/user/16747)
+
+License
+---------------
+
+This project is GPL v2 software. See the LICENSE.txt file in this directory
+for complete text.
diff --git a/www/modules/contrib/entity_plus/composer.json b/www/modules/contrib/entity_plus/composer.json
new file mode 100644
index 000000000..434716538
--- /dev/null
+++ b/www/modules/contrib/entity_plus/composer.json
@@ -0,0 +1,5 @@
+{
+ "require-dev": {
+ "backdrop/coder": "^1.0"
+ }
+}
diff --git a/www/modules/contrib/entity_plus/entity_plus.api.php b/www/modules/contrib/entity_plus/entity_plus.api.php
new file mode 100644
index 000000000..058dbf8cf
--- /dev/null
+++ b/www/modules/contrib/entity_plus/entity_plus.api.php
@@ -0,0 +1,641 @@
+ 'type'.
+ * If a name key is given, the name is used as entity identifier by the
+ * Entity Plus module, metadata wrappers and entity-type specific hooks.
+ * However note that for consistency all generic entity hooks like
+ * hook_entity_load() are invoked with the entities keyed by numeric id,
+ * while entity-type specific hooks like hook_{entity_type}_load() are
+ * invoked with the entities keyed by name.
+ * Also entity_load() or entity_load_multiple() may be called
+ * with names passed as the $ids parameter, while the results of
+ * entity_load() are always keyed by numeric id. Thus, it is suggested to
+ * make use of entity_load_multiple_by_name() to implement entity-type
+ * specific loading functions like {entity_type}_load_multiple(), as this
+ * function returns the entities keyed by name.
+ * For exportable entities, it is strongly recommended to make use of a
+ * machine name as names are portable across systems.
+ * This option requires the EntityAPIControllerExportable to work.
+ * - status (optional): The name of the entity property used by the entity
+ * CRUD API to save the exportable entity status using defined bit flags.
+ * Defaults to 'status'. See entity_plus_has_status().
+ * - language (optional): The name of the property, typically 'language', that contains
+ * the language code representing the language the entity has been created
+ * in. This value may be changed when editing the entity and represents
+ * the language its textual components are supposed to have. If no
+ * language property is available, the 'language callback' may be used
+ * instead. This entry can be omitted if the entities of this type are not
+ * language-aware.
+ * - module: (optional) A key for the module property used by the entity CRUD
+ * API to save the source module name for exportable entities that have been
+ * provided in code. Defaults to 'module'.
+ * - default revision: (optional) The name of the entity property used by
+ * the entity CRUD API to determine if a newly-created revision should be
+ * set as the default revision. Defaults to 'default_revision'.
+ * Note that on entity insert the created revision will be always default
+ * regardless of the value of this entity property.
+ * - language callback: (optional) The name of an implementation of
+ * callback_entity_info_language(). In most situations, when needing to
+ * determine this value, inspecting a property named after the 'language'
+ * element of the 'entity keys' should be enough. The language callback is
+ * meant to be used primarily for temporary alterations of the property
+ * value: entity-defining modules are encouraged to always define a
+ * language property, instead of using the callback as main entity language
+ * source. In fact not having a language property defined is likely to
+ * prevent an entity from being queried by language. Moreover, given that
+ * entity_plus_language() is not necessarily used everywhere it would be
+ * appropriate, modules implementing the language callback should be aware
+ * that this might not be always called.
+ * - extra fields controller class (optional): The name of the class that is used to return extra field
+ * information, and for creating display information for extra fields. Extra fields are non-Field API
+ * properties of the entity, other than ID or bundle. This class has to implement
+ * EntityExtraFieldsControllerInterface. Entity Plus provides the default class
+ * EntityDefaultExtraFieldsController that themes the properties using theme_entity_plus_property().
+ * - metadata controller class: (optional) A controller class for providing
+ * entity property info. By default some info is generated out of the
+ * information provided in your hook_schema() implementation, while only read
+ * access is granted to that properties by default. Based upon that the
+ * Entity tokens module also generates token replacements for your entity
+ * type, once activated.
+ * Override the controller class to adapt the defaults and to improve and
+ * complete the generated metadata. Set it to FALSE to disable this feature.
+ * Defaults to the EntityDefaultMetadataController class.
+ * - module (optional): The module providing the entity type. This is optional,
+ * but strongly suggested.
+ * - plural label: (optional) The human-readable, plural name of the entity
+ * type. As 'label' it should start capitalized.
+ * - description: (optional) A human-readable description of the entity type.
+ * - access callback: (optional) Specify a callback that returns access
+ * permissions for the operations 'create', 'update', 'delete' and 'view'.
+ * The callback gets optionally the entity and the user account to check for
+ * passed. See entity_access() for more details on the arguments and
+ * entity_metadata_no_hook_node_access() for an example.
+ * - views controller class: (optional) A controller class for providing views
+ * integration. The given class has to inherit from the class
+ * EntityPlusDefaultViewsController, which is set as default in case the providing
+ * module has been specified (see 'module') and the module does not provide
+ * any views integration. Else it defaults to FALSE, which disables this
+ * feature. See EntityPlusDefaultViewsController.
+ * - creation callback: (optional) A callback that creates a new instance of
+ * this entity type. See entity_plus_metadata_create_node() for an example.
+ * - save callback: (optional) A callback that permanently saves an entity of
+ * this type.
+ * - deletion callback: (optional) A callback that permanently deletes an
+ * entity of this type.
+ * - revision deletion callback: (optional) A callback that deletes a revision
+ * of the entity.
+ * - view callback: (optional) A callback to render a list of entities.
+ * See entity_metadata_view_node() as example.
+ * - form callback: (optional) A callback that returns a fully built edit form
+ * for the entity type.
+ * - token type: (optional) A type name to use for token replacements. Set it
+ * to FALSE if there aren't any token replacements for this entity type.
+ * - configuration: (optional) A boolean value that specifies whether the entity
+ * type should be considered as configuration. Modules working with entities
+ * may use this value to decide whether they should deal with a certain entity
+ * type. Defaults to TRUE to for entity types that are exportable, else to
+ * FALSE.
+ *
+ * @see entity_load()
+ * @see hook_entity_info_alter()
+ */
+
+/**
+ * This is a placeholder to illustrate the keys added by Entity Plus.
+ */
+function entity_plus_hook_entity_info() {
+ $return = array(
+ 'basic_entity_plus' => array(
+ 'label' => t('Basic Entity Plus entity'),
+ 'plural label' => t('Basic Entity Plus entities'),
+ 'entity class' => 'BasicEntityPlus',
+ 'controller class' => 'BasicEntityPlusController',
+ 'base table' => 'basic_entity_plus',
+ 'fieldable' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'basic_entity_plus_id',
+ 'bundle' => 'type',
+ 'label' => 'title',
+ ),
+ 'bundle keys' => array(
+ 'bundle' => 'type',
+ ),
+ 'bundles' => array(),
+ 'load hook' => 'basic_entity_plus_load',
+ 'view modes' => array(),
+
+ 'label callback' => 'entity_label',
+
+ // This key is also used by the core entity.tokens.inc to provide a url token.
+ 'uri callback' => 'basic_entity_plus_uri',
+
+ 'module' => 'basic_entity_plus',
+ 'access callback' => 'basic_entity_plus_access',
+ ),
+ );
+
+ // Entity to hold bundle definitions.
+ $return['basic_entity_plus_type'] = array(
+ 'label' => t('Basic Entity Plus type'),
+ 'entity class' => 'BasicEntityPlus',
+ 'controller class' => 'BasicEntityPlusTypeController',
+ 'base table' => 'basic_entity_plus_type',
+ 'fieldable' => FALSE,
+ 'bundle of' => 'basic_entity_plus',
+ 'exportable' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'id',
+ 'name' => 'type',
+ 'label' => 'label',
+ 'module' => 'module',
+ ),
+ 'module' => 'basic_entity_plus',
+ // Enable the admin UI. See Entity UI.
+ 'admin ui' => array(
+ 'path' => 'admin/structure/basic_entity_plus-types',
+ 'file' => 'basic_entity_plus.admin.inc',
+ 'controller class' => 'EntityDefaultUIController',
+ ),
+ 'access callback' => 'basic_entity_plus_type_access',
+ 'uri callback' => 'basic_entity_plus_type_uri',
+ );
+
+ return $return;
+}
+
+
+/**
+ * Allow modules to define metadata about entity properties.
+ *
+ * Modules providing properties for any entities defined in hook_entity_info()
+ * can implement this hook to provide metadata about this properties.
+ * For making use of the metadata have a look at the provided wrappers returned
+ * by entity_metadata_wrapper().
+ * For providing property information for fields see entity_hook_field_info().
+ *
+ * @return array
+ * An array whose keys are entity type names and whose values are arrays
+ * containing the keys:
+ * - properties: The array describing all properties for this entity. Entries
+ * are keyed by the property name and contain an array of metadata for each
+ * property. The name may only contain alphanumeric lowercase characters
+ * and underscores. Known keys are:
+ * - label: A human readable, translated label for the property.
+ * - description: (optional) A human readable, translated description for
+ * the property.
+ * - type: The data type of the property. To make the property actually
+ * useful it is important to map your properties to one of the known data
+ * types, which currently are:
+ * - text: Any text.
+ * - token: A string containing only lowercase letters, numbers, and
+ * underscores starting with a letter; e.g. this type is useful for
+ * machine readable names.
+ * - integer: A usual PHP integer value.
+ * - decimal: A PHP float or integer.
+ * - date: A full date and time, as timestamp.
+ * - duration: A duration as number of seconds.
+ * - boolean: A usual PHP boolean value.
+ * - uri: An absolute URI or URL.
+ * - entities - You may use the type of each entity known by
+ * hook_entity_info(), e.g. 'node' or 'user'. Internally entities are
+ * represented by their identifieres. In case of single-valued
+ * properties getter callbacks may return full entity objects as well,
+ * while a value of FALSE is interpreted like a NULL value as "property
+ * is not set".
+ * - entity: A special type to be used generically for entities where the
+ * entity type is not known beforehand. The entity has to be
+ * represented using an EntityMetadataWrapper.
+ * - struct: This as well as any else not known type may be used for
+ * supporting arbitrary data structures. For that additional metadata
+ * has to be specified with the 'property info' key. New type names
+ * have to be properly prefixed with the module name.
+ * - list: A list of values, represented as numerically indexed array.
+ * The list notation may be used to specify the type of the
+ * contained items, where TYPE may be any valid type expression.
+ * - bundle: (optional) If the property is an entity, you may specify the
+ * bundle of the referenced entity.
+ * - options list: (optional) A callback that returns a list of possible
+ * values for the property. The callback has to return an array as
+ * used by hook_options_list().
+ * Note that it is possible to return a different set of options depending
+ * whether they are used in read or in write context. See
+ * EntityMetadataWrapper::optionsList() for more details on that.
+ * - getter callback: (optional) A callback used to retrieve the value of
+ * the property. Defaults to entity_property_verbatim_get().
+ * It is important that your data is represented, as documented for your
+ * data type, e.g. a date has to be a timestamp. Thus if necessary, the
+ * getter callback has to do the necessary conversion. In case of an empty
+ * or not set value, the callback has to return NULL.
+ * - setter callback: (optional) A callback used to set the value of the
+ * property. In many cases entity_plus_property_verbatim_set() can be used.
+ * - validation callback: (optional) A callback that returns whether the
+ * passed data value is valid for the property. May be used to implement
+ * additional validation checks, such as to ensure the value is a valid
+ * mail address.
+ * - access callback: (optional) An access callback to allow for checking
+ * 'view' and 'edit' access for the described property. If no callback
+ * is specified, a 'setter permission' may be specified instead.
+ * - setter permission: (optional) A permission that describes whether
+ * a user has permission to set ('edit') this property. This permission
+ * is only be taken into account, if no 'access callback' is given.
+ * - schema field: (optional) In case the property is directly based upon
+ * a field specified in the entity's hook_schema(), the name of the field.
+ * - queryable: (optional) Whether a property is queryable with
+ * EntityFieldQuery. Defaults to TRUE if a 'schema field' is specified, or
+ * if the deprecated 'query callback' is set to
+ * 'entity_metadata_field_query'. Otherwise it defaults to FALSE.
+ * - query callback: (deprecated) A callback for querying for entities
+ * having the given property value. See entity_property_query().
+ * Generally, properties should be queryable via EntityFieldQuery. If
+ * that is the case, just set 'queryable' to TRUE.
+ * - required: (optional) Whether this property is required for the creation
+ * of a new instance of its entity. See
+ * entity_property_values_create_entity().
+ * - field: (optional) A boolean indicating whether a property is stemming
+ * from a field.
+ * - computed: (optional) A boolean indicating whether a property is
+ * computed, i.e. the property value is not stored or loaded by the
+ * entity's controller but determined on the fly by the getter callback.
+ * Defaults to FALSE.
+ * - entity views field: (optional) If enabled, the property is
+ * automatically exposed as views field available to all views query
+ * backends listing this entity-type. As the property value will always be
+ * generated from a loaded entity object, this is particularly useful for
+ * 'computed' properties. Defaults to FALSE.
+ * - sanitized: (optional) For textual properties only, whether the text is
+ * already sanitized. In this case you might want to also specify a raw
+ * getter callback. Defaults to FALSE.
+ * - sanitize: (optional) For textual properties, that are not sanitized
+ * yet, specify a function for sanitizing the value. Defaults to
+ * check_plain().
+ * - raw getter callback: (optional) For sanitized textual properties, a
+ * separate callback which can be used to retrieve the raw, unprocessed
+ * value.
+ * - clear: (optional) An array of property names, of which the cache should
+ * be cleared too once this property is updated. As a rule of thumb any
+ * duplicated properties should be avoided though.
+ * - property info: (optional) An array of info for an arbitrary data
+ * structure together with any else not defined type, see data type
+ * 'struct'. Specify metadata in the same way as defined for this hook.
+ * - property info alter: (optional) A callback for altering the property
+ * info before it is used by the metadata wrappers.
+ * - property defaults: (optional) An array of property info defaults for
+ * each property derived of the wrapped data item (e.g. an entity).
+ * Applied by the metadata wrappers.
+ * - auto creation: (optional) Properties of type 'struct' may specify
+ * this callback which is used to automatically create the data structure
+ * (e.g. an array) if necessary. This is necessary in order to support
+ * setting a property of a not yet initialized data structure.
+ * See entity_metadata_field_file_callback() for an example.
+ * - translatable: (optional) Whether the property is translatable, defaults
+ * to FALSE.
+ * - entity token: (optional) If Entity tokens module is enabled, the
+ * module provides a token for the property if one does not exist yet.
+ * Specify FALSE to disable this functionality for the property.
+ * - bundles: An array keyed by bundle name containing further metadata
+ * related to the bundles only. This array may contain the key 'properties'
+ * with an array of info about the bundle specific properties, structured in
+ * the same way as the entity properties array.
+ *
+ * @see hook_entity_property_info_alter()
+ * @see entity_get_property_info()
+ * @see entity_metadata_wrapper()
+ */
+function hook_entity_property_info() {
+ $info = array();
+ $properties = &$info['node']['properties'];
+
+ $properties['nid'] = array(
+ 'label' => t("Content ID"),
+ 'type' => 'integer',
+ 'description' => t("The unique content ID."),
+ );
+ return $info;
+}
+
+/**
+ * Allow modules to alter metadata about entity properties.
+ *
+ * @see hook_entity_property_info()
+ */
+function hook_entity_property_info_alter(&$info) {
+ $properties = &$info['node']['bundles']['poll']['properties'];
+
+ $properties['poll-votes'] = array(
+ 'label' => t("Poll votes"),
+ 'description' => t("The number of votes that have been cast on a poll node."),
+ 'type' => 'integer',
+ 'getter callback' => 'entity_property_poll_node_get_properties',
+ );
+}
+
+/**
+ * Provide entity property information for fields.
+ *
+ * This is a placeholder for describing further keys for hook_field_info(),
+ * which are introduced by the entity API.
+ *
+ * For providing entity property info for fields each field type may specify a
+ * property type to map to using the key 'property_type'. With that info in
+ * place useful defaults are generated, which suffice for a lot of field
+ * types.
+ * However it is possible to specify further callbacks that may alter the
+ * generated property info. To do so use the key 'property_callbacks' and set
+ * it to an array of function names. Apart from that any property info provided
+ * for a field instance using the key 'property info' is added in too.
+ *
+ * @see entity_field_info_alter()
+ * @see entity_metadata_field_text_property_callback()
+ */
+function entity_plus_hook_field_info() {
+ return array(
+ 'text' => array(
+ 'label' => t('Text'),
+ 'property_type' => 'text',
+ // ...
+ ),
+ );
+}
+
+/**
+ * Act after default entities have been rebuilt.
+ *
+ * This hook is invoked after default entities have been fully saved to the
+ * database, but with the lock still active.
+ *
+ * @param array $entities
+ * An array of the entities that have been saved, keyed by name.
+ * @param array $originals
+ * An array of the original copies of the entities that have been saved,
+ * keyed by name.
+ *
+ * @see _entity_defaults_rebuild()
+ */
+function hook_ENTITY_TYPE_defaults_rebuild($entities, $originals) {
+
+}
+
+/**
+ * Act on an entity before it is about to be created or updated.
+ *
+ * @param Entity $entity
+ * The entity object.
+ * @param string $type
+ * The type of entity being saved (i.e. node, user, comment).
+ */
+function hook_entity_plus_presave($entity, $type) {
+ $entity->changed = REQUEST_TIME;
+}
+
+/**
+ * Act on entities when inserted.
+ *
+ * @param Entity $entity
+ * The entity object.
+ * @param string $type
+ * The type of entity being inserted (i.e. node, user, comment).
+ */
+function hook_entity_plus_insert($entity, $type) {
+ // Insert the new entity into a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_insert('example_entity')
+ ->fields(array(
+ 'type' => $type,
+ 'id' => $id,
+ 'created' => REQUEST_TIME,
+ 'updated' => REQUEST_TIME,
+ ))
+ ->execute();
+}
+
+/**
+ * Act on entities when updated.
+ *
+ * @param Entity $entity
+ * The entity object.
+ * @param sting $type
+ * The type of entity being updated (e.g. node, user, comment).
+ */
+function hook_entity_plus_update($entity, $type) {
+ // Update the entity's entry in a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_update('example_entity')
+ ->fields(array(
+ 'updated' => REQUEST_TIME,
+ ))
+ ->condition('type', $type)
+ ->condition('id', $id)
+ ->execute();
+}
+
+/**
+ * Respond to entity deletion.
+ *
+ * This hook runs after the entity type-specific delete hook.
+ *
+ * @param Entity $entity
+ * The entity object for the entity that has been deleted.
+ * @param string $type
+ * The type of entity being deleted (i.e. node, user, comment).
+ */
+function hook_entity_plus_delete($entity, $type) {
+ // Delete the entity's entry from a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_delete('example_entity')
+ ->condition('type', $type)
+ ->condition('id', $id)
+ ->execute();
+}
+
+/**
+ * Act on an entity that is being assembled before rendering.
+ *
+ * The module may add elements to $entity->content prior to rendering. This hook
+ * will be called after hook_view(). The structure of $entity->content is a
+ * renderable array as expected by backdrop_render().
+ *
+ * When $view_mode is 'rss', modules can also add extra RSS elements and
+ * namespaces to $node->rss_elements and $node->rss_namespaces respectively for
+ * the RSS item generated for this node.
+ * For details on how this is used, see node_feed().
+ *
+ * @param Entity $entity
+ * The entity that is being assembled for rendering.
+ * @param string $entity_type
+ * The entity type of the entity being assembled.
+ * @param string $view_mode
+ * The $view_mode parameter from entity_view().
+ * @param string $langcode
+ * The language code used for rendering.
+ *
+ * @see hook_entity_view()
+ * @see hook_entity_view_alter()
+ * @see hook_entity_plus_view_alter()
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_entity_plus_view($entity, $entity_type, $view_mode, $langcode) {
+ $entity->content['my_additional_field'] = array(
+ '#markup' => $additional_field,
+ '#weight' => 10,
+ '#theme' => 'mymodule_my_additional_field',
+ );
+}
+
+/**
+ * Alter the handlers used by the data selection tables provided by this module.
+ *
+ * @param array $field_handlers
+ * An array of the field handler classes to use for specific types. The keys
+ * are the types, mapped to their respective classes. Contained types are:
+ * - All primitive types known by the entity API (see
+ * hook_entity_property_info()).
+ * - options: Special type for fields having an options list.
+ * - field: Special type for Field API fields.
+ * - entity: Special type for entity-valued fields.
+ * - relationship: Views relationship handler to use for relationships.
+ * Values for all specific entity types can be additionally added.
+ *
+ * @see entity_plus_views_field_definition()
+ * @see entity_plus_views_get_field_handlers()
+ */
+function hook_entity_plus_views_field_handlers_alter(array &$field_handlers) {
+ $field_handlers['text'] = 'example_text_handler';
+}
diff --git a/www/modules/contrib/entity_plus/entity_plus.info b/www/modules/contrib/entity_plus/entity_plus.info
new file mode 100644
index 000000000..0aa07485b
--- /dev/null
+++ b/www/modules/contrib/entity_plus/entity_plus.info
@@ -0,0 +1,10 @@
+name = Entity Plus
+description = Functions and wrappers to extend core Entity functionality.
+
+backdrop = 1.x
+type = module
+
+; Added by Backdrop CMS packaging script on 2026-01-06
+project = entity_plus
+version = 1.x-1.0.23
+timestamp = 1767717936
diff --git a/www/modules/contrib/entity_plus/entity_plus.module b/www/modules/contrib/entity_plus/entity_plus.module
new file mode 100644
index 000000000..816f215a8
--- /dev/null
+++ b/www/modules/contrib/entity_plus/entity_plus.module
@@ -0,0 +1,1229 @@
+uid;
+ }
+ return new EntityBackdropWrapper($type, $data, $info);
+ }
+ elseif ($type == 'list' || entity_plus_property_list_extract_type($type)) {
+ return new EntityListWrapper($type, $data, $info);
+ }
+ elseif (isset($info['property info'])) {
+ return new EntityStructureWrapper($type, $data, $info);
+ }
+ elseif ($type == 'taxonomy_vocabulary') {
+ return new EntityVocabularyWrapper($type, $data, $info);
+ }
+ else {
+ return new EntityValueWrapper($type, $data, $info);
+ }
+}
+
+/**
+ * Returns a property wrapper for the given data.
+ *
+ * Short version of entity_metadata_wrapper().
+ *
+ * @return EntityMetadataWrapper
+ * Dependend on the passed data the right wrapper is returned.
+ *
+ * @see entity_metadata_wrapper()
+ */
+function emw($type, $data = NULL, array $info = array()) {
+ return entity_metadata_wrapper($type, $data, $info);
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function entity_plus_autoload_info() {
+ return array(
+ 'EntityValueWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityStructureWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityListWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityBackdropWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityMetadataWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityVocabularyWrapper' => 'includes/entity_plus.wrapper.inc',
+ 'EntityMetadataWrapperException' => 'includes/entity_plus.wrapper.inc',
+ 'EntityMetadataWrapperIterator' => 'includes/entity_plus.wrapper.inc',
+ 'EntityMetadataArrayObject' => 'includes/entity_plus.wrapper.inc',
+ 'EntityDefaultMetadataController' => 'includes/entity_plus.wrapper.inc',
+ 'EntityExtraFieldsControllerInterface' => 'includes/entity_plus.wrapper.inc',
+ 'EntityDefaultExtraFieldsController' => 'includes/entity_plus.wrapper.inc',
+ 'EntityPlusControllerInterface' => 'includes/entity_plus.controller.inc',
+ 'EntityPlusControllerRevisionableInterface' => 'includes/entity_plus.controller.inc',
+ 'EntityPlusController' => 'includes/entity_plus.controller.inc',
+ 'EntityPlusControllerExportable' => 'includes/entity_plus.controller.inc',
+ 'EntityPlusFieldHandlerHelper' => 'views/handlers/entity_plus_views_field_handler_helper.inc',
+ 'entity_plus_views_handler_area_entity' => 'views/handlers/entity_plus_views_handler_area_entity.inc',
+ 'entity_plus_views_handler_field_boolean' => 'views/handlers/entity_plus_views_handler_field_boolean.inc',
+ 'entity_plus_views_handler_field_date' => 'views/handlers/entity_plus_views_handler_field_date.inc',
+ 'entity_plus_views_handler_field_duration' => 'views/handlers/entity_plus_views_handler_field_duration.inc',
+ 'entity_plus_views_handler_field_entity' => 'views/handlers/entity_plus_views_handler_field_entity.inc',
+ 'entity_plus_views_handler_field_field' => 'views/handlers/entity_plus_views_handler_field_field.inc',
+ 'entity_plus_views_handler_field_numeric' => 'views/handlers/entity_plus_views_handler_field_numeric.inc',
+ 'entity_plus_views_handler_field_options' => 'views/handlers/entity_plus_views_handler_field_options.inc',
+ 'entity_plus_views_handler_field_text' => 'views/handlers/entity_plus_views_handler_field_text.inc',
+ 'entity_plus_views_handler_field_uri' => 'views/handlers/entity_plus_views_handler_field_uri.inc',
+ 'entity_plus_views_handler_relationship_by_bundle' => 'views/handlers/entity_plus_views_handler_relationship_by_bundle.inc',
+ 'entity_plus_views_handler_relationship' => 'views/handlers/entity_plus_views_handler_relationship.inc',
+ 'entity_plus_views_plugin_row_entity_view' => 'views/plugins/entity_plus_views_plugin_row_entity_view.inc',
+ );
+}
+
+/**
+ * Implements hook_entity_property_info().
+ */
+function entity_plus_entity_property_info() {
+ $items = array();
+ // Add in info about entities provided by the CRUD API.
+ foreach (entity_plus_crud_get_info() as $type => $info) {
+ // Automatically enable the controller only if the module does not implement
+ // the hook itself.
+ if (!isset($info['metadata controller class']) && !empty($info['base table']) && (!isset($info['module']) || !module_hook($info['module'], 'entity_property_info'))) {
+ $info['metadata controller class'] = 'EntityDefaultMetadataController';
+ }
+ if (!empty($info['metadata controller class'])) {
+ $controller = new $info['metadata controller class']($type);
+ $items += $controller->entityPropertyInfo();
+ }
+ }
+ // Add in info for all core entities.
+ foreach (_entity_plus_metadata_core_modules() as $module) {
+ module_load_include('inc', 'entity_plus', "modules/$module.info");
+ if (function_exists($function = "entity_plus_metadata_{$module}_entity_property_info")) {
+ if ($return = $function()) {
+ $items = array_merge_recursive($items, $return);
+ }
+ }
+ }
+ return $items;
+}
+
+/**
+ * Returns the entity identifier, i.e. the entities name or numeric id.
+ *
+ * Unlike entity_extract_ids() this function returns the name of the entity
+ * instead of the numeric id, in case the entity type has specified a name key.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * An entity object.
+ *
+ * @see entity_extract_ids()
+ */
+function entity_plus_id($entity_type, $entity) {
+ if (method_exists($entity, 'identifier')) {
+ return $entity->identifier();
+ }
+ $info = entity_get_info($entity_type);
+ $key = isset($info['entity keys']['name']) ? $info['entity keys']['name'] : $info['entity keys']['id'];
+ return isset($entity->$key) ? $entity->$key : NULL;
+}
+
+/**
+ * Get the entity info for the entity types provided via the entity CRUD API.
+ *
+ * @return array
+ * An array in the same format as entity_get_info(), containing the entities whose controller class implements the EntityPlusControllerInterface.
+ */
+function entity_plus_crud_get_info() {
+ $types = array();
+ foreach (entity_get_info() as $type => $info) {
+ if (isset($info['controller class']) && in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ $types[$type] = $info;
+ }
+ }
+ return $types;
+}
+
+/**
+ * Implements hook_entity_property_info_alter().
+ */
+function entity_plus_entity_property_info_alter(&$entity_info) {
+ // Add in info for all core entities.
+ foreach (_entity_plus_metadata_core_modules() as $module) {
+ module_load_include('inc', 'entity_plus', "modules/$module.info");
+ if (function_exists($function = "entity_plus_metadata_{$module}_entity_property_info_alter")) {
+ $function($entity_info);
+ }
+ }
+}
+
+/**
+ * Helper function that returns an array of core modules.
+ */
+function _entity_plus_metadata_core_modules() {
+ return array_filter(array(
+ 'book',
+ 'comment',
+ 'field',
+ 'locale',
+ 'node',
+ 'taxonomy',
+ 'user',
+ 'system',
+ 'statistics',
+ ), 'module_exists');
+}
+
+/**
+ * Implements hook_implements_alter().
+ */
+function entity_plus_module_implements_alter(&$implementations, $hook) {
+ if ($hook == 'entity_info_alter') {
+ // Move our hook implementation to the bottom.
+ $group = $implementations['entity_plus'];
+ unset($implementations['entity_plus']);
+ $implementations['entity_plus'] = $group;
+ }
+}
+
+/**
+ * Determines whether for the given entity type a given operation is available.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param string $op
+ * One of 'create', 'view', 'save', 'delete', 'revision delete', 'access' or 'form'.
+ *
+ * @return bool
+ * Whether the entity type supports the given operation.
+ */
+function entity_plus_type_supports($entity_type, $op) {
+ $info = entity_get_info($entity_type);
+ $keys = array(
+ 'view' => 'view callback',
+ 'create' => 'creation callback',
+ 'delete' => 'deletion callback',
+ 'revision delete' => 'revision deletion callback',
+ 'save' => 'save callback',
+ 'access' => 'access callback',
+ 'form' => 'form callback'
+ );
+ if (isset($info[$keys[$op]])) {
+ return TRUE;
+ }
+ if ($op == 'revision delete') {
+ return in_array('EntityPlusControllerInterface', class_implements($info['controller class']));
+ }
+ if ($op == 'form' && module_exists('entity_ui')) {
+ return (bool) entity_ui_controller($entity_type);
+ }
+ if ($op != 'access') {
+ return in_array('EntityPlusControllerInterface', class_implements($info['controller class']));
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ *
+ * @see entity_plus_module_implements_alter()
+ */
+function entity_plus_entity_info_alter(&$entity_plus_info) {
+ _entity_plus_info_add_metadata($entity_plus_info);
+
+ // Populate a default value for the 'configuration' key of all entity types.
+ foreach ($entity_plus_info as $type => $info) {
+ if (!isset($info['configuration'])) {
+ $entity_plus_info[$type]['configuration'] = !empty($info['exportable']);
+ }
+
+ if (isset($info['controller class']) && in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ // Automatically disable field cache when entity cache is used.
+ if (!empty($info['entity cache'])) {
+ $entity_plus_info[$type]['field cache'] = FALSE;
+ }
+ }
+ }
+}
+
+/**
+ * Adds metadata and callbacks for core entities to the entity info.
+ */
+function _entity_plus_info_add_metadata(&$entity_plus_info) {
+ // Set plural labels.
+ $entity_plus_info['node']['plural label'] = t('Nodes');
+ $entity_plus_info['user']['plural label'] = t('Users');
+ $entity_plus_info['file']['plural label'] = t('Files');
+
+ // Set descriptions.
+ $entity_plus_info['node']['description'] = t('Nodes represent the main site content items.');
+ $entity_plus_info['user']['description'] = t('Users who have created accounts on your site.');
+ $entity_plus_info['file']['description'] = t('Uploaded file.');
+
+ // Set access callbacks.
+ $entity_plus_info['node']['access callback'] = 'entity_plus_metadata_no_hook_node_access';
+ $entity_plus_info['user']['access callback'] = 'entity_plus_metadata_user_access';
+ $entity_plus_info['file']['access callback'] = 'entity_plus_metadata_file_access';
+
+ // CRUD function callbacks.
+ $entity_plus_info['node']['creation callback'] = 'entity_plus_metadata_create_node';
+ $entity_plus_info['node']['save callback'] = 'node_save';
+ $entity_plus_info['node']['deletion callback'] = 'node_delete';
+ $entity_plus_info['node']['revision deletion callback'] = 'node_revision_delete';
+ $entity_plus_info['user']['creation callback'] = 'entity_plus_metadata_create_object';
+ $entity_plus_info['user']['save callback'] = 'user_save';
+ $entity_plus_info['user']['deletion callback'] = 'user_delete';
+ $entity_plus_info['file']['save callback'] = 'file_save';
+ $entity_plus_info['file']['deletion callback'] = 'entity_plus_metadata_delete_file';
+
+ // Form callbacks.
+ $entity_plus_info['node']['form callback'] = 'entity_plus_metadata_form_node';
+ $entity_plus_info['user']['form callback'] = 'entity_plus_metadata_form_user';
+
+ // URI callbacks.
+ if (!isset($entity_plus_info['file']['uri callback'])) {
+ $entity_plus_info['file']['uri callback'] = 'entity_plus_metadata_uri_file';
+ }
+
+ // View callbacks.
+ $entity_plus_info['node']['view callback'] = 'entity_plus_metadata_view_node';
+ $entity_plus_info['user']['view callback'] = 'entity_plus_metadata_view_single';
+
+ if (module_exists('comment')) {
+ $entity_plus_info['comment']['plural label'] = t('Comments');
+ $entity_plus_info['comment']['description'] = t('Remark or note that refers to a node.');
+ $entity_plus_info['comment']['access callback'] = 'entity_plus_metadata_comment_access';
+ $entity_plus_info['comment']['creation callback'] = 'entity_plus_metadata_create_comment';
+ $entity_plus_info['comment']['save callback'] = 'comment_save';
+ $entity_plus_info['comment']['deletion callback'] = 'comment_delete';
+ $entity_plus_info['comment']['view callback'] = 'entity_plus_metadata_view_comment';
+ $entity_plus_info['comment']['form callback'] = 'entity_plus_metadata_form_comment';
+ }
+ if (module_exists('taxonomy')) {
+ $entity_plus_info['taxonomy_term']['plural label'] = t('Taxonomy terms');
+ $entity_plus_info['taxonomy_term']['description'] = t('Taxonomy terms are used for classifying content.');
+ $entity_plus_info['taxonomy_term']['access callback'] = 'entity_plus_metadata_taxonomy_access';
+ $entity_plus_info['taxonomy_term']['creation callback'] = 'entity_plus_metadata_create_object';
+ $entity_plus_info['taxonomy_term']['save callback'] = 'taxonomy_term_save';
+ $entity_plus_info['taxonomy_term']['deletion callback'] = 'taxonomy_term_delete';
+ $entity_plus_info['taxonomy_term']['view callback'] = 'entity_plus_metadata_view_single';
+ $entity_plus_info['taxonomy_term']['form callback'] = 'entity_plus_metadata_form_taxonomy_term';
+
+ // Token type mapping.
+ $entity_plus_info['taxonomy_term']['token type'] = 'term';
+ }
+}
+
+/**
+ * Generate an array for rendering the given entities.
+ *
+ * Entities being viewed, are generally expected to be fully-loaded entity
+ * objects, thus have their name or id key set. However, it is possible to
+ * view a single entity without any id, e.g. for generating a preview during
+ * creation.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param array $entities
+ * An array of entities to render.
+ * @param string $view_mode
+ * A view mode as used by this entity type, e.g. 'full', 'teaser'...
+ * @param string $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ * @param bool $page
+ * (optional) If set will control if the entity is rendered: if TRUE
+ * the entity will be rendered without its title, so that it can be embeded
+ * in another context. If FALSE the entity will be displayed with its title
+ * in a mode suitable for lists.
+ * If unset, the page mode will be enabled if the current path is the URI
+ * of the entity, as returned by entity_uri().
+ * This parameter is only supported for entities which controller is a
+ * EntityPlusControllerInterface.
+ *
+ * @return array
+ * The renderable array, keyed by the entity type and by entity identifiers,
+ * for which the entity name is used if existing - see entity_plus_id(). If there
+ * is no information on how to view an entity, FALSE is returned.
+ */
+function entity_plus_view($entity_type, $entities, $view_mode = 'full', $langcode = NULL, $page = NULL) {
+ $info = entity_get_info($entity_type);
+ if (isset($info['view callback'])) {
+ $entities = entity_plus_key_array_by_property($entities, $info['entity keys']['id']);
+ return $info['view callback']($entities, $view_mode, $langcode, $entity_type);
+ }
+ elseif (in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->view($entities, $view_mode, $langcode, $page);
+ }
+ return FALSE;
+}
+
+/**
+ * Determines whether the given user can perform actions on an entity.
+ *
+ * For create operations, the pattern is to create an entity and then
+ * check if the user has create access.
+ *
+ * @code
+ * $node = entity_create('node', array('type' => 'page'));
+ * $access = entity_plus_access('create', 'node', $node, $account);
+ * @endcode
+ *
+ * @param string $op
+ * The operation being performed. One of 'view', 'update', 'create' or
+ * 'delete'.
+ * @param string $entity_type
+ * The entity type of the entity to check for.
+ * @param Entity $entity
+ * Optionally an entity to check access for. If no entity is given, it will be
+ * determined whether access is allowed for all entities of the given type.
+ * @param object $account
+ * The user to check for. Leave it to NULL to check for the global user.
+ *
+ * @return bool
+ * Whether access is allowed or not. If the entity type does not specify any
+ * access information, NULL is returned.
+ *
+ * @see entity_plus_type_supports()
+ */
+function entity_plus_access($op, $entity_type, $entity = NULL, $account = NULL) {
+ if (($info = entity_get_info()) && isset($info[$entity_type]['access callback'])) {
+ return $info[$entity_type]['access callback']($op, $entity, $account, $entity_type);
+ }
+}
+
+/**
+ * Converts the schema information available for the given table to property info.
+ *
+ * @param string $table
+ * The name of the table as used in hook_schema().
+ *
+ * @return array
+ * An array of property info as suiting for hook_entity_property_info().
+ */
+function entity_plus_metadata_convert_schema($table) {
+ $schema = backdrop_get_schema($table);
+ $properties = array();
+ foreach ($schema['fields'] as $name => $info) {
+ if ($type = _entity_plus_metadata_convert_schema_type($info['type'])) {
+ $properties[$name] = array(
+ 'type' => $type,
+ 'label' => backdrop_ucfirst($name),
+ 'schema field' => $name,
+ // As we cannot know about any setter access, leave out the setter
+ // callback. For getting usually no further access callback is needed.
+ );
+ if ($info['type'] == 'serial') {
+ $properties[$name]['validation callback'] = 'entity_plus_metadata_validate_integer_positive';
+ }
+ }
+ }
+ return $properties;
+}
+
+/**
+ * Helper function that converts a schema type to a type we understand.
+ */
+function _entity_plus_metadata_convert_schema_type($type) {
+ switch ($type) {
+ case 'int':
+ case 'serial':
+ case 'date':
+ return 'integer';
+
+ case 'float':
+ case 'numeric':
+ return 'decimal';
+
+ case 'char':
+ case 'varchar':
+ case 'text':
+ return 'text';
+ }
+}
+
+
+/**
+ * Converts an array of entities to be keyed by the values of a given property.
+ *
+ * @param array $entities
+ * The array of entities to convert.
+ * @param string $property
+ * The name of entity property, by which the array should be keyed. To get
+ * reasonable results, the property has to have unique values.
+ *
+ * @return array
+ * The same entities in the same order, but keyed by their $property values.
+ */
+function entity_plus_key_array_by_property(array $entities, $property) {
+ $ret = array();
+ foreach ($entities as $entity) {
+ $key = isset($entity->$property) ? $entity->$property : NULL;
+ $ret[$key] = $entity;
+ }
+ return $ret;
+}
+
+/**
+ * Builds a structured array representing the entity's content.
+ *
+ * The content built for the entity will vary depending on the $view_mode
+ * parameter.
+ *
+ * Note: Currently, this only works for entity types provided with the entity
+ * CRUD API.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * An entity object.
+ * @param string $view_mode
+ * A view mode as used by this entity type, e.g. 'full', 'teaser'...
+ * @param string $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return array
+ * The renderable array.
+ */
+function entity_plus_build_content($entity_type, $entity, $view_mode = 'full', $langcode = NULL) {
+ $info = entity_get_info($entity_type);
+ if (method_exists($entity, 'buildContent')) {
+ return $entity->buildContent($view_mode, $langcode);
+ }
+ elseif (in_array('EntityControllerInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->buildContent($entity, $view_mode, $langcode);
+ }
+}
+
+/**
+ * Menu loader function: load an entity from its path.
+ *
+ * This can be used to load entities of all types in menu paths:
+ *
+ * @code
+ * $items['myentity/%entity_plus_object'] = array(
+ * 'load arguments' => array('myentity'),
+ * 'title' => ...,
+ * 'page callback' => ...,
+ * 'page arguments' => array(...),
+ * 'access arguments' => array(...),
+ * );
+ * @endcode
+ *
+ * @param int $entity_plus_id
+ * The ID of the entity to load, passed by the menu URL.
+ * @param string $entity_type
+ * The type of the entity to load.
+ *
+ * @return object
+ * A fully loaded entity object, or FALSE in case of error.
+ */
+function entity_plus_object_load($entity_plus_id, $entity_type) {
+ $entities = entity_load($entity_type, array($entity_plus_id));
+ return reset($entities);
+}
+
+/**
+ * A wrapper around entity_load() to return entities keyed by name key if existing.
+ *
+ * @param string $entity_type
+ * The entity type to load, e.g. node or user.
+ * @param array $names
+ * An array of entity names or ids, or FALSE to load all entities.
+ * @param array $conditions
+ * (deprecated) An associative array of conditions on the base table, where
+ * the keys are the database fields and the values are the values those
+ * fields must have. Instead, it is preferable to use EntityFieldQuery to
+ * retrieve a list of entity IDs loadable by this function.
+ *
+ * @return array
+ * An array of entity objects indexed by their names (or ids if the entity
+ * type has no name key).
+ *
+ * @see entity_load()
+ */
+function entity_load_multiple_by_name($entity_type, $names = FALSE, $conditions = array()) {
+ $entities = entity_load($entity_type, $names, $conditions);
+ $info = entity_get_info($entity_type);
+ if (!isset($info['entity keys']['name'])) {
+ return $entities;
+ }
+ return entity_plus_key_array_by_property($entities, $info['entity keys']['name']);
+}
+
+/**
+ * Loads an entity revision.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param int $revision_id
+ * The id of the revision to load.
+ *
+ * @return object
+ * The entity object, or FALSE if there is no entity with the given revision
+ * id.
+ */
+function entity_plus_revision_load($entity_type, $revision_id) {
+ $info = entity_get_info($entity_type);
+ if (!empty($info['entity keys']['revision'])) {
+ $entity_plus_revisions = entity_load($entity_type, FALSE, array($info['entity keys']['revision'] => $revision_id));
+ return reset($entity_plus_revisions);
+ }
+ return FALSE;
+}
+
+/**
+ * Deletes an entity revision.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param int $revision_id
+ * The revision ID to delete.
+ *
+ * @return bool
+ * TRUE if the entity revision could be deleted, FALSE otherwise.
+ */
+function entity_plus_revision_delete($entity_type, $revision_id) {
+ $info = entity_get_info($entity_type);
+ if (isset($info['revision deletion callback'])) {
+ return $info['revision deletion callback']($revision_id, $entity_type);
+ }
+ elseif (in_array('EntityPlusControllerRevisionableInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->deleteRevision($revision_id);
+ }
+ return FALSE;
+}
+
+/**
+ * Checks whether the given entity is the default revision.
+ *
+ * Note that newly created entities will always be created in default revision,
+ * thus TRUE is returned for not yet saved entities.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * The entity object to check.
+ *
+ * @return bool
+ * A boolean indicating whether the entity is in default revision is returned.
+ * If the entity is not revisionable or is new, TRUE is returned.
+ *
+ * @see entity_revision_set_default()
+ */
+function entity_plus_revision_is_default($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+ if (empty($info['entity keys']['revision'])) {
+ return TRUE;
+ }
+ // Newly created entities will always be created in default revision.
+ if (!empty($entity->is_new) || empty($entity->{$info['entity keys']['id']})) {
+ return TRUE;
+ }
+ if (in_array('EntityPlusControllerRevisionableInterface', class_implements($info['controller class']))) {
+ $key = !empty($info['entity keys']['default revision']) ? $info['entity keys']['default revision'] : 'default_revision';
+ return !empty($entity->$key);
+ }
+ else {
+ // Else, just load the default entity and compare the ID. Usually, the
+ // entity should be already statically cached anyway.
+ $default = entity_load($entity_type, $entity->{$info['entity keys']['id']});
+ return $default->{$info['entity keys']['revision']} == $entity->{$info['entity keys']['revision']};
+ }
+}
+
+/**
+ * Sets a given entity revision as default revision.
+ *
+ * Note that the default revision flag will only be supported by entity types
+ * based upon the EntityPlusController, i.e. implementing the
+ * EntityPlusControllerRevisionableInterface.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * The entity revision to update.
+ *
+ * @see entity_revision_is_default()
+ */
+function entity_plus_revision_set_default($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+ if (!empty($info['entity keys']['revision'])) {
+ $key = !empty($info['entity keys']['default revision']) ? $info['entity keys']['default revision'] : 'default_revision';
+ $entity->$key = TRUE;
+ }
+}
+
+/**
+ * Checks if a given entity has a certain exportable status.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * The entity to check the status on.
+ * @param int $status
+ * The constant status like ENTITY_PLUS_CUSTOM, ENTITY_PLUS_IN_CODE, ENTITY_PLUS_OVERRIDDEN
+ * or ENTITY_PLUS_FIXED.
+ *
+ * @return bool
+ * TRUE if the entity has the status, FALSE otherwise.
+ */
+function entity_plus_has_status($entity_type, $entity, $status) {
+ $info = entity_get_info($entity_type);
+ $status_key = empty($info['entity keys']['status']) ? 'status' : $info['entity keys']['status'];
+ return isset($entity->{$status_key}) && ($entity->{$status_key} & $status) == $status;
+}
+
+/**
+ * Export a variable. Copied from ctools.
+ *
+ * This is a replacement for var_export(), allowing us to more nicely
+ * format exports. It will recurse down into arrays and will try to
+ * properly export bools when it can.
+ */
+function entity_plus_var_export($var, $prefix = '') {
+ if (is_array($var)) {
+ if (empty($var)) {
+ $output = 'array()';
+ }
+ else {
+ $output = "array(\n";
+ foreach ($var as $key => $value) {
+ $output .= " '$key' => " . entity_plus_var_export($value, ' ') . ",\n";
+ }
+ $output .= ')';
+ }
+ }
+ elseif (is_bool($var)) {
+ $output = $var ? 'TRUE' : 'FALSE';
+ }
+ else {
+ $output = var_export($var, TRUE);
+ }
+
+ if ($prefix) {
+ $output = str_replace("\n", "\n$prefix", $output);
+ }
+ return $output;
+}
+
+/**
+ * Export a variable in pretty formatted JSON.
+ */
+function entity_plus_var_json_export($var, $prefix = '') {
+ if (is_array($var) && $var) {
+ // Defines whether we use a JSON array or object.
+ $use_array = ($var == array_values($var));
+ $output = $use_array ? "[" : "{";
+
+ foreach ($var as $key => $value) {
+ if ($use_array) {
+ $values[] = entity_plus_var_json_export($value, ' ');
+ }
+ else {
+ $values[] = entity_plus_var_json_export((string) $key, ' ') . ' : ' . entity_plus_var_json_export($value, ' ');
+ }
+ }
+ // Use several lines for long content. However for objects with a single
+ // entry keep the key in the first line.
+ if (strlen($content = implode(', ', $values)) > 70 && ($use_array || count($values) > 1)) {
+ $output .= "\n " . implode(",\n ", $values) . "\n";
+ }
+ elseif (strpos($content, "\n") !== FALSE) {
+ $output .= " " . $content . "\n";
+ }
+ else {
+ $output .= " " . $content . ' ';
+ }
+ $output .= $use_array ? ']' : '}';
+ }
+ else {
+ $output = backdrop_json_encode($var);
+ }
+
+ if ($prefix) {
+ $output = str_replace("\n", "\n$prefix", $output);
+ }
+ return $output;
+}
+
+/**
+ * Rebuild the default entities provided in code.
+ *
+ * Exportable entities provided in code get saved to the database once a module
+ * providing defaults in code is activated. This allows module and entity_load()
+ * to easily deal with exportable entities just by relying on the database.
+ *
+ * The defaults get rebuilt if the cache is cleared or new modules providing
+ * defaults are enabled, such that the defaults in the database are up to date.
+ * A default entity gets updated with the latest defaults in code during rebuild
+ * as long as the default has not been overridden. Once a module providing
+ * defaults is disabled, its default entities get removed from the database
+ * unless they have been overridden. In that case the overridden entity is left
+ * in the database, but its status gets updated to 'custom'.
+ *
+ * @param array $entity_types
+ * (optional) If specified, only the defaults of the given entity types are
+ * rebuilt.
+ */
+function entity_plus_defaults_rebuild($entity_types = NULL) {
+ if (!isset($entity_types)) {
+ $entity_types = array();
+ foreach (entity_plus_crud_get_info() as $type => $info) {
+ if (!empty($info['exportable'])) {
+ $entity_types[] = $type;
+ }
+ };
+ }
+ foreach ($entity_types as $type) {
+ _entity_plus_defaults_rebuild($type);
+ }
+}
+
+/**
+ * Actually rebuild the defaults of a given entity type.
+ */
+function _entity_plus_defaults_rebuild($entity_type) {
+ if (lock_acquire('entity_plus_rebuild_' . $entity_type)) {
+ $info = entity_get_info($entity_type);
+ $hook = isset($info['export']['default hook']) ? $info['export']['default hook'] : 'default_' . $entity_type;
+ $keys = $info['entity keys'] + array(
+ 'module' => 'module',
+ 'status' => 'status',
+ 'name' => $info['entity keys']['id']
+ );
+
+ // Check for the existence of the module and status columns.
+ if (!in_array($keys['status'], $info['schema_fields_sql']['base table']) || !in_array($keys['module'], $info['schema_fields_sql']['base table'])) {
+ trigger_error("Missing database columns for the exportable entity $entity_type as defined by entity_plus_exportable_schema_fields(). Update the according module and run update.php!", E_USER_WARNING);
+ return;
+ }
+
+ // Invoke the hook and collect default entities.
+ $entities = array();
+ foreach (module_implements($hook) as $module) {
+ foreach ((array) module_invoke($module, $hook) as $name => $entity) {
+ $entity->{$keys['name']} = $name;
+ $entity->{$keys['module']} = $module;
+ $entities[$name] = $entity;
+ }
+ }
+ backdrop_alter($hook, $entities);
+
+ // Check for defaults that disappeared.
+ $existing_defaults = entity_load_multiple_by_name($entity_type, FALSE, array(
+ $keys['status'] => array(
+ ENTITY_PLUS_OVERRIDDEN,
+ ENTITY_PLUS_IN_CODE,
+ ENTITY_PLUS_FIXED,
+ )
+ ));
+
+ foreach ($existing_defaults as $name => $entity) {
+ if (empty($entities[$name])) {
+ $entity->is_rebuild = TRUE;
+ if (entity_plus_has_status($entity_type, $entity, ENTITY_PLUS_OVERRIDDEN)) {
+ $entity->{$keys['status']} = ENTITY_PLUS_CUSTOM;
+ entity_plus_save($entity_type, $entity);
+ }
+ else {
+ $entity->delete();
+ }
+ unset($entity->is_rebuild);
+ }
+ }
+
+ // Load all existing entities.
+ $existing_entities = entity_load_multiple_by_name($entity_type, array_keys($entities));
+
+ foreach ($existing_entities as $name => $entity) {
+ if (entity_plus_has_status($entity_type, $entity, ENTITY_PLUS_CUSTOM)) {
+ // If the entity already exists but is not yet marked as overridden, we
+ // have to update the status.
+ if (!entity_plus_has_status($entity_type, $entity, ENTITY_PLUS_OVERRIDDEN)) {
+ $entity->{$keys['status']} |= ENTITY_PLUS_OVERRIDDEN;
+ $entity->{$keys['module']} = $entities[$name]->{$keys['module']};
+ $entity->is_rebuild = TRUE;
+ entity_plus_save($entity_type, $entity);
+ unset($entity->is_rebuild);
+ }
+
+ // The entity is overridden, so we do not need to save the default.
+ unset($entities[$name]);
+ }
+ }
+
+ // Save defaults.
+ $originals = array();
+ foreach ($entities as $name => $entity) {
+ if (!empty($existing_entities[$name])) {
+ // Make sure we are updating the existing default.
+ $entity->{$keys['id']} = $existing_entities[$name]->{$keys['id']};
+ unset($entity->is_new);
+ }
+ // Pre-populate $entity->original as we already have it. So we avoid
+ // loading it again.
+ $entity->original = !empty($existing_entities[$name]) ? $existing_entities[$name] : FALSE;
+ // Keep original entities for hook_{entity_plus_type}_defaults_rebuild()
+ // implementations.
+ $originals[$name] = $entity->original;
+
+ if (!isset($entity->{$keys['status']})) {
+ $entity->{$keys['status']} = ENTITY_PLUS_IN_CODE;
+ }
+ else {
+ $entity->{$keys['status']} |= ENTITY_PLUS_IN_CODE;
+ }
+ $entity->is_rebuild = TRUE;
+ entity_plus_save($entity_type, $entity);
+ unset($entity->is_rebuild);
+ }
+
+ // Invoke an entity type-specific hook so modules may apply changes, e.g.
+ // efficiently rebuild caches.
+ module_invoke_all($entity_type . '_defaults_rebuild', $entities, $originals);
+
+ lock_release('entity_plus_rebuild_' . $entity_type);
+ }
+}
+
+/**
+ * Gets the extra field controller class for a given entity type.
+ *
+ * @return EntityExtraFieldsControllerInterface|false
+ * The controller for the given entity type or FALSE if none is specified.
+ */
+function entity_plus_get_extra_fields_controller($type = NULL) {
+ $static = &backdrop_static(__FUNCTION__);
+
+ if (!isset($static[$type])) {
+ $static[$type] = FALSE;
+ $info = entity_get_info($type);
+ if (!empty($info['extra fields controller class'])) {
+ $static[$type] = new $info['extra fields controller class']($type);
+ }
+ }
+ return $static[$type];
+}
+
+/**
+ * Permanently save an entity.
+ *
+ * In case of failures, an exception is thrown.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param Entity $entity
+ * The entity to save.
+ *
+ * @return int
+ * For entity types provided by the CRUD API, SAVED_NEW or SAVED_UPDATED is
+ * returned depending on the operation performed. If there is no information
+ * how to save the entity, FALSE is returned.
+ *
+ * @see entity_plus_type_supports()
+ */
+function entity_plus_save($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+ if (method_exists($entity, 'save')) {
+ return $entity->save();
+ }
+ elseif (isset($info['save callback'])) {
+ $info['save callback']($entity);
+ }
+ elseif (in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->save($entity);
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function entity_plus_theme() {
+ // Build a pattern in the form of "(type1|type2|...)(\.|__)" such that all
+ // templates starting with an entity type or named like the entity type
+ // are found.
+ // This has to match the template suggestions provided in
+ // template_preprocess_entity_plus().
+ $types = array();
+ foreach (entity_get_info() as $type => $info) {
+ if (isset($info['controller class']) && in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ $types[] = $type;
+ }
+ }
+ $pattern = '(' . implode('|', $types) . ')(\.|__)';
+
+ return array(
+ 'entity_plus_status' => array(
+ 'variables' => array('status' => NULL, 'html' => TRUE),
+ 'file' => 'theme/entity_plus.theme.inc',
+ ),
+ 'entity_plus' => array(
+ 'render element' => 'elements',
+ 'template' => 'entity_plus',
+ 'pattern' => $pattern,
+ 'path' => backdrop_get_path('module', 'entity_plus') . '/theme',
+ 'file' => 'entity_plus.theme.inc',
+ ),
+ 'entity_plus_property' => array(
+ 'render element' => 'elements',
+ 'path' => backdrop_get_path('module', 'entity_plus') . '/theme',
+ 'file' => 'entity_plus.theme.inc',
+ ),
+ );
+}
+
+/**
+ * Exports an entity.
+ *
+ * Note: Currently, this only works for entity types provided with the entity
+ * CRUD API.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param EntityInterface $entity
+ * The entity to export.
+ * @param string $prefix
+ * An optional prefix for each line.
+ *
+ * @return EntityPlusControllerInterface
+ * The exported entity as serialized string. The format is determined by the
+ * respective entity controller, e.g. it is JSON for the EntityAPIController.
+ * The output is suitable for entity_import().
+ */
+function entity_plus_export($entity_type, $entity, $prefix = '') {
+ if (method_exists($entity, 'export')) {
+ return $entity->export($prefix);
+ }
+ $info = entity_get_info($entity_type);
+ if (in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->export($entity, $prefix);
+ }
+}
+
+/**
+ * Imports an entity.
+ *
+ * Note: Currently, this only works for entity types provided with the entity
+ * CRUD API.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param string $export
+ * The string containing the serialized entity as produced by
+ * entity_export().
+ *
+ * @return EntityInterface
+ * The imported entity object not yet saved.
+ */
+function entity_plus_import($entity_type, $export) {
+ $info = entity_get_info($entity_type);
+ if (in_array('EntityPlusControllerInterface', class_implements($info['controller class']))) {
+ return entity_get_controller($entity_type)->import($export);
+ }
+}
+
+/**
+ * Checks whether an entity type is fieldable.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ *
+ * @return bool
+ * TRUE if the entity type is fieldable, FALSE otherwise.
+ */
+function entity_plus_type_is_fieldable($entity_type) {
+ $info = entity_get_info($entity_type);
+ return !empty($info['fieldable']);
+}
+
+/**
+ * Returns the language of an entity.
+ *
+ * @param string $entity_type
+ * The entity type; e.g., 'node' or 'user'.
+ * @param Entity $entity
+ * The entity for which to get the language.
+ *
+ * @return string
+ * A valid language code or NULL if the entity has no language support.
+ */
+function entity_plus_language($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+
+ // Invoke the callback to get the language. If there is no callback, try to
+ // get it from a property of the entity, otherwise NULL.
+ if (isset($info['language callback']) && function_exists($info['language callback'])) {
+ $langcode = $info['language callback']($entity_type, $entity);
+ }
+ elseif (!empty($info['entity keys']['language']) && isset($entity->{$info['entity keys']['language']})) {
+ $langcode = $entity->{$info['entity keys']['language']};
+ }
+ elseif (isset($entity->langcode)) {
+ // Backdrop added the property langcode as default for core entities.
+ $langcode = $entity->langcode;
+ }
+ else {
+ $langcode = LANGUAGE_NONE;
+ }
+
+ return $langcode;
+}
+
+/**
+ * Returns a metadata wrapper for accessing site-wide properties.
+ *
+ * Although there is no 'site' entity or such, modules may provide info about
+ * site-wide properties using hook_entity_property_info(). This function returns
+ * a wrapper for making use of this properties.
+ *
+ * @return EntityMetadataWrapper
+ * A wrapper for accessing site-wide properties.
+ *
+ * @see entity_metadata_system_entity_property_info()
+ */
+function entity_plus_metadata_site_wrapper() {
+ $site_info = entity_plus_get_property_info('site');
+ $info['property info'] = $site_info['properties'];
+ return entity_metadata_wrapper('site', FALSE, $info);
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function entity_plus_modules_enabled($modules) {
+ foreach (_entity_plus_modules_get_default_types($modules) as $type) {
+ _entity_plus_defaults_rebuild($type);
+ }
+}
+
+/**
+ * Gets all entity types for which defaults are provided by the $modules.
+ */
+function _entity_plus_modules_get_default_types($modules) {
+ $types = array();
+ foreach (entity_plus_crud_get_info() as $entity_type => $info) {
+ if (!empty($info['exportable'])) {
+ $hook = isset($info['export']['default hook']) ? $info['export']['default hook'] : 'default_' . $entity_type;
+ foreach ($modules as $module) {
+ if (module_hook($module, $hook) || module_hook($module, $hook . '_alter')) {
+ $types[] = $entity_type;
+ }
+ }
+ }
+ }
+ return $types;
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function entity_plus_modules_disabled($modules) {
+ foreach (_entity_plus_modules_get_default_types($modules) as $entity_type) {
+ $info = entity_get_info($entity_type);
+
+ // Do nothing if the module providing the entity type has been disabled too.
+ if (isset($info['module']) && in_array($info['module'], $modules)) {
+ return;
+ }
+
+ $keys = $info['entity keys'] + array(
+ 'module' => 'module',
+ 'status' => 'status',
+ 'name' => $info['entity keys']['id'],
+ );
+ // Remove entities provided in code by one of the disabled modules.
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $entity_type, '=')
+ ->propertyCondition($keys['module'], $modules, 'IN')
+ ->propertyCondition($keys['status'], array(ENTITY_PLUS_IN_CODE, ENTITY_PLUS_FIXED), 'IN');
+ $result = $query->execute();
+ if (isset($result[$entity_type])) {
+ $entities = entity_load_multiple($entity_type, array_keys($result[$entity_type]));
+ entity_delete_multiple($entity_type, array_keys($entities));
+ }
+
+ // Update overridden entities to be now custom.
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $entity_type, '=')
+ ->propertyCondition($keys['module'], $modules, 'IN')
+ ->propertyCondition($keys['status'], ENTITY_PLUS_OVERRIDDEN, '=');
+ $result = $query->execute();
+ if (isset($result[$entity_type])) {
+ foreach (entity_load_multiple($entity_type, array_keys($result[$entity_type])) as $name => $entity) {
+ $entity->{$keys['status']} = ENTITY_PLUS_CUSTOM;
+ $entity->{$keys['module']} = NULL;
+ entity_plus_save($entity_type, $entity);
+ }
+ }
+
+ // Rebuild the remaining defaults so any alterations of the disabled modules
+ // are gone.
+ _entity_plus_defaults_rebuild($entity_type);
+ }
+}
+
+/**
+ * Helper for using i18n_string().
+ *
+ * For backward compatibility, this function (which can be called even if
+ * i18n_string is not enabled) is kept in this module rather than in the new
+ * submodule entity_plus_i18n.
+ *
+ * @param $name
+ * Textgroup and context glued with ':'.
+ * @param $default
+ * String in default language. Default language may or may not be English.
+ * @param $langcode
+ * (optional) The code of a certain language to translate the string into.
+ * Defaults to the i18n_string() default, i.e. the current language.
+ *
+ * @see i18n_string()
+ */
+function entity_plus_i18n_string($name, $default, $langcode = NULL) {
+ return function_exists('i18n_string') ? i18n_string($name, $default, array('langcode' => $langcode)) : $default;
+}
diff --git a/www/modules/contrib/entity_plus/includes/entity_plus.controller.inc b/www/modules/contrib/entity_plus/includes/entity_plus.controller.inc
new file mode 100644
index 000000000..a81eba592
--- /dev/null
+++ b/www/modules/contrib/entity_plus/includes/entity_plus.controller.inc
@@ -0,0 +1,1051 @@
+entityInfo['bundle of'])) {
+ $info = entity_get_info($this->entityInfo['bundle of']);
+ $this->bundleKey = $info['bundle keys']['bundle'];
+ }
+ $this->defaultRevisionKey = !empty($this->entityInfo['entity keys']['default revision']) ? $this->entityInfo['entity keys']['default revision'] : 'default_revision';
+
+ // @todo: Remove this in a future version of the module.
+ // Compatibility with versions of backdrop older than 1.16.x.
+ if (isset($this->cache)) {
+ $this->staticCache = $this->cache;
+ $this->persistentCache = FALSE;
+ }
+ }
+
+ /**
+ * Overrides DefaultEntityController::buildQuery().
+ */
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ $query = parent::buildQuery($ids, $conditions, $revision_id);
+ if ($this->revisionKey) {
+ // Compare revision id of the base and revision table, if equal then this
+ // is the default revision.
+ $query->addExpression('CASE WHEN base.' . $this->revisionKey . ' = revision.' . $this->revisionKey . ' THEN 1 ELSE 0 END', $this->defaultRevisionKey);
+ }
+ return $query;
+ }
+
+ /**
+ * Builds and executes the query for loading.
+ *
+ * @return object
+ * The results in a Traversable object.
+ */
+ public function query($ids, $conditions, $revision_id = FALSE) {
+ // Build the query.
+ $query = $this->buildQuery($ids, $conditions, $revision_id);
+ $result = $query->execute();
+ if (!empty($this->entityInfo['entity class'])) {
+ $result->setFetchMode(PDO::FETCH_CLASS, $this->entityInfo['entity class'], array(array(), $this->entityType));
+ }
+ return $result;
+ }
+
+ /**
+ * Overridden. In contrast to the parent implementation we factor out query execution, so fetching can be further customized easily.
+ *
+ * @see BackdropDefaultEntityController::load()
+ */
+ public function load($ids = array(), $conditions = array()) {
+ $entities = array();
+
+ // Revisions are not statically cached, and require a different query to
+ // other conditions, so separate the revision id into its own variable.
+ if ($this->revisionKey && isset($conditions[$this->revisionKey])) {
+ $revision_id = $conditions[$this->revisionKey];
+ unset($conditions[$this->revisionKey]);
+ }
+ else {
+ $revision_id = FALSE;
+ }
+
+ // Create a new variable which is either a prepared version of the $ids
+ // array for later comparison with the entity cache, or FALSE if no $ids
+ // were passed. The $ids array is reduced as items are loaded from cache,
+ // and we need to know if it's empty for this reason to avoid querying the
+ // database when all requested entities are loaded from cache.
+ $passed_ids = !empty($ids) ? array_flip($ids) : FALSE;
+
+ // Try to load entities from the static cache.
+ if ($this->staticCache && !$revision_id) {
+ $entities = $this->cacheGet($ids, $conditions);
+ // If any entities were loaded, remove them from the ids still to load.
+ if ($passed_ids) {
+ $ids = array_keys(array_diff_key($passed_ids, $entities));
+ }
+ }
+
+ // Try to load entities from the persistent cache.
+ if ($this->persistentCache && !$revision_id && $ids && !$conditions) {
+ $cached_entities = array();
+ if ($ids && !$conditions) {
+ $cached = cache_get_multiple($ids, 'cache_entity_' . $this->entityType);
+
+ if ($cached) {
+ foreach ($cached as $item) {
+ $cached_entities[$item->cid] = $item->data;
+ }
+ }
+ }
+ $entities += $cached_entities;
+
+ // If any entities were loaded, remove them from the ids still to load.
+ $ids = array_diff($ids, array_keys($cached_entities));
+
+ if ($this->staticCache) {
+ // Add entities to the cache if we are not loading a revision.
+ if (!empty($cached_entities) && !$revision_id) {
+ $this->cacheSet($cached_entities);
+ }
+ }
+ }
+
+ // Load any remaining entities from the database. This is the case if $ids
+ // is set to FALSE (so we load all entities), if there are any ids left to
+ // load or if loading a revision.
+ if (!($this->cacheComplete && $ids === FALSE && !$conditions) && ($ids === FALSE || $ids || $revision_id)) {
+ $queried_entities = array();
+ foreach ($this->query($ids, $conditions, $revision_id) as $record) {
+ // Skip entities already retrieved from cache.
+ if (isset($entities[$record->{$this->idKey}])) {
+ continue;
+ }
+
+ // For DB-based entities take care of serialized columns.
+ if (!empty($this->entityInfo['base table'])) {
+ $schema = backdrop_get_schema($this->entityInfo['base table']);
+
+ foreach ($schema['fields'] as $field => $info) {
+ if (!empty($info['serialize']) && isset($record->$field)) {
+ $record->$field = unserialize($record->$field);
+ // Support automatic merging of 'data' fields into the entity.
+ if (!empty($info['merge']) && is_array($record->$field)) {
+ foreach ($record->$field as $key => $value) {
+ $record->$key = $value;
+ }
+ unset($record->$field);
+ }
+ }
+ }
+ }
+
+ $queried_entities[$record->{$this->idKey}] = $record;
+ }
+ }
+
+ // Pass all entities loaded from the database through $this->attachLoad(),
+ // which attaches fields (if supported by the entity type) and calls the
+ // entity type specific load callback, for example hook_node_load().
+ if (!empty($queried_entities)) {
+ $this->attachLoad($queried_entities, $revision_id);
+ $entities += $queried_entities;
+ }
+
+ // Add entities to the entity cache if we are not loading a revision.
+ if ($this->persistentCache && !empty($queried_entities) && !$revision_id) {
+ // Only cache the entities which were loaded by ID. Entities that were
+ // loaded based on conditions will never be found via cacheGet() and we
+ // would keep on caching them.
+ if ($passed_ids) {
+ $queried_entities_by_id = array_intersect_key($queried_entities, $passed_ids);
+ if (!empty($queried_entities_by_id)) {
+ foreach ($queried_entities_by_id as $id => $item) {
+ cache_set($id, $item, 'cache_entity_' . $this->entityType);
+ }
+ }
+ }
+ }
+
+ if ($this->staticCache) {
+ // Add entities to the cache if we are not loading a revision.
+ if (!empty($queried_entities) && !$revision_id) {
+ $this->cacheSet($queried_entities);
+
+ // Remember if we have cached all entities now.
+ if (!$conditions && $ids === FALSE) {
+ $this->cacheComplete = TRUE;
+ }
+ }
+ }
+ // Ensure that the returned array is ordered the same as the original
+ // $ids array if this was passed in and remove any invalid ids.
+ if ($passed_ids && $passed_ids = array_intersect_key($passed_ids, $entities)) {
+ foreach ($passed_ids as $id => $value) {
+ $passed_ids[$id] = $entities[$id];
+ }
+ $entities = $passed_ids;
+ }
+ return $entities;
+ }
+
+ /**
+ * Overrides BackdropDefaultEntityController::resetCache().
+ */
+ public function resetCache(?array $ids = NULL) {
+ $this->cacheComplete = FALSE;
+ parent::resetCache($ids);
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ */
+ public function invoke($hook, $entity) {
+ // entity_plus_revision_delete() invokes hook_entity_plus_revision_delete() and
+ // hook_field_attach_delete_revision() just as node module does. So we need
+ // to adjust the name of our revision deletion field attach hook in order to
+ // stick to this pattern.
+ $field_attach_hook = ($hook == 'revision_delete' ? 'delete_revision' : $hook);
+ if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $field_attach_hook)) {
+ $function($this->entityType, $entity);
+ }
+
+ if (!empty($this->entityInfo['bundle of']) && entity_plus_type_is_fieldable($this->entityInfo['bundle of'])) {
+ $type = $this->entityInfo['bundle of'];
+ // Call field API bundle attachers for the entity we are a bundle of.
+ if ($hook == 'insert') {
+ field_attach_create_bundle($type, $entity->{$this->bundleKey});
+ }
+ elseif ($hook == 'delete') {
+ field_attach_delete_bundle($type, $entity->{$this->bundleKey});
+ }
+ elseif ($hook == 'update' && $entity->original->{$this->bundleKey} != $entity->{$this->bundleKey}) {
+ field_attach_rename_bundle($type, $entity->original->{$this->bundleKey}, $entity->{$this->bundleKey});
+ }
+ }
+ // Invoke the hook.
+ module_invoke_all($this->entityType . '_' . $hook, $entity);
+ // Invoke the respective entity level hook.
+ if ($hook == 'presave' || $hook == 'insert' || $hook == 'update' || $hook == 'delete') {
+ if (is_a($entity, 'Entity')) {
+ // Invoke all regular entity hooks as well as entity_plus hooks.
+ // See https://github.com/backdrop-contrib/entity_plus/issues/35
+ module_invoke_all('entity_' . $hook, $entity, $this->entityType);
+ module_invoke_all('entity_plus_' . $hook, $entity, $this->entityType);
+ }
+ }
+ // Invoke rules.
+ if (module_exists('rules')) {
+ rules_invoke_event($this->entityType . '_' . $hook, $entity);
+ }
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ *
+ * @param object $transaction
+ * Optionally a DatabaseTransaction object to use. Allows overrides to pass
+ * in their transaction object.
+ */
+ public function delete($ids, ?DatabaseTransaction $transaction = NULL) {
+ $entities = $ids ? $this->load($ids) : FALSE;
+ if (!$entities) {
+ // Do nothing, in case invalid or no ids have been passed.
+ return;
+ }
+ $transaction = isset($transaction) ? $transaction : db_transaction();
+
+ try {
+ $ids = array_keys($entities);
+
+ db_delete($this->entityInfo['base table'])
+ ->condition($this->idKey, $ids, 'IN')
+ ->execute();
+
+ if (isset($this->revisionTable)) {
+ db_delete($this->revisionTable)
+ ->condition($this->idKey, $ids, 'IN')
+ ->execute();
+ }
+ // Reset the cache as soon as the changes have been applied.
+ $this->resetCache($ids);
+
+ foreach ($entities as $id => $entity) {
+ $this->invoke('delete', $entity);
+ }
+ // Ignore slave server temporarily.
+ db_ignore_slave();
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception($this->entityType, $e);
+ throw $e;
+ }
+ }
+
+ /**
+ * Implements EntityPlusControllerRevisionableInterface::deleteRevision().
+ */
+ public function deleteRevision($revision_id) {
+ if ($entity_plus_revision = entity_plus_revision_load($this->entityType, $revision_id)) {
+ // Prevent deleting the default revision.
+ if (entity_plus_revision_is_default($this->entityType, $entity_plus_revision)) {
+ return FALSE;
+ }
+
+ db_delete($this->revisionTable)
+ ->condition($this->revisionKey, $revision_id)
+ ->execute();
+
+ $this->invoke('revision_delete', $entity_plus_revision);
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasStatus($status) {
+ if (!empty($this->entityInfo['exportable'])) {
+ return isset($this->{$this->statusKey}) && ($this->{$this->statusKey} & $status) == $status;
+ }
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ *
+ * @param object $transaction
+ * Optionally a DatabaseTransaction object to use. Allows overrides to pass
+ * in their transaction object.
+ */
+ public function save($entity, ?DatabaseTransaction $transaction = NULL) {
+ $transaction = isset($transaction) ? $transaction : db_transaction();
+ try {
+ // Load the stored entity, if any.
+ if (!empty($entity->{$this->idKey}) && !isset($entity->original)) {
+ // In order to properly work in case of name changes, load the original
+ // entity using the id key if it is available.
+ $entity->original = entity_load_unchanged($this->entityType, $entity->{$this->idKey});
+ }
+ $entity->is_new = !empty($entity->is_new) || empty($entity->{$this->idKey});
+ $this->invoke('presave', $entity);
+
+ if ($entity->is_new) {
+ $return = backdrop_write_record($this->entityInfo['base table'], $entity);
+ if ($this->revisionKey) {
+ $this->saveRevision($entity);
+ }
+ $this->invoke('insert', $entity);
+ }
+ else {
+ // Update the base table if the entity doesn't have revisions or
+ // we are updating the default revision.
+ if (!$this->revisionKey || !empty($entity->{$this->defaultRevisionKey})) {
+ $return = backdrop_write_record($this->entityInfo['base table'], $entity, $this->idKey);
+ }
+ if ($this->revisionKey) {
+ $return = $this->saveRevision($entity);
+ }
+ $this->resetCache(array($entity->{$this->idKey}));
+ $this->invoke('update', $entity);
+
+ // Field API always saves as default revision, so if the revision saved
+ // is not default we have to restore the field values of the default
+ // revision now by invoking field_attach_update() once again.
+ if ($this->revisionKey && !$entity->{$this->defaultRevisionKey} && !empty($this->entityInfo['fieldable'])) {
+ field_attach_update($this->entityType, $entity->original);
+ }
+ }
+
+ // Ignore slave server temporarily.
+ db_ignore_slave();
+ unset($entity->is_new);
+ unset($entity->is_new_revision);
+ unset($entity->original);
+
+ return $return;
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception($this->entityType, $e);
+ throw $e;
+ }
+ }
+
+ /**
+ * Saves an entity revision.
+ *
+ * @param Entity $entity
+ * Entity revision to save.
+ */
+ protected function saveRevision($entity) {
+ // Convert the entity into an array as it might not have the same properties
+ // as the entity, it is just a raw structure.
+ $record = (array) $entity;
+ // File fields assumes we are using $entity->revision instead of
+ // $entity->is_new_revision, so we also support it and make sure it's set to
+ // the same value.
+ $entity->is_new_revision = !empty($entity->is_new_revision) || !empty($entity->revision) || $entity->is_new;
+ $entity->revision = &$entity->is_new_revision;
+ $entity->{$this->defaultRevisionKey} = !empty($entity->{$this->defaultRevisionKey}) || $entity->is_new;
+
+ // When saving a new revision, set any existing revision ID to NULL so as to
+ // ensure that a new revision will actually be created.
+ if ($entity->is_new_revision && isset($record[$this->revisionKey])) {
+ $record[$this->revisionKey] = NULL;
+ }
+
+ if ($entity->is_new_revision) {
+ backdrop_write_record($this->revisionTable, $record);
+ $update_default_revision = $entity->{$this->defaultRevisionKey};
+ }
+ else {
+ backdrop_write_record($this->revisionTable, $record, $this->revisionKey);
+ // @todo: Fix original entity to be of the same revision and check whether
+ // the default revision key has been set.
+ $update_default_revision = $entity->{$this->defaultRevisionKey} && $entity->{$this->revisionKey} != $entity->original->{$this->revisionKey};
+ }
+ // Make sure to update the new revision key for the entity.
+ $entity->{$this->revisionKey} = $record[$this->revisionKey];
+
+ // Mark this revision as the default one.
+ if ($update_default_revision) {
+ db_update($this->entityInfo['base table'])
+ ->fields(array($this->revisionKey => $record[$this->revisionKey]))
+ ->condition($this->idKey, $entity->{$this->idKey})
+ ->execute();
+ }
+ return $entity->is_new_revision ? SAVED_NEW : SAVED_UPDATED;
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ */
+ public function create(array $values = array()) {
+ // Add is_new property if it is not set.
+ $values += array('is_new' => TRUE);
+ if (isset($this->entityInfo['entity class']) && $class = $this->entityInfo['entity class']) {
+ return new $class($values, $this->entityType);
+ }
+ return (object) $values;
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ *
+ * @return string
+ * A serialized string in JSON format suitable for the import() method.
+ */
+ public function export($entity, $prefix = '') {
+ $vars = get_object_vars($entity);
+ unset($vars['is_new']);
+ return entity_plus_var_json_export($vars, $prefix);
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ *
+ * @param string $export
+ * A serialized string in JSON format as produced by the export() method.
+ */
+ public function import($export) {
+ $vars = backdrop_json_decode($export);
+ if (is_array($vars)) {
+ return $this->create($vars);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ *
+ * @param array $content
+ * Optionally. Allows pre-populating the built content to ease overridding
+ * this method.
+ */
+ public function buildContent(EntityInterface $entity, $view_mode = 'full', $langcode = NULL, $content = array()) {
+ // Remove previously built content, if exists.
+ $entity->content = $content;
+ $langcode = isset($langcode) ? $langcode : $GLOBALS['language_content']->langcode;
+
+ // Allow modules to change the view mode.
+ $context = array(
+ 'entity_type' => $this->entityType,
+ // Leaving for backwards compatibility (https://github.com/backdrop-contrib/entity_plus/issues/44).
+ 'entity_plus_type' => $this->entityType,
+ 'entity' => $entity,
+ 'langcode' => $langcode,
+ );
+ backdrop_alter('entity_plus_view_mode', $view_mode, $context);
+ // Make sure the used view-mode gets stored.
+ $entity->content += array('#view_mode' => $view_mode);
+
+ // By default add in properties for all defined extra fields.
+ if ($extra_field_controller = entity_plus_get_extra_fields_controller($this->entityType)) {
+ $wrapper = entity_metadata_wrapper($this->entityType, $entity);
+ $extra = $extra_field_controller->fieldExtraFields();
+ $type_extra = &$extra[$this->entityType][$this->entityType]['display'];
+ $bundle_extra = &$extra[$this->entityType][$wrapper->getBundle()]['display'];
+
+ foreach ($wrapper as $name => $property) {
+ if (isset($type_extra[$name]) || isset($bundle_extra[$name])) {
+ $this->renderEntityProperty($wrapper, $name, $property, $view_mode, $langcode, $entity->content);
+ }
+ }
+ }
+
+ // Add in fields.
+ if (!empty($this->entityInfo['fieldable'])) {
+ // Perform the preparation tasks if they have not been performed yet.
+ // An internal flag prevents the operation from running twice.
+ $key = isset($entity->{$this->idKey}) ? $entity->{$this->idKey} : NULL;
+ field_attach_prepare_view($this->entityType, array($key => $entity), $view_mode);
+ $entity->content += field_attach_view($this->entityType, $entity, $view_mode, $langcode);
+ }
+ // Invoke hook_ENTITY_PLUS_view() to allow modules to add their additions.
+ if (module_exists('rules')) {
+ rules_invoke_all($this->entityType . '_view', $entity, $view_mode, $langcode);
+ }
+ else {
+ module_invoke_all($this->entityType . '_view', $entity, $view_mode, $langcode);
+ }
+ module_invoke_all('entity_plus_view', $entity, $this->entityType, $view_mode, $langcode);
+ $build = $entity->content;
+ unset($entity->content);
+ return $build;
+ }
+
+ /**
+ * Creates a render array for a single entity property.
+ */
+ protected function renderEntityProperty($wrapper, $name, $property, $view_mode, $langcode, &$content) {
+ $info = $property->info();
+
+ $content[$name] = array(
+ // Options are 'above', 'inline', 'hidden'.
+ '#label_display' => 'above',
+ '#label' => $info['label'],
+ '#entity_plus_wrapped' => $wrapper,
+ '#theme' => 'entity_plus_property',
+ '#property_name' => $name,
+ '#access' => $property->access('view'),
+ '#entity_type' => $this->entityType,
+ // Hide display for empty properties.
+ '#access' => !(empty($property->value())),
+ );
+ $content['#attached']['css']['entity_plus.theme'] = backdrop_get_path('module', 'entity_plus') . '/theme/entity_plus.theme.css';
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ */
+ public function view($entities, $view_mode = 'full', $langcode = NULL, $page = NULL) {
+ // For Field API and entity_plus_prepare_view, the entities have to be keyed by
+ // (numeric) id.
+ $entities = entity_plus_key_array_by_property($entities, $this->idKey);
+ if (!empty($this->entityInfo['fieldable'])) {
+ field_attach_prepare_view($this->entityType, $entities, $view_mode);
+ }
+ entity_prepare_view($this->entityType, $entities);
+ $langcode = isset($langcode) ? $langcode : $GLOBALS['language_content']->langcode;
+
+ $view = array();
+ foreach ($entities as $entity) {
+ $build = entity_plus_build_content($this->entityType, $entity, $view_mode, $langcode);
+ $build += array(
+ // If the entity type provides an implementation, use this instead of
+ // the generic one.
+ // @see template_preprocess_entity()
+ '#theme' => 'entity_plus',
+ '#entity' => $entity,
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ '#page' => $page,
+ '#entity_type' => $this->entityType,
+ // Include entity_plus_type for backwards compatibility.
+ // (See https://github.com/backdrop-contrib/entity_plus/issues/44).
+ '#entity_plus_type' => $this->entityType,
+ );
+ // Allow modules to modify the structured entity.
+ backdrop_alter(array($this->entityType . '_view', 'entity_plus_view'), $build, $this->entityType);
+ $key = isset($entity->{$this->idKey}) ? $entity->{$this->idKey} : NULL;
+ $view[$this->entityType][$key] = $build;
+ }
+ return $view;
+ }
+}
+
+/**
+ * A controller implementing exportables stored in the database.
+ */
+class EntityPlusControllerExportable extends EntityPlusController {
+
+ protected $entityCacheByName = array();
+ protected $nameKey;
+ protected $statusKey;
+ protected $moduleKey;
+
+ /**
+ * Overridden.
+ *
+ * Allows specifying a name key serving as uniform identifier for this entity
+ * type while still internally we are using numeric identifieres.
+ */
+ public function __construct($entity_type) {
+ parent::__construct($entity_type);
+ // Use the name key as primary identifier.
+ $this->nameKey = isset($this->entityInfo['entity keys']['name']) ? $this->entityInfo['entity keys']['name'] : $this->idKey;
+ if (!empty($this->entityInfo['exportable'])) {
+ $this->statusKey = isset($this->entityInfo['entity keys']['status']) ? $this->entityInfo['entity keys']['status'] : 'status';
+ $this->moduleKey = isset($this->entityInfo['entity keys']['module']) ? $this->entityInfo['entity keys']['module'] : 'module';
+ }
+ }
+
+ /**
+ * Support loading by name key.
+ */
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ // Add the id condition ourself, as we might have a separate name key.
+ $query = parent::buildQuery(array(), $conditions, $revision_id);
+ if ($ids) {
+ // Support loading by numeric ids as well as by machine names.
+ $key = is_numeric(reset($ids)) ? $this->idKey : $this->nameKey;
+ $query->condition("base.$key", $ids, 'IN');
+ }
+ return $query;
+ }
+
+ /**
+ * Overridden to support passing numeric ids as well as names as $ids.
+ */
+ public function load($ids = array(), $conditions = array()) {
+ $entities = array();
+
+ // Only do something if loaded by names.
+ if (!$ids || $this->nameKey == $this->idKey || is_numeric(reset($ids))) {
+ return parent::load($ids, $conditions);
+ }
+
+ // Revisions are not statically cached, and require a different query to
+ // other conditions, so separate the revision id into its own variable.
+ if ($this->revisionKey && isset($conditions[$this->revisionKey])) {
+ $revision_id = $conditions[$this->revisionKey];
+ unset($conditions[$this->revisionKey]);
+ }
+ else {
+ $revision_id = FALSE;
+ }
+ $passed_ids = !empty($ids) ? array_flip($ids) : FALSE;
+
+ // Care about the static cache.
+ if ($this->staticCache && !$revision_id) {
+ $entities = $this->cacheGetByName($ids, $conditions);
+ }
+ // If any entities were loaded, remove them from the ids still to load.
+ if ($entities) {
+ $ids = array_keys(array_diff_key($passed_ids, $entities));
+ }
+
+ $entities_by_id = parent::load($ids, $conditions);
+ $entities += entity_plus_key_array_by_property($entities_by_id, $this->nameKey);
+
+ // Ensure that the returned array is keyed by numeric id and ordered the
+ // same as the original $ids array and remove any invalid ids.
+ $return = array();
+ foreach ($passed_ids as $name => $value) {
+ if (isset($entities[$name])) {
+ $return[$entities[$name]->{$this->idKey}] = $entities[$name];
+ }
+ }
+ return $return;
+ }
+
+ /**
+ * Overridden.
+ * @see BackdropDefaultEntityController::cacheGet()
+ */
+ protected function cacheGet($ids, $conditions = array()) {
+ if (!empty($this->entityCache) && $ids !== array()) {
+ $entities = $ids ? array_intersect_key($this->entityCache, array_flip($ids)) : $this->entityCache;
+ return $this->applyConditions($entities, $conditions);
+ }
+ return array();
+ }
+
+ /**
+ * Like cacheGet() but keyed by name.
+ */
+ protected function cacheGetByName($names, $conditions = array()) {
+ if (!empty($this->entityCacheByName) && $names !== array() && $names) {
+ // First get the entities by ids, then apply the conditions.
+ // Generally, we make use of $this->entityCache, but if we are loading by
+ // name, we have to use $this->entityCacheByName.
+ $entities = array_intersect_key($this->entityCacheByName, array_flip($names));
+ return $this->applyConditions($entities, $conditions);
+ }
+ return array();
+ }
+
+ /**
+ * Filters entities by property conditions.
+ */
+ protected function applyConditions($entities, $conditions = array()) {
+ if ($conditions) {
+ foreach ($entities as $key => $entity) {
+ $entity_plus_values = (array) $entity;
+ // We cannot use array_diff_assoc() here because condition values can
+ // also be arrays, e.g. '$conditions = array('status' => array(1, 2))'
+ foreach ($conditions as $condition_key => $condition_value) {
+ if (is_array($condition_value)) {
+ if (!isset($entity_plus_values[$condition_key]) || !in_array($entity_plus_values[$condition_key], $condition_value)) {
+ unset($entities[$key]);
+ }
+ }
+ elseif (!isset($entity_plus_values[$condition_key]) || $entity_plus_values[$condition_key] != $condition_value) {
+ unset($entities[$key]);
+ }
+ }
+ }
+ }
+ return $entities;
+ }
+
+ /**
+ * Overridden.
+ * @see BackdropDefaultEntityController::cacheSet()
+ */
+ protected function cacheSet($entities) {
+ $this->entityCache += $entities;
+ // If we have a name key, also support static caching when loading by name.
+ if ($this->nameKey != $this->idKey) {
+ $this->entityCacheByName += entity_plus_key_array_by_property($entities, $this->nameKey);
+ }
+ }
+
+ /**
+ * Overridden. Changed to call type-specific hook with the entities keyed by name if they have one.
+ *
+ * @see BackdropDefaultEntityController::attachLoad()
+ */
+ protected function attachLoad(&$queried_entities, $revision_id = FALSE) {
+ // Attach fields.
+ if ($this->entityInfo['fieldable']) {
+ if ($revision_id) {
+ field_attach_load_revision($this->entityType, $queried_entities);
+ }
+ else {
+ field_attach_load($this->entityType, $queried_entities);
+ }
+ }
+
+ // Call hook_entity_load().
+ foreach (module_implements('entity_load') as $module) {
+ $function = $module . '_entity_load';
+ $function($queried_entities, $this->entityType);
+ }
+ // Call hook_TYPE_load(). The first argument for hook_TYPE_load() are
+ // always the queried entities, followed by additional arguments set in
+ // $this->hookLoadArguments.
+ // For entities with a name key, pass the entities keyed by name to the
+ // specific load hook.
+ if ($this->nameKey != $this->idKey) {
+ $entities_by_name = entity_plus_key_array_by_property($queried_entities, $this->nameKey);
+ }
+ else {
+ $entities_by_name = $queried_entities;
+ }
+ $args = array_merge(array($entities_by_name), $this->hookLoadArguments);
+ foreach (module_implements($this->entityInfo['load hook']) as $module) {
+ call_user_func_array($module . '_' . $this->entityInfo['load hook'], $args);
+ }
+ }
+
+ /**
+ * Resets the cache.
+ */
+ public function resetCache(?array $ids = NULL) {
+ $this->cacheComplete = FALSE;
+ if (isset($ids)) {
+ foreach (array_intersect_key($this->entityCache, array_flip($ids)) as $id => $entity) {
+ unset($this->entityCacheByName[$this->entityCache[$id]->{$this->nameKey}]);
+ unset($this->entityCache[$id]);
+ }
+ }
+ else {
+ $this->entityCache = array();
+ $this->entityCacheByName = array();
+ }
+ }
+
+ /**
+ * Overridden to care about reverted entities.
+ */
+ public function delete($ids, ?DatabaseTransaction $transaction = NULL) {
+ $entities = $ids ? $this->load($ids) : FALSE;
+ if ($entities) {
+ parent::delete($ids, $transaction);
+
+ foreach ($entities as $id => $entity) {
+ if (entity_plus_has_status($this->entityType, $entity, ENTITY_PLUS_IN_CODE)) {
+ entity_plus_defaults_rebuild(array($this->entityType));
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Overridden to care about reverted bundle entities and to skip Rules.
+ */
+ public function invoke($hook, $entity) {
+ if ($hook == 'delete') {
+ // To ease figuring out whether this is a revert, make sure that the
+ // entity status is updated in case the providing module has been
+ // disabled.
+ if (entity_plus_has_status($this->entityType, $entity, ENTITY_PLUS_IN_CODE) && !module_exists($entity->{$this->moduleKey})) {
+ $entity->{$this->statusKey} = ENTITY_PLUS_CUSTOM;
+ }
+ $is_revert = entity_plus_has_status($this->entityType, $entity, ENTITY_PLUS_IN_CODE);
+ }
+
+ if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $hook)) {
+ $function($this->entityType, $entity);
+ }
+
+ if (isset($this->entityInfo['bundle of']) && $type = $this->entityInfo['bundle of']) {
+ // Call field API bundle attachers for the entity we are a bundle of.
+ if ($hook == 'insert') {
+ field_attach_create_bundle($type, $entity->{$this->bundleKey});
+ }
+ elseif ($hook == 'delete' && !$is_revert) {
+ field_attach_delete_bundle($type, $entity->{$this->bundleKey});
+ }
+ elseif ($hook == 'update' && $id = $entity->{$this->nameKey}) {
+ if ($entity->original->{$this->bundleKey} != $entity->{$this->bundleKey}) {
+ field_attach_rename_bundle($type, $entity->original->{$this->bundleKey}, $entity->{$this->bundleKey});
+ }
+ }
+ }
+ // Invoke the hook.
+ module_invoke_all($this->entityType . '_' . $hook, $entity);
+ // Invoke the respective entity level hook.
+ if ($hook == 'presave' || $hook == 'insert' || $hook == 'update' || $hook == 'delete') {
+ if (is_a($entity, 'Entity')) {
+ module_invoke_all('entity_plus_' . $hook, $entity, $this->entityType);
+ }
+ }
+ }
+
+ /**
+ * Overridden to care exportables that are overridden.
+ */
+ public function save($entity, ?DatabaseTransaction $transaction = NULL) {
+ // Preload $entity->original by name key if necessary.
+ if (!empty($entity->{$this->nameKey}) && empty($entity->{$this->idKey}) && !isset($entity->original)) {
+ $entity->original = entity_load_unchanged($this->entityType, $entity->{$this->nameKey});
+ }
+ // Update the status for entities getting overridden.
+ if (entity_plus_has_status($this->entityType, $entity, ENTITY_PLUS_IN_CODE) && empty($entity->is_rebuild)) {
+ $entity->{$this->statusKey} |= ENTITY_PLUS_CUSTOM;
+ }
+ return parent::save($entity, $transaction);
+ }
+
+ /**
+ * Overridden.
+ */
+ public function export($entity, $prefix = '') {
+ $vars = get_object_vars($entity);
+ unset($vars[$this->statusKey], $vars[$this->moduleKey], $vars['is_new']);
+ if ($this->nameKey != $this->idKey) {
+ unset($vars[$this->idKey]);
+ }
+ return entity_plus_var_json_export($vars, $prefix);
+ }
+
+ /**
+ * Implements EntityPlusControllerInterface.
+ */
+ public function view($entities, $view_mode = 'full', $langcode = NULL, $page = NULL) {
+ $view = parent::view($entities, $view_mode, $langcode, $page);
+
+ if ($this->nameKey != $this->idKey) {
+ // Re-key the view array to be keyed by name.
+ $return = array();
+ foreach ($view[$this->entityType] as $id => $content) {
+ $key = isset($content['#entity']->{$this->nameKey}) ? $content['#entity']->{$this->nameKey} : NULL;
+ $return[$this->entityType][$key] = $content;
+ }
+ $view = $return;
+ }
+ return $view;
+ }
+}
diff --git a/www/modules/contrib/entity_plus/includes/entity_plus.property.inc b/www/modules/contrib/entity_plus/includes/entity_plus.property.inc
new file mode 100644
index 000000000..adfd57681
--- /dev/null
+++ b/www/modules/contrib/entity_plus/includes/entity_plus.property.inc
@@ -0,0 +1,706 @@
+langcode;
+
+ if (empty($info)) {
+ if ($cache = cache_get("entity_property_info:$langcode")) {
+ $info = $cache->data;
+ }
+ else {
+ $info = module_invoke_all('entity_property_info');
+ // Let other modules alter the entity info.
+ backdrop_alter('entity_property_info', $info);
+ cache_set("entity_property_info:$langcode", $info);
+ }
+ }
+
+ return empty($entity_type) ? $info : (isset($info[$entity_type]) ? $info[$entity_type] : array());
+}
+
+/**
+ * Returns the default information for an entity property.
+ *
+ * @return array
+ * An array of optional property information keys mapped to their defaults.
+ *
+ * @see hook_entity_property_info()
+ */
+function entity_plus_property_info_defaults() {
+ return array(
+ 'type' => 'text',
+ 'getter callback' => 'entity_plus_property_verbatim_get',
+ );
+}
+
+/**
+ * Gets an array of info about all properties of a given entity type.
+ *
+ * In contrast to entity_plus_get_property_info(), this function returns info about
+ * all properties the entity might have, thus it adds an all properties assigned
+ * to entity bundles.
+ *
+ * @param string $entity_type
+ * (optiona) The entity type to return properties for.
+ *
+ * @return array
+ * An array of info about properties. If the type is ommitted, all known
+ * properties are returned.
+ */
+function entity_plus_get_all_property_info($entity_type = NULL) {
+ if (!isset($entity_type)) {
+ // Retrieve all known properties.
+ $properties = array();
+ foreach (entity_get_info() as $entity_type => $info) {
+ $properties += entity_plus_get_all_property_info($entity_type);
+ }
+ return $properties;
+ }
+ // Else retrieve the properties of the given entity type only.
+ $info = entity_plus_get_property_info($entity_type);
+ $info += array('properties' => array(), 'bundles' => array());
+ // Add all bundle properties.
+ foreach ($info['bundles'] as $bundle => $bundle_info) {
+ $bundle_info += array('properties' => array());
+ $info['properties'] += $bundle_info['properties'];
+ }
+ return $info['properties'];
+}
+
+/**
+ * Queries for entities having the given property value.
+ *
+ * @param string $entity_type
+ * The type of the entity.
+ * @param string $property
+ * The name of the property to query for.
+ * @param mixed $value
+ * A single property value or an array of possible values to query for.
+ * @param int $limit
+ * Limit the numer of results. Defaults to 30.
+ *
+ * @return array
+ * An array of entity ids or NULL if there is no information how to query for
+ * the given property.
+ */
+function entity_plus_property_query($entity_type, $property, $value, $limit = 30) {
+ $properties = entity_plus_get_all_property_info($entity_type);
+ $info = $properties[$property] + array('type' => 'text', 'queryable' => !empty($properties[$property]['schema field']));
+
+ // We still support the deprecated query callback, so just add in EFQ-based
+ // callbacks in case 'queryable' is set to TRUE and make use of the callback.
+ if ($info['queryable'] && empty($info['query callback'])) {
+ $info['query callback'] = !empty($info['field']) ? 'entity_plus_metadata_field_query' : 'entity_plus_metadata_table_query';
+ }
+
+ $type = $info['type'];
+ // Make sure an entity or a list of entities are passed on as identifiers
+ // with the help of the wrappers. For that ensure the data type matches the
+ // passed on value(s).
+ if (is_array($value) && !entity_plus_property_list_extract_type($type)) {
+ $type = 'list<' . $type . '>';
+ }
+ elseif (!is_array($value) && entity_plus_property_list_extract_type($type)) {
+ $type = entity_plus_property_list_extract_type($type);
+ }
+
+ $wrapper = entity_metadata_wrapper($type, $value);
+ $value = $wrapper->value(array('identifier' => TRUE));
+
+ if (!empty($info['query callback'])) {
+ return $info['query callback']($entity_type, $property, $value, $limit);
+ }
+}
+
+/**
+ * Resets the cached information of hook_entity_property_info().
+ */
+function entity_property_info_cache_clear() {
+ backdrop_static_reset('entity_plus_get_property_info');
+ // Clear all languages.
+ cache_clear_all('entity_property_info:', 'cache', TRUE);
+}
+
+/**
+ * Implements hook_hook_info().
+ */
+function entity_plus_hook_info() {
+ $hook_info['entity_property_info'] = array(
+ 'group' => 'info',
+ );
+ $hook_info['entity_property_info_alter'] = array(
+ 'group' => 'info',
+ );
+ return $hook_info;
+}
+
+/**
+ * Implements hook_field_info_alter().
+ *
+ * Defines default property types for core field types.
+ */
+function entity_plus_field_info_alter(&$field_info) {
+ if (module_exists('number')) {
+ $field_info['number_integer']['property_type'] = 'integer';
+ $field_info['number_decimal']['property_type'] = 'decimal';
+ $field_info['number_float']['property_type'] = 'decimal';
+ }
+ if (module_exists('text')) {
+ $field_info['text']['property_type'] = 'text';
+ $field_info['text']['property_callbacks'][] = 'entity_plus_metadata_field_text_property_callback';
+ $field_info['text_long']['property_type'] = 'text';
+ $field_info['text_long']['property_callbacks'][] = 'entity_plus_metadata_field_text_property_callback';
+ $field_info['text_with_summary']['property_type'] = 'field_item_textsummary';
+ $field_info['text_with_summary']['property_callbacks'][] = 'entity_plus_metadata_field_text_property_callback';
+ }
+ if (module_exists('list')) {
+ $field_info['list_integer']['property_type'] = 'integer';
+ $field_info['list_boolean']['property_type'] = 'boolean';
+ $field_info['list_float']['property_type'] = 'decimal';
+ $field_info['list_text']['property_type'] = 'text';
+ }
+ if (module_exists('taxonomy')) {
+ $field_info['taxonomy_term_reference']['property_type'] = 'taxonomy_term';
+ $field_info['taxonomy_term_reference']['property_callbacks'][] = 'entity_plus_metadata_field_term_reference_callback';
+ }
+ if (module_exists('file')) {
+ // The callback specifies a custom data structure matching the file field
+ // items. We introduce a custom type name for this data structure.
+ $field_info['file']['property_type'] = 'field_item_file';
+ $field_info['file']['property_callbacks'][] = 'entity_plus_metadata_field_file_callback';
+ }
+ if (module_exists('image')) {
+ // The callback specifies a custom data structure matching the image field
+ // items. We introduce a custom type name for this data structure.
+ $field_info['image']['property_type'] = 'field_item_image';
+ $field_info['image']['property_callbacks'][] = 'entity_plus_metadata_field_file_callback';
+ $field_info['image']['property_callbacks'][] = 'entity_plus_metadata_field_image_callback';
+ }
+ if (module_exists('email')) {
+ $field_info['email']['property_type'] = 'text';
+ }
+ foreach (array('date', 'datetime', 'datestamp') as $date_type) {
+ if (isset($field_info[$date_type])) {
+ $field_info[$date_type]['property_type'] = 'date';
+ $field_info[$date_type]['property_callbacks'][] = 'entity_plus_metadata_field_date_callback';
+ }
+ }
+ if (module_exists('link')) {
+ $field_info['link_field']['property_type'] = 'field_item_link';
+ $field_info['link_field']['property_callbacks'][] = 'entity_plus_metadata_field_link_callback';
+ }
+}
+
+/**
+ * Implements hook_field_create_instance().
+ *
+ * Clear the cache when a field instance changed.
+ */
+function entity_plus_field_create_instance() {
+ entity_property_info_cache_clear();
+}
+
+/**
+ * Implements hook_field_delete_instance().
+ *
+ * Clear the cache when a field instance changed.
+ */
+function entity_plus_field_delete_instance() {
+ entity_property_info_cache_clear();
+}
+
+/**
+ * Implements hook_field_update_instance().
+ *
+ * Clear the cache when a field instance changed.
+ */
+function entity_plus_field_update_instance() {
+ entity_property_info_cache_clear();
+}
+
+/**
+ * Verifies that the given data can be safely used as the given type regardless of the PHP variable type of $data.
+ *
+ * Example: the string "15" is a valid integer, but "15nodes" is not.
+ *
+ * @return bool
+ * Whether the data is valid for the given type.
+ */
+function entity_plus_property_verify_data_type($data, $type) {
+ // As this may be called very often statically cache the entity info using
+ // the fast pattern.
+ static $backdrop_static_fast;
+ if (!isset($backdrop_static_fast)) {
+ // Make use of the same static as entity info.
+ entity_get_info();
+ $backdrop_static_fast['entity_plus_info'] = &backdrop_static('entity_get_info');
+ }
+ $info = &$backdrop_static_fast['entity_plus_info'];
+
+ // First off check for entities, which may be represented by their ids too.
+ if (isset($info[$type])) {
+ if (is_object($data)) {
+ return TRUE;
+ }
+ elseif (isset($info[$type]['entity keys']['name'])) {
+ // Read the data type of the name key from the metadata if available.
+ $key = $info[$type]['entity keys']['name'];
+ $property_info = entity_plus_get_property_info($type);
+ $property_type = isset($property_info['properties'][$key]['type']) ? $property_info['properties'][$key]['type'] : 'token';
+ return entity_plus_property_verify_data_type($data, $property_type);
+ }
+ return entity_plus_property_verify_data_type($data, empty($info[$type]['fieldable']) ? 'text' : 'integer');
+ }
+
+ switch ($type) {
+ case 'site':
+ case 'unknown':
+ return TRUE;
+
+ case 'date':
+ case 'duration':
+ case 'integer':
+ return is_numeric($data) && strpos($data, '.') === FALSE;
+
+ case 'decimal':
+ return is_numeric($data);
+
+ case 'text':
+ return is_scalar($data);
+
+ case 'token':
+ return is_scalar($data) && preg_match('!^[a-z][a-z0-9_]*$!', $data);
+
+ case 'boolean':
+ return is_scalar($data) && (is_bool($data) || $data == 0 || $data == 1);
+
+ case 'uri':
+ return valid_url($data, TRUE);
+
+ case 'list':
+ return (is_array($data) && array_values($data) == $data) || (is_object($data) && $data instanceof EntityMetadataArrayObject);
+
+ case 'entity':
+ return is_object($data) && $data instanceof EntityBackdropWrapper;
+
+ case 'taxonomy_vocabulary':
+ return (is_object($data) && $data instanceof EntityVocabularyWrapper) || is_a($data, 'TaxonomyVocabulary') || is_scalar($data) && preg_match('!^[a-z][a-z0-9_]*$!', $data);
+
+ default:
+ case 'struct':
+ return is_object($data) || is_array($data);
+
+ }
+}
+
+/**
+ * Creates the entity object for an array of given property values.
+ *
+ * @param string $entity_type
+ * The entity type to create an entity for.
+ * @param array $values
+ * An array of values as described by the entity's property info. All entity
+ * properties of the given entity type that are marked as required, must be
+ * present.
+ * If the passed values have no matching property, their value will be
+ * assigned to the entity directly, without the use of the metadata-wrapper
+ * property.
+ *
+ * @return EntityBackdropWrapper
+ * An EntityBackdropWrapper wrapping the newly created entity or FALSE, if
+ * there were no information how to create the entity.
+ */
+function entity_plus_property_values_create_entity($entity_type, $values = array()) {
+ if (entity_plus_type_supports($entity_type, 'create')) {
+ $info = entity_get_info($entity_type);
+ // Create the initial entity by passing the values for all 'entity keys'
+ // to entity_create().
+ $entity_plus_keys = array_filter($info['entity keys']);
+ $creation_values = array_intersect_key($values, array_flip($entity_plus_keys));
+
+ // In case the bundle key does not match the property that sets it, ensure
+ // the bundle key is initialized somehow, so entity_extract_ids()
+ // does not bail out during wrapper creation.
+ if (!empty($info['entity keys']['bundle'])) {
+ $creation_values += array($info['entity keys']['bundle'] => FALSE);
+ }
+ $entity = entity_create($entity_type, $creation_values);
+
+ // Now set the remaining values using the wrapper.
+ $wrapper = entity_metadata_wrapper($entity_type, $entity);
+ foreach ($values as $key => $value) {
+ if (!in_array($key, $info['entity keys'])) {
+ if (isset($wrapper->$key)) {
+ $wrapper->$key->set($value);
+ }
+ else {
+ $entity->$key = $value;
+ }
+ }
+ }
+ // @todo: Verify the entity has
+ // now a valid bundle and throw the EntityMalformedException if not.
+ return $wrapper;
+ }
+ return FALSE;
+}
+
+
+/**
+ * Extracts the contained type for a list type string like list.
+ *
+ * @return string|bool
+ * The contained type or FALSE, if the given type string is no list.
+ */
+function entity_plus_property_list_extract_type($type) {
+ if (strpos($type, 'list<') === 0 && $type[strlen($type) - 1] == '>') {
+ return substr($type, 5, -1);
+ }
+ return FALSE;
+}
+
+/**
+ * Extracts the innermost type for a type string like list>.
+ *
+ * @param string $type
+ * The type to examine.
+ *
+ * @return string
+ * For list types, the innermost type. The type itself otherwise.
+ */
+function entity_plus_property_extract_innermost_type($type) {
+ while (strpos($type, 'list<') === 0 && $type[strlen($type) - 1] == '>') {
+ $type = substr($type, 5, -1);
+ }
+ return $type;
+}
+
+/**
+ * Gets the property just as it is set in the data.
+ */
+function entity_plus_property_verbatim_get($data, array $options, $name, $type, $info) {
+ $name = isset($info['schema field']) ? $info['schema field'] : $name;
+ if ((is_array($data) || (is_object($data) && $data instanceof ArrayAccess)) && isset($data[$name])) {
+ return $data[$name];
+ }
+ elseif (is_object($data) && isset($data->$name)) {
+ $value = $data->$name;
+ // Incorporate i18n_string translations. We may rely on the entity class
+ // here as its usage is required by the i18n integration.
+ if (isset($options['language']) && !empty($info['i18n string'])) {
+ if (method_exists($data, 'getTranslation')) {
+ $value = $data->getTranslation($name, $options['language']->langcode);
+ }
+ elseif ($data instanceof EntityInterface) {
+ $entity_info = entity_get_info($data->entityType());
+ // hook_entity_info() must include a 'module' key, as this is needed
+ // for string translation.
+ if (!empty($entity_info['module'])) {
+ $textgroup = $entity_info['module'] . ':' . $data->entityType() . ':' . $data->id() . ':' . $name;
+ $value = entity_plus_i18n_string($textgroup, $data->$name, $options['language']->langcode);
+ }
+ }
+ }
+ return $value;
+ }
+ return NULL;
+}
+
+/**
+ * Date values are converted from ISO strings to timestamp if needed.
+ */
+function entity_plus_property_verbatim_date_get($data, array $options, $name, $type, $info) {
+ $name = isset($info['schema field']) ? $info['schema field'] : $name;
+ if (is_array($data) || (is_object($data) && $data instanceof ArrayAccess)) {
+ return is_numeric($data[$name]) ? $data[$name] : strtotime($data[$name], REQUEST_TIME);
+ }
+ elseif (is_object($data)) {
+ return is_numeric($data->$name) ? $data->$name : strtotime($data->$name, REQUEST_TIME);
+ }
+}
+
+/**
+ * Sets the property to the given value. May be used as 'setter callback'.
+ */
+function entity_plus_property_verbatim_set(&$data, $name, $value, $langcode, $type, $info) {
+ $name = isset($info['schema field']) ? $info['schema field'] : $name;
+ if (is_array($data) || (is_object($data) && $data instanceof ArrayAccess)) {
+ $data[$name] = $value;
+ }
+ elseif (is_object($data)) {
+ $data->$name = $value;
+ }
+}
+
+/**
+ * Gets the property using the getter method (named just like the property).
+ */
+function entity_plus_property_getter_method($object, array $options, $name) {
+ // Remove any underscores as classes are expected to use CamelCase.
+ $method = strtr($name, array('_' => ''));
+ return $object->$method();
+}
+
+/**
+ * Sets the property to the given value using the setter method. May be used as 'setter callback'.
+ */
+function entity_plus_property_setter_method($object, $name, $value) {
+ // Remove any underscores as classes are expected to use CamelCase.
+ $method = 'set' . strtr($name, array('_' => ''));
+ // Invoke the setProperty() method where 'Property' is the property name.
+ $object->$method($value);
+}
+
+/**
+ * Getter callback for getting an array. Makes sure it's numerically indexed.
+ */
+function entity_plus_property_get_list($data, array $options, $name) {
+ return isset($data->$name) ? array_values($data->$name) : array();
+}
+
+/**
+ * A validation callback ensuring the passed integer is positive.
+ */
+function entity_plus_property_validate_integer_positive($value) {
+ return $value > 0;
+}
+
+/**
+ * A validation callback ensuring the passed integer is non-negative.
+ */
+function entity_plus_property_validate_integer_non_negative($value) {
+ return $value >= 0;
+}
+
+/**
+ * A simple auto-creation callback for array based data structures.
+ */
+function entity_plus_property_create_array($property_name, $context) {
+ return array();
+}
+
+/**
+ * Flattens the given options in single dimensional array.
+ *
+ * We don't depend on options module, so we cannot use options_array_flatten().
+ *
+ * @see options_array_flatten()
+ */
+function entity_plus_property_options_flatten($options) {
+ $result = array();
+ foreach ($options as $key => $value) {
+ if (is_array($value)) {
+ $result += $value;
+ }
+ else {
+ $result[$key] = $value;
+ }
+ }
+ return $result;
+}
+
+/**
+ * Defines info for the properties of the text_formatted data structure.
+ */
+function entity_plus_property_text_formatted_info() {
+ return array(
+ 'value' => array(
+ 'type' => 'text',
+ 'label' => t('Text'),
+ 'sanitized' => TRUE,
+ 'getter callback' => 'entity_plus_metadata_field_text_get',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'raw getter callback' => 'entity_plus_property_verbatim_get',
+ ),
+ 'summary' => array(
+ 'type' => 'text',
+ 'label' => t('Summary'),
+ 'sanitized' => TRUE,
+ 'getter callback' => 'entity_plus_metadata_field_text_get',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'raw getter callback' => 'entity_plus_property_verbatim_get',
+ ),
+ 'format' => array(
+ 'type' => 'token',
+ 'label' => t('Text format'),
+ 'options list' => 'entity_plus_metadata_field_text_formats',
+ 'getter callback' => 'entity_plus_property_verbatim_get',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permissions' => 'administer filters',
+ ),
+ );
+}
+
+/**
+ * Defines info for the properties of the field_item_textsummary data structure.
+ */
+function entity_plus_property_field_item_textsummary_info() {
+ return array(
+ 'value' => array(
+ 'type' => 'text',
+ 'label' => t('Text'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ ),
+ 'summary' => array(
+ 'type' => 'text',
+ 'label' => t('Summary'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ ),
+ );
+}
+
+/**
+ * Defines info for the properties of the file-field item data structure.
+ */
+function entity_plus_property_field_item_file_info() {
+ $properties['file'] = array(
+ 'type' => 'file',
+ 'label' => t('The file.'),
+ 'getter callback' => 'entity_plus_metadata_field_file_get',
+ 'setter callback' => 'entity_plus_metadata_field_file_set',
+ 'required' => TRUE,
+ );
+ $properties['description'] = array(
+ 'type' => 'text',
+ 'label' => t('The file description'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ );
+ $properties['display'] = array(
+ 'type' => 'boolean',
+ 'label' => t('Whether the file is being displayed.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ );
+ return $properties;
+}
+
+/**
+ * Defines info for the properties of the image-field item data structure.
+ */
+function entity_plus_property_field_item_image_info() {
+ $properties['file'] = array(
+ 'type' => 'file',
+ 'label' => t('The image file.'),
+ 'getter callback' => 'entity_plus_metadata_field_file_get',
+ 'setter callback' => 'entity_plus_metadata_field_file_set',
+ 'required' => TRUE,
+ );
+ $properties['alt'] = array(
+ 'type' => 'text',
+ 'label' => t('The "Alt" attribute text'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ );
+ $properties['title'] = array(
+ 'type' => 'text',
+ 'label' => t('The "Title" attribute text'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ );
+ return $properties;
+}
+
+
+/**
+ * Previously, hook_entity_property_info() has been provided by the removed
+ * entity metadata module. To provide backward compatibility for provided
+ * helpers that may be specified in hook_entity_property_info(), the following
+ * (deprecated) functions are provided.
+ */
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_verbatim_get($data, array $options, $name) {
+ return entity_plus_property_verbatim_get($data, $options, $name);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_verbatim_set($data, $name, $value) {
+ return entity_plus_property_verbatim_set($data, $name, $value);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_getter_method($object, array $options, $name) {
+ return entity_plus_property_getter_method($object, $options, $name);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_setter_method($object, $name, $value) {
+ entity_plus_property_setter_method($object, $name, $value);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_get_list($data, array $options, $name) {
+ return entity_plus_property_get_list($data, $options, $name);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_validate_integer_positive($value) {
+ return entity_plus_property_validate_integer_positive($value);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_validate_integer_non_negative($value) {
+ return entity_plus_property_validate_integer_non_negative($value);
+}
+
+/**
+ * Deprecated.
+ *
+ * Do not make use of this function, instead use the new one.
+ */
+function entity_plus_metadata_text_formatted_properties() {
+ return entity_plus_property_text_formatted_info();
+}
diff --git a/www/modules/contrib/entity_plus/includes/entity_plus.wrapper.inc b/www/modules/contrib/entity_plus/includes/entity_plus.wrapper.inc
new file mode 100644
index 000000000..0c68c1edd
--- /dev/null
+++ b/www/modules/contrib/entity_plus/includes/entity_plus.wrapper.inc
@@ -0,0 +1,1714 @@
+type = $type;
+ $this->info = $info + array(
+ 'langcode' => NULL,
+ );
+ $this->info['type'] = $type;
+ if (isset($data)) {
+ $this->set($data);
+ }
+ }
+
+ /**
+ * Gets info about the wrapped data.
+ *
+ * @return array
+ * Keys set are all keys as specified for a property in hook_entity_plus_info()
+ * as well as possible the following keys:
+ * - name: If this wraps a property, the name of the property.
+ * - parent: The parent wrapper, if any.
+ * - langcode: The language code, if this data is language specific.
+ */
+ public function info() {
+ return $this->info;
+ }
+
+ /**
+ * Gets the (entity)type of the wrapped data.
+ */
+ public function type() {
+ return $this->type;
+ }
+
+ /**
+ * Returns the wrapped data.
+ *
+ * If no options are given the data is returned as described in the info.
+ *
+ * @param array $options
+ * (optional) A keyed array of options:
+ * - sanitize: A boolean flag indicating that textual properties should be
+ * sanitized for display to a web browser. Defaults to FALSE.
+ * - decode: If set to TRUE and some textual data is already sanitized, it
+ * strips HTML tags and decodes HTML entities. Defaults to FALSE.
+ *
+ * @return object
+ * The value of the wrapped data. If the data property is not set, NULL
+ * is returned.
+ *
+ * @throws EntityMetadataWrapperException
+ * In case there are no data values available to the wrapper, an exception
+ * is thrown. E.g. if the value for an entity property is to be retrieved
+ * and there is no entity available, the exception is thrown. However, if
+ * an entity is available but the property is not set, NULL is returned.
+ */
+ public function value(array $options = array()) {
+ if (!$this->dataAvailable() && isset($this->info['parent'])) {
+ throw new EntityMetadataWrapperException('Missing data values.');
+ }
+ if (!isset($this->data) && isset($this->info['name'])) {
+ $this->data = $this->info['parent']->getPropertyValue($this->info['name'], $this->info);
+ }
+ return $this->data;
+ }
+
+ /**
+ * Returns the raw, unprocessed data.
+ *
+ * Most times this is the same as returned by value(), however for already processed and sanitized textual data, this will return the unprocessed data in contrast to value().
+ */
+ public function raw() {
+ if (!$this->dataAvailable()) {
+ throw new EntityMetadataWrapperException('Missing data values.');
+ }
+ if (isset($this->info['name']) && isset($this->info['parent'])) {
+ return $this->info['parent']->getPropertyRaw($this->info['name'], $this->info);
+ }
+ // Else return the usual value, which should be raw in this case.
+ return $this->value();
+ }
+
+ /**
+ * Returns whether data is available to work with.
+ *
+ * @return bool
+ * If we operate without any data FALSE, else TRUE.
+ */
+ protected function dataAvailable() {
+ return isset($this->data) || (isset($this->info['parent']) && $this->info['parent']->dataAvailable());
+ }
+
+ /**
+ * Set a new data value.
+ */
+ public function set($value) {
+ if (!$this->validate($value)) {
+ throw new EntityMetadataWrapperException('Invalid data value given. Be sure it matches the required data type and format.');
+ }
+ $this->clear();
+ $this->data = $value;
+ $this->updateParent($value);
+ return $this;
+ }
+
+ /**
+ * Updates the parent data structure of a data property with the latest data value.
+ */
+ protected function updateParent($value) {
+ if (isset($this->info['parent'])) {
+ $this->info['parent']->setProperty($this->info['name'], $value);
+ }
+ }
+
+ /**
+ * Returns whether $value is a valid value to set.
+ */
+ public function validate($value) {
+ if (isset($value) && !entity_plus_property_verify_data_type($value, $this->type)) {
+ return FALSE;
+ }
+ // Only proceed with further checks if this is not a list item. If this is
+ // a list item, the checks are performed on the list property level.
+ if (isset($this->info['parent']) && $this->info['parent'] instanceof EntityListWrapper) {
+ return TRUE;
+ }
+ if (!isset($value) && !empty($this->info['required'])) {
+ // Do not allow NULL values if the property is required.
+ return FALSE;
+ }
+ return !isset($this->info['validation callback']) || call_user_func($this->info['validation callback'], $value, $this->info);
+ }
+
+ /**
+ * Returns this property name or type as string.
+ */
+ public function __toString() {
+ return isset($this->info) ? 'Property ' . $this->info['name'] : $this->type;
+ }
+
+ /**
+ * Clears the data value and the wrapper cache.
+ */
+ protected function clear() {
+ $this->data = NULL;
+ foreach ($this->cache as $wrapper) {
+ $wrapper->clear();
+ }
+ }
+
+ /**
+ * Returns the options list specifying possible values for the property, if defined.
+ *
+ * @param string $op
+ * (optional) One of 'edit' or 'view'. In case the list of possible values
+ * a user could set for a property differs from the list of values a
+ * property could have, $op determines which options should be returned.
+ * Defaults to 'edit'.
+ * E.g. all possible roles a user could have include the anonymous and the
+ * authenticated user roles, while those roles cannot be added to a user
+ * account. So their options would be included for 'view', but for 'edit'
+ * not.
+ *
+ * @return array|bool
+ * An array as used by hook_options_list() or FALSE.
+ */
+ public function optionsList($op = 'edit') {
+ if (isset($this->info['options list']) && is_callable($this->info['options list'])) {
+ $name = isset($this->info['name']) ? $this->info['name'] : NULL;
+ return call_user_func($this->info['options list'], $name, $this->info, $op);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Returns the label for the currently set property value if there is one available, i.e. if an options list has been specified.
+ */
+ public function label() {
+ if ($options = $this->optionsList('view')) {
+ $options = entity_plus_property_options_flatten($options);
+ $value = $this->value();
+ if (is_scalar($value) && isset($options[$value])) {
+ return $options[$value];
+ }
+ }
+ }
+
+ /**
+ * Determines whether the given user has access to view or edit this property.
+ *
+ * Apart from relying on access metadata of properties, this takes into
+ * account information about entity level access, if available:
+ * - Referenced entities can only be viewed, when the user also has
+ * permission to view the entity.
+ * - A property may be only edited, if the user has permission to update the
+ * entity containing the property.
+ *
+ * @param string $op
+ * The operation being performed. One of 'view' or 'edit.
+ * @param object $account
+ * The user to check for. Leave it to NULL to check for the global user.
+ *
+ * @return bool
+ * Whether access to entity property is allowed for the given operation.
+ * However if we wrap no data, it returns whether access is allowed to the
+ * property of all entities of this type.
+ * If there is no access information for this property, TRUE is returned.
+ */
+ public function access($op, $account = NULL) {
+ return !empty($this->info['parent']) ? $this->info['parent']->propertyAccess($this->info['name'], $op, $account) : TRUE;
+ }
+
+ /**
+ * Prepare for serializiation.
+ */
+ public function __sleep() {
+ $vars = get_object_vars($this);
+ unset($vars['cache']);
+ return backdrop_map_assoc(array_keys($vars));
+ }
+}
+
+/**
+ * Wraps a single value.
+ */
+class EntityValueWrapper extends EntityMetadataWrapper {
+
+ /**
+ * Overrides EntityMetadataWrapper::value().
+ *
+ * Sanitizes or decode textual data if necessary.
+ */
+ public function value(array $options = array()) {
+ $data = parent::value();
+ if ($this->type == 'text' && isset($data)) {
+ $info = $this->info + array('sanitized' => FALSE, 'sanitize' => 'check_plain');
+ $options += array('sanitize' => FALSE, 'decode' => FALSE);
+ if ($options['sanitize'] && !$info['sanitized']) {
+ return call_user_func($info['sanitize'], $data);
+ }
+ elseif ($options['decode'] && $info['sanitized']) {
+ return decode_entities(strip_tags($data));
+ }
+ }
+ return $data;
+ }
+}
+
+/**
+ * Provides a general wrapper for any data structure. For this to work the metadata has to be passed during construction.
+ */
+class EntityStructureWrapper extends EntityMetadataWrapper implements IteratorAggregate {
+
+ protected $propertyInfo = array();
+ protected $propertyInfoAltered = FALSE;
+ protected $langcode = LANGUAGE_NONE;
+
+ protected $propertyInfoDefaults = array(
+ 'type' => 'text',
+ 'getter callback' => 'entity_plus_property_verbatim_get',
+ 'clear' => array(),
+ );
+
+ /**
+ * Construct a new EntityStructureWrapper object.
+ *
+ * @param string $type
+ * The type of the passed data.
+ * @param mixed $data
+ * Optional. The data to wrap.
+ * @param array $info
+ * Used to for specifying metadata about the data and internally to pass
+ * info about properties down the tree. For specifying metadata known keys
+ * are:
+ * - property info: An array of info about the properties of the wrapped
+ * data structure. It has to contain an array of property info in the same
+ * structure as used by hook_entity_property_info().
+ */
+ public function __construct($type, $data = NULL, $info = array()) {
+ parent::__construct($type, $data, $info);
+ $this->info += array('property defaults' => array());
+ $info += array('property info' => array());
+ $this->propertyInfo['properties'] = $info['property info'];
+ }
+
+ /**
+ * May be used to lazy-load additional info about the data, depending on the concrete passed data.
+ */
+ protected function spotInfo() {
+ // Apply the callback if set, such that the caller may alter the info.
+ if (!empty($this->info['property info alter']) && !$this->propertyInfoAltered) {
+ $this->propertyInfo = call_user_func($this->info['property info alter'], $this, $this->propertyInfo);
+ $this->propertyInfoAltered = TRUE;
+ }
+ }
+
+ /**
+ * Gets the info about the given property.
+ *
+ * @param string $name
+ * The name of the property. If not given, info about all properties will
+ * be returned.
+ *
+ * @throws EntityMetadataWrapperException
+ * If there is no such property.
+ *
+ * @return array
+ * An array of info about the property.
+ */
+ public function getPropertyInfo($name = NULL) {
+ $this->spotInfo();
+ if (!isset($name)) {
+ return $this->propertyInfo['properties'];
+ }
+ if (!isset($this->propertyInfo['properties'][$name])) {
+ throw new EntityMetadataWrapperException('Unknown data property ' . check_plain($name) . '.');
+ }
+ return $this->propertyInfo['properties'][$name] + $this->info['property defaults'] + $this->propertyInfoDefaults;
+ }
+
+ /**
+ * Returns a reference on the property info.
+ *
+ * If possible, use the property info alter callback for spotting metadata.
+ * The reference may be used to alter the property info for any remaining
+ * cases, e.g. if additional metadata has been asserted.
+ */
+ public function &refPropertyInfo() {
+ return $this->propertyInfo;
+ }
+
+ /**
+ * Sets a new language to use for retrieving properties.
+ *
+ * @param string $langcode
+ * The language code of the language to set.
+ *
+ * @return EntityWrapper
+ * An EntityWrapper.
+ */
+ public function language($langcode = LANGUAGE_NONE) {
+ if ($langcode != $this->langcode) {
+ $this->langcode = $langcode;
+ $this->cache = array();
+ }
+ return $this;
+ }
+
+ /**
+ * Gets the language used for retrieving properties.
+ *
+ * @return string
+ * The language object of the language or NULL for the default language.
+ *
+ * @see EntityStructureWrapper::language()
+ */
+ public function getPropertyLanguage() {
+ if ($this->langcode != LANGUAGE_NONE && $list = language_list()) {
+ if (isset($list[$this->langcode])) {
+ return $list[$this->langcode];
+ }
+ }
+ return NULL;
+ }
+
+ /**
+ * Get the wrapper for a property.
+ *
+ * @return EntityMetadataWrapper
+ * An instance of EntityMetadataWrapper.
+ */
+ public function get($name) {
+ // Look it up in the cache if possible.
+ if (!array_key_exists($name, $this->cache)) {
+ if ($info = $this->getPropertyInfo($name)) {
+ $info += array(
+ 'parent' => $this,
+ 'name' => $name,
+ 'langcode' => $this->langcode,
+ 'property defaults' => array()
+ );
+ $info['property defaults'] += $this->info['property defaults'];
+ $this->cache[$name] = entity_metadata_wrapper($info['type'], NULL, $info);
+ }
+ else {
+ throw new EntityMetadataWrapperException('There is no property ' . check_plain($name) . " for this entity.");
+ }
+ }
+ return $this->cache[$name];
+ }
+
+ /**
+ * Magic method: Get a wrapper for a property.
+ */
+ public function __get($name) {
+ if (strpos($name, 'krumo') === 0) {
+ // #914934 Ugly workaround to allow krumo to write its recursion property.
+ // This is necessary to make dpm() work without throwing exceptions.
+ return NULL;
+ }
+ $get = $this->get($name);
+ return $get;
+ }
+
+ /**
+ * Magic method: Set a property.
+ */
+ public function __set($name, $value) {
+ if (strpos($name, 'krumo') === 0) {
+ // #914934 Ugly workaround to allow krumo to write its recursion property.
+ // This is necessary to make dpm() work without throwing exceptions.
+ $this->$name = $value;
+ }
+ else {
+ $this->get($name)->set($value);
+ }
+ }
+
+ /**
+ * Gets the value of a property.
+ */
+ protected function getPropertyValue($name, &$info) {
+ $options = array('language' => $this->getPropertyLanguage(), 'absolute' => TRUE);
+ $data = $this->value();
+ if (!isset($data)) {
+ throw new EntityMetadataWrapperException('Unable to get the data property ' . check_plain($name) . ' as the parent data structure is not set.');
+ }
+ return $info['getter callback']($data, $options, $name, $this->type, $info);
+ }
+
+ /**
+ * Gets the raw value of a property.
+ */
+ protected function getPropertyRaw($name, &$info) {
+ if (!empty($info['raw getter callback'])) {
+ $options = array('language' => $this->getPropertyLanguage(), 'absolute' => TRUE);
+ $data = $this->value();
+ if (!isset($data)) {
+ throw new EntityMetadataWrapperException('Unable to get the data property ' . check_plain($name) . ' as the parent data structure is not set.');
+ }
+ return $info['raw getter callback']($data, $options, $name, $this->type, $info);
+ }
+ return $this->getPropertyValue($name, $info);
+ }
+
+ /**
+ * Sets a property.
+ */
+ protected function setProperty($name, $value) {
+ $info = $this->getPropertyInfo($name);
+ if (!empty($info['setter callback'])) {
+ $data = $this->value();
+
+ // In case the data structure is not set, support simple auto-creation
+ // for arrays. Else an exception is thrown.
+ if (!isset($data)) {
+ if (!empty($this->info['auto creation']) && !($this instanceof EntityBackdropWrapper)) {
+ $data = $this->info['auto creation']($name, $this->info);
+ }
+ else {
+ throw new EntityMetadataWrapperException('Unable to set the data property ' . check_plain($name) . ' as the parent data structure is not set.');
+ }
+ }
+
+ // Invoke the setter callback for updating our data.
+ $info['setter callback']($data, $name, $value, $this->langcode, $this->type, $info);
+
+ // If the setter has not thrown any exceptions, proceed and apply the
+ // update to the current and any parent wrappers as necessary.
+ $data = $this->info['type'] == 'entity' ? $this : $data;
+ $this->set($data);
+
+ // Clear the cache of properties dependent on this value.
+ foreach ($info['clear'] as $name) {
+ if (isset($this->cache[$name])) {
+ $this->cache[$name]->clear();
+ }
+ }
+ }
+ else {
+ throw new EntityMetadataWrapperException('Entity property ' . check_plain($name) . " doesn't support writing.");
+ }
+ }
+
+ /**
+ * Checks for access privileges to a property.
+ */
+ protected function propertyAccess($name, $op, $account = NULL) {
+ $info = $this->getPropertyInfo($name);
+
+ // If a property should be edited and this is part of an entity, make sure
+ // the user has update access for this entity.
+ if ($op == 'edit') {
+ $entity = $this;
+ while (!($entity instanceof EntityBackdropWrapper) && isset($entity->info['parent'])) {
+ $entity = $entity->info['parent'];
+ }
+ if ($entity instanceof EntityBackdropWrapper && $entity->entityAccess('update', $account) === FALSE) {
+ return FALSE;
+ }
+ }
+ if (!empty($info['access callback'])) {
+ $data = $this->dataAvailable() ? $this->value() : NULL;
+ return call_user_func($info['access callback'], $op, $name, $data, $account, $this->type);
+ }
+ elseif ($op == 'edit' && isset($info['setter permission'])) {
+ return user_access($info['setter permission'], $account);
+ }
+ // If access is unknown, we return TRUE.
+ return TRUE;
+ }
+
+ /**
+ * Magic method: Can be used to check if a property is known.
+ */
+ public function __isset($name) {
+ $this->spotInfo();
+ return isset($this->propertyInfo['properties'][$name]);
+ }
+
+ /**
+ * Retrieve an external iterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function getIterator() {
+ $this->spotInfo();
+ return new EntityMetadataWrapperIterator($this, array_keys($this->propertyInfo['properties']));
+ }
+
+ /**
+ * Returns the identifier of the data structure. If there is none, NULL is returned.
+ */
+ public function getIdentifier() {
+ return isset($this->id) && $this->dataAvailable() ? $this->id->value() : NULL;
+ }
+
+ /**
+ * Prepare for serializiation.
+ */
+ public function __sleep() {
+ $vars = parent::__sleep();
+ unset($vars['propertyInfoDefaults']);
+ return $vars;
+ }
+
+ /**
+ * Overrides EntityStructureWrapper:clear().
+ */
+ public function clear() {
+ $this->propertyInfoAltered = FALSE;
+ parent::clear();
+ }
+}
+
+/**
+ * Provides a wrapper for entities registered in hook_entity_plus_info().
+ *
+ * The wrapper eases applying getter and setter callbacks of entity properties
+ * specified in hook_entity_property_info().
+ */
+class EntityBackdropWrapper extends EntityStructureWrapper {
+
+ /**
+ * Contains the entity id.
+ */
+ protected $id = FALSE;
+ protected $bundle;
+ protected $entityInfo;
+
+ /**
+ * Construct a new EntityBackdropWrapper object.
+ *
+ * @param string $type
+ * The type of the passed data.
+ * @param mixed $data
+ * Optional. The entity to wrap or its identifier.
+ * @param array $info
+ * Optional. Used internally to pass info about properties down the tree.
+ */
+ public function __construct($type, $data = NULL, $info = array()) {
+ parent::__construct($type, $data, $info);
+ $this->setUp();
+ }
+
+ /**
+ * Sets up the entity info.
+ */
+ protected function setUp() {
+ $this->propertyInfo = entity_plus_get_property_info($this->type) + array('properties' => array());
+ $info = $this->info + array('property info' => array(), 'bundle' => NULL);
+ $this->propertyInfo['properties'] += $info['property info'];
+ $this->bundle = $info['bundle'];
+ $this->entityInfo = entity_get_info($this->type);
+ if (isset($this->bundle)) {
+ $this->spotBundleInfo(FALSE);
+ }
+ }
+
+ /**
+ * Sets the entity internally accepting both the entity id and object.
+ */
+ protected function setEntity($data) {
+ // For entities we allow getter callbacks to return FALSE, which we
+ // interpret like NULL values as unset properties.
+ if (isset($data) && $data !== FALSE && !is_object($data)) {
+ $this->id = $data;
+ $this->data = FALSE;
+ }
+ elseif (is_object($data) && $data instanceof EntityBackdropWrapper) {
+ // We got a wrapped entity passed, so take over its values.
+ $this->id = $data->id;
+ $this->data = $data->data;
+ // For generic entity references, also update the entity type accordingly.
+ if ($this->info['type'] == 'entity') {
+ $this->type = $data->type;
+ }
+ }
+ elseif (is_object($data)) {
+ // We got the entity object passed.
+ $this->data = $data;
+ $id = entity_plus_id($this->type, $data);
+ $this->id = isset($id) ? $id : FALSE;
+ }
+ else {
+ $this->id = FALSE;
+ $this->data = NULL;
+ }
+ }
+
+ /**
+ * Used to lazy-load bundle info. So the wrapper can be loaded e.g. just for setting without the data being loaded.
+ */
+ protected function spotInfo() {
+ if (!$this->propertyInfoAltered) {
+ if ($this->info['type'] == 'entity' && $this->dataAvailable() && $this->value()) {
+ // Add in entity-type specific details.
+ $this->setUp();
+ }
+ $this->spotBundleInfo(TRUE);
+ parent::spotInfo();
+ $this->propertyInfoAltered = TRUE;
+ }
+ }
+
+ /**
+ * Tries to determine the bundle and adds in the according property info.
+ *
+ * @param bool $load
+ * Whether the entity should be loaded to spot the info if necessary.
+ */
+ protected function spotBundleInfo($load = TRUE) {
+ // Like entity_extract_ids() assume the entity type if no key is given.
+ if (empty($this->entityInfo['entity keys']['bundle']) && $this->type != 'entity') {
+ $this->bundle = $this->type;
+ }
+ // Detect the bundle if not set yet and add in properties from the bundle.
+ elseif (!$this->bundle && $load && $this->dataAvailable()) {
+ try {
+ if ($entity = $this->value()) {
+ list($id, $vid, $bundle) = entity_extract_ids($this->type, $entity);
+ $this->bundle = $bundle;
+ }
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // Loading data failed, so we cannot derive the used bundle.
+ }
+ }
+
+ if ($this->bundle && isset($this->propertyInfo['bundles'][$this->bundle])) {
+ $bundle_info = (array) $this->propertyInfo['bundles'][$this->bundle] + array('properties' => array());
+ // Allow bundles to re-define existing properties, such that the bundle
+ // can add in more bundle-specific details like the bundle of a referenced
+ // entity.
+ $this->propertyInfo['properties'] = $bundle_info['properties'] + $this->propertyInfo['properties'];
+ }
+ }
+
+ /**
+ * Returns the identifier of the wrapped entity.
+ *
+ * @see entity_plus_id()
+ */
+ public function getIdentifier() {
+ return $this->dataAvailable() ? $this->value(array('identifier' => TRUE)) : NULL;
+ }
+
+ /**
+ * Returns the bundle of an entity, or FALSE if it has no bundles.
+ */
+ public function getBundle() {
+ if ($this->dataAvailable()) {
+ $this->spotInfo();
+ return $this->bundle;
+ }
+ }
+
+ /**
+ * Overridden.
+ *
+ * @param array $options
+ * An array of options. Known keys:
+ * - identifier: If set to TRUE, the entity identifier is returned.
+ */
+ public function value(array $options = array()) {
+ // Try loading the data via the getter callback if there is none yet.
+ if (!isset($this->data)) {
+ $this->setEntity(parent::value());
+ }
+ if (!empty($options['identifier'])) {
+ return $this->id;
+ }
+ elseif (!$this->data && !empty($this->id)) {
+ // Lazy load the entity if necessary.
+ $return = entity_load($this->type, array($this->id));
+ // In case the entity cannot be loaded, we return NULL just as for empty
+ // properties.
+ $this->data = $return ? reset($return) : NULL;
+ }
+ return $this->data;
+ }
+
+ /**
+ * Returns the entity prepared for rendering.
+ *
+ * @see entity_plus_view()
+ */
+ public function view($view_mode = 'full', $langcode = NULL, $page = NULL) {
+ return entity_plus_view($this->type(), array($this->value()), $view_mode, $langcode, $page);
+ }
+
+ /**
+ * Overridden to support setting the entity by either the object or the id.
+ */
+ public function set($value) {
+ if (!$this->validate($value)) {
+ throw new EntityMetadataWrapperException('Invalid data value given. Be sure it matches the required data type and format.');
+ }
+ if ($this->info['type'] == 'entity' && $value === $this) {
+ // Nothing to do.
+ return $this;
+ }
+ $previous_id = $this->id;
+ $previous_type = $this->type;
+ // Set value, so we get the identifier and pass it to the normal setter.
+ $this->clear();
+ $this->setEntity($value);
+ // Generally, we have to update the parent only if the entity reference
+ // has changed. In case of a generic entity reference, we pass the entity
+ // wrapped. Else we just pass the id of the entity to the setter callback.
+ if ($this->info['type'] == 'entity' && ($previous_id != $this->id || $previous_type != $this->type)) {
+ // We need to clone the wrapper we pass through as value, so it does not
+ // get cleared when the current wrapper instance gets cleared.
+ $this->updateParent(clone $this);
+ }
+ // In case the entity has been unset, we cannot properly detect changes as
+ // the previous id defaults to FALSE for unloaded entities too. So in that
+ // case we just always update the parent.
+ elseif ($this->id === FALSE && !$this->data) {
+ $this->updateParent(NULL);
+ }
+ elseif ($previous_id !== $this->id) {
+ $this->updateParent($this->id);
+ }
+ return $this;
+ }
+
+ /**
+ * Overridden.
+ */
+ public function clear() {
+ $this->id = NULL;
+ $this->bundle = isset($this->info['bundle']) ? $this->info['bundle'] : NULL;
+ if ($this->type != $this->info['type']) {
+ // Reset entity info / property info based upon the info provided during
+ // the creation of the wrapper.
+ $this->type = $this->info['type'];
+ $this->setUp();
+ }
+ parent::clear();
+ }
+
+ /**
+ * Overridden.
+ */
+ public function type() {
+ // In case of a generic entity wrapper, load the data first to determine
+ // the type of the concrete entity.
+ if ($this->dataAvailable() && $this->info['type'] == 'entity') {
+ try {
+ $this->value(array('identifier' => TRUE));
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // If loading data fails, we cannot determine the concrete entity type.
+ }
+ }
+ return $this->type;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Note that this method checks property access, but can be used for checking
+ * entity access *only* if the wrapper is not a property (i.e. has no parent
+ * wrapper).
+ * To be safe, better use EntityBackdropWrapper::entityAccess() for checking
+ * entity access.
+ */
+ public function access($op, $account = NULL) {
+ if (!empty($this->info['parent'])) {
+ // If this is a property, make sure the user is able to view the
+ // currently referenced entity also.
+ if ($this->entityAccess('view', $account) === FALSE) {
+ return FALSE;
+ }
+ if (parent::access($op, $account) === FALSE) {
+ return FALSE;
+ }
+ // If access is unknown, we return TRUE.
+ return TRUE;
+ }
+ else {
+ // This is not a property, so fallback on entity access.
+ return $this->entityAccess($op == 'edit' ? 'update' : 'view', $account);
+ }
+ }
+
+ /**
+ * Checks whether the operation $op is allowed on the entity.
+ *
+ * @see entity_plus_access()
+ */
+ public function entityAccess($op, $account = NULL) {
+ $entity = $this->dataAvailable() ? $this->value() : NULL;
+ // The value() method could return FALSE on entities such as user 0, so we
+ // need to use NULL instead to conform to the expectations of
+ // entity_plus_access().
+ if ($entity === FALSE) {
+ $entity = NULL;
+ }
+ return entity_plus_access($op, $this->type, $entity, $account);
+ }
+
+ /**
+ * Permanently save the wrapped entity.
+ *
+ * @throws EntityMetadataWrapperException
+ * If the entity type does not support saving.
+ *
+ * @return EntityBackdropWrapper
+ * The saved entity wrapper.
+ */
+ public function save() {
+ if ($this->data) {
+ if (!entity_plus_type_supports($this->type, 'save')) {
+ throw new EntityMetadataWrapperException("There is no information about how to save entities of type " . check_plain($this->type) . '.');
+ }
+ $this->data->save();
+ // On insert, update the identifier afterwards.
+ if (!$this->id) {
+ list($this->id, ,) = entity_extract_ids($this->type, $this->data);
+ }
+ }
+ // If the entity hasn't been loaded yet, don't bother saving it.
+ return $this;
+ }
+
+ /**
+ * Permanently delete the wrapped entity.
+ *
+ * @return EntityBackdropWrapper
+ * An entity wrapper for the deleted entity.
+ */
+ public function delete() {
+ if ($this->dataAvailable() && $this->value()) {
+ $return = entity_delete_multiple($this->type, array($this->id));
+ if ($return === FALSE) {
+ throw new EntityMetadataWrapperException("There is no information about how to delete entities of type " . check_plain($this->type) . '.');
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Gets the info about the wrapped entity.
+ */
+ public function entityInfo() {
+ return $this->entityInfo;
+ }
+
+ /**
+ * Returns the name of the key used by the entity for given entity key.
+ *
+ * @param string $name
+ * One of 'id', 'name', 'bundle' or 'revision'.
+ *
+ * @return string
+ * The name of the key used by the entity.
+ */
+ public function entityKey($name) {
+ return isset($this->entityInfo['entity keys'][$name]) ? $this->entityInfo['entity keys'][$name] : FALSE;
+ }
+
+ /**
+ * Returns the entity label.
+ *
+ * @see entity_label()
+ */
+ public function label() {
+ if ($entity = $this->value()) {
+ return entity_label($this->type, $entity);
+ }
+ }
+
+ /**
+ * Prepare for serializiation.
+ */
+ public function __sleep() {
+ $vars = parent::__sleep();
+ // Don't serialize the loaded entity and its property info.
+ unset($vars['data'], $vars['propertyInfo'], $vars['propertyInfoAltered'], $vars['entityInfo']);
+ // In case the entity is not saved yet, serialize the unsaved data.
+ if ($this->dataAvailable() && $this->id === FALSE) {
+ $vars['data'] = 'data';
+ }
+ return $vars;
+ }
+
+ /**
+ * Magic wake up function.
+ */
+ public function __wakeup() {
+ $this->setUp();
+ if ($this->id !== FALSE) {
+ // Make sure data is set, so the entity will be loaded when needed.
+ $this->data = FALSE;
+ }
+ }
+}
+
+/**
+ * Wraps a list of values.
+ *
+ * If the wrapped data is a list of data, its numerical indexes may be used to
+ * retrieve wrappers for the list items. For that this wrapper implements
+ * ArrayAccess so it may be used like a usual numerically indexed array.
+ */
+class EntityListWrapper extends EntityMetadataWrapper implements IteratorAggregate, ArrayAccess, Countable {
+
+ /**
+ * The type of contained items.
+ */
+ protected $itemType;
+
+ /**
+ * Whether this is a list of entities with a known entity type, i.e. for generic list of entities (list) this is FALSE.
+ */
+ protected $isEntityList;
+
+ /**
+ * Constructs a new EntityListWrapper object.
+ *
+ * @param string $type
+ * The type of the passed data.
+ * @param mixed $data
+ * Optional. The data to wrap.
+ * @param array $info
+ * Optional. Used internally to pass info about properties down the tree.
+ */
+ public function __construct($type, $data = NULL, $info = array()) {
+ parent::__construct($type, NULL, $info);
+
+ $this->itemType = entity_plus_property_list_extract_type($this->type);
+ if (!$this->itemType) {
+ $this->itemType = 'unknown';
+ }
+ $this->isEntityList = (bool) entity_get_info($this->itemType);
+
+ if (isset($data)) {
+ $this->set($data);
+ }
+ }
+
+ /**
+ * Get the wrapper for a single item.
+ *
+ * @return EntityMetadataWrapper
+ * An instance of EntityMetadataWrapper.
+ */
+ public function get($delta) {
+ // Look it up in the cache if possible.
+ if (!array_key_exists($delta, $this->cache)) {
+ if (!isset($delta)) {
+ // The [] operator has been used so point at a new entry.
+ $values = parent::value();
+ $delta = $values ? max(array_keys($values)) + 1 : 0;
+ }
+ if (is_numeric($delta)) {
+ $info = array('parent' => $this, 'name' => $delta) + $this->info;
+ $this->cache[$delta] = entity_metadata_wrapper($this->itemType, NULL, $info);
+ }
+ else {
+ throw new EntityMetadataWrapperException('There can be only numerical keyed items in a list.');
+ }
+ }
+ return $this->cache[$delta];
+ }
+
+ /**
+ * Overrides EntityMetadataWrapper::getPropertyValue().
+ */
+ protected function getPropertyValue($delta) {
+ // Make use parent::value() to easily by-pass any entity-loading.
+ $data = parent::value();
+ if (isset($data[$delta])) {
+ return $data[$delta];
+ }
+ }
+
+ /**
+ * Gets the raw value of a property.
+ */
+ protected function getPropertyRaw($delta) {
+ return $this->getPropertyValue($delta);
+ }
+
+ /**
+ * Overrides EntityMetadataWrapper::setProperty().
+ */
+ protected function setProperty($delta, $value) {
+ $data = parent::value();
+ if (is_numeric($delta)) {
+ $data[$delta] = $value;
+ $this->set($data);
+ }
+ }
+
+ /**
+ * Checks for access privileges to a property.
+ */
+ protected function propertyAccess($delta, $op, $account = NULL) {
+ return $this->access($op, $account);
+ }
+
+ /**
+ * Returns the list as numerically indexed array.
+ *
+ * Note that a list of entities might contain stale entity references. In
+ * that case the wrapper and the identifier of a stale reference would be
+ * still accessible, however the entity object value would be NULL. That way,
+ * there may be NULL values in lists of entity objects due to stale entity
+ * references.
+ *
+ * @param array $options
+ * An array of options. Known keys:
+ * - identifier: If set to TRUE for a list of entities, it won't be returned
+ * as list of fully loaded entity objects, but as a list of entity ids.
+ * Note that this list may contain ids of stale entity references.
+ */
+ public function value(array $options = array()) {
+ // For lists of entities fetch full entity objects before returning.
+ // Generic entity-wrappers need to be handled separately though.
+ if ($this->isEntityList && empty($options['identifier']) && $this->dataAvailable()) {
+ $list = parent::value();
+ $entities = $list ? entity_load($this->get(0)->type, $list) : array();
+ // Make sure to keep the array keys as present in the list.
+ foreach ($list as $key => $id) {
+ // In case the entity cannot be loaded, we return NULL just as for empty
+ // properties.
+ $list[$key] = isset($entities[$id]) ? $entities[$id] : NULL;
+ }
+ return $list;
+ }
+ return parent::value();
+ }
+
+ /**
+ * Overrides EntityMetadataWrapper::set().
+ */
+ public function set($values) {
+ // Support setting lists of fully loaded entities.
+ if ($this->isEntityList && $values && is_object(reset($values))) {
+ foreach ($values as $key => $value) {
+ // Ignore outdated NULL value references in lists of entities.
+ if (isset($value)) {
+ list($id, $vid, $bundle) = entity_extract_ids($this->itemType, $value);
+ $values[$key] = $id;
+ }
+ }
+ }
+ return parent::set($values);
+ }
+
+ /**
+ * If we wrap a list, we return an iterator over the data list.
+ */
+ #[\ReturnTypeWillChange]
+ public function getIterator() {
+ // In case there is no data available, just iterate over the first item.
+ return new EntityMetadataWrapperIterator($this, $this->dataAvailable() ? array_keys(parent::value()) : array(0));
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetGet($delta) {
+ return $this->get($delta);
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetExists($delta) {
+ return $this->dataAvailable() && ($data = $this->value()) && array_key_exists($delta, $data);
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetSet($delta, $value) {
+ $this->get($delta)->set($value);
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetUnset($delta) {
+ if ($this->offsetExists($delta)) {
+ unset($this->data[$delta]);
+ $this->set($this->data);
+ }
+ }
+
+ /**
+ * Implements the Countable interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function count() {
+ return $this->dataAvailable() ? count($this->value()) : 0;
+ }
+
+ /**
+ * Overridden.
+ */
+ public function validate($value) {
+ // Required lists may not be empty or unset.
+ if (!empty($this->info['required']) && empty($value)) {
+ return FALSE;
+ }
+ return parent::validate($value);
+ }
+
+ /**
+ * Returns the label for the list of set values if available.
+ */
+ public function label() {
+ if ($options = $this->optionsList('view')) {
+ $options = entity_plus_property_options_flatten($options);
+ $labels = array_intersect_key($options, array_flip((array) parent::value()));
+ }
+ else {
+ // Get each label on its own, e.g. to support getting labels of a list
+ // of entities.
+ $labels = array();
+ foreach ($this as $key => $property) {
+ $label = $property->label();
+ if (!$label) {
+ return NULL;
+ }
+ $labels[] = $label;
+ }
+ }
+ return isset($labels) ? implode(', ', $labels) : NULL;
+ }
+}
+
+/**
+ * Wrapper for Taxonomy Vocabularies.
+ */
+class EntityVocabularyWrapper extends EntityStructureWrapper {
+
+ protected $machineName;
+
+ /**
+ * Overrides EntityStructureWrapper::__construct().
+ */
+ public function __construct($type, $data = NULL, $info = array()) {
+ parent::__construct($type, $data, $info);
+ $this->setUp();
+ }
+
+ /**
+ * Set up the property info.
+ */
+ public function setUp() {
+ $this->propertyInfo = entity_plus_get_property_info('taxonomy_vocabulary') + array('properties' => array());
+ $info = $this->info + array('property info' => array());
+ $this->propertyInfo['properties'] += $info['property info'];
+ }
+
+ /**
+ * Overridden to support setting the entity by either the object or the id.
+ */
+ public function set($value) {
+ if (!$this->validate($value)) {
+ throw new EntityMetadataWrapperException('Invalid data value given. Be sure it matches the required data type and format.');
+ }
+
+ $previous_machine_name = $this->machineName;
+ // Set value, so we get the identifier and pass it to the normal setter.
+ $this->clear();
+ $this->setVocabulary($value);
+
+ // In case the vocabulary has been unset, we cannot properly detect changes as
+ // the previous name defaults to FALSE for unloaded vocabularies too. So in that
+ // case we just always update the parent.
+ if ($this->machineName === FALSE && !$this->data) {
+ $this->updateParent(NULL);
+ }
+ elseif ($previous_machine_name !== $this->machineName) {
+ $this->updateParent($this->machineName);
+ }
+ return $this;
+ }
+
+ /**
+ * Overridden.
+ */
+ public function clear() {
+ $this->machineName = NULL;
+ parent::clear();
+ }
+
+ /**
+ * Sets the vocabulary internally accepting both the machine_name and object.
+ */
+ protected function setVocabulary($data) {
+ // For vocabularies we allow getter callbacks to return FALSE, which we
+ // interpret like NULL values as unset properties.
+ if (isset($data) && $data !== FALSE && !is_object($data)) {
+ $this->machineName = $data;
+ $this->data = FALSE;
+ }
+ elseif (is_object($data) && $data instanceof EntityVocabularyWrapper) {
+ // We got a wrapped vocabulary passed, so take over its values.
+ $this->machineName = $data->machine_name;
+ $this->data = $data->data;
+ }
+ elseif (is_object($data)) {
+ // We got the entity object passed.
+ $this->data = $data;
+ $machine_name = $data->machine_name;
+ $this->machineName = isset($machine_name) ? $machine_name : FALSE;
+ }
+ else {
+ $this->machineName = FALSE;
+ $this->data = NULL;
+ }
+ }
+
+ /**
+ * Permanently saves the wrapped vocabulary.
+ *
+ * @return EntityVocabularyWrapper
+ * The saved vocabulary wrapper.
+ */
+ public function save() {
+ if ($this->data) {
+ $this->data->save();
+ }
+ // If the entity hasn't been loaded yet, don't bother saving it.
+ return $this;
+ }
+
+ /**
+ * Overridden.
+ *
+ * @param array $options
+ * An array of options. Known keys:
+ * - identifier: If set to TRUE, the vocabulary identifier is returned.
+ */
+ public function value(array $options = array()) {
+ // Try loading the data via the getter callback if there is none yet.
+ if (!isset($this->data)) {
+ $this->setVocabulary(parent::value());
+ }
+ if (!empty($options['identifier'])) {
+ return $this->machineName;
+ }
+ elseif (!$this->data && !empty($this->machineName)) {
+ // Lazy load the vocabulary if necessary.
+ $return = taxonomy_vocabulary_load($this->machineName);
+ // In case the vocabulary cannot be loaded, we return NULL just as for empty
+ // properties.
+ $this->data = $return ? $return : NULL;
+ }
+ return $this->data;
+ }
+
+ /**
+ * Returns the identifier of the wrapped vocabulary.
+ */
+ public function getIdentifier() {
+ return $this->dataAvailable() ? $this->value(array('identifier' => TRUE)) : NULL;
+ }
+
+ /**
+ * Returns the human readable name of the vocabulary.
+ */
+ public function label() {
+ if ($vocabulary = $this->value()) {
+ return $vocabulary->name;
+ }
+ }
+
+ /**
+ * Permanently delete the wrapped vocabulary.
+ *
+ * @return EntityVocabularyWrapper
+ * An entity wrapper for the deleted entity.
+ */
+ public function delete() {
+ if ($this->dataAvailable() && $this->value()) {
+ taxonomy_vocabulary_delete($this->machineName);
+ }
+ return $this;
+ }
+
+ /**
+ * Prepare for serializiation.
+ */
+ public function __sleep() {
+ $vars = parent::__sleep();
+ // Don't serialize the loaded vocabulary and its property info.
+ unset($vars['data'], $vars['propertyInfo'], $vars['propertyInfoAltered']);
+ // In case the vocabulary is not saved yet, serialize the unsaved data.
+ if ($this->dataAvailable() && !taxonomy_vocabulary_load($this->machineName)) {
+ $vars['data'] = 'data';
+ }
+ return $vars;
+ }
+
+ /**
+ * Magic wake up function.
+ */
+ public function __wakeup() {
+ $this->setUp();
+ if (taxonomy_vocabulary_load($this->machineName)) {
+ // This vocabulary was previously saved, therefore
+ // make sure data is set, so the vocabulary will be loaded when needed.
+ $this->data = FALSE;
+ }
+ }
+}
+
+/**
+ * Provide a separate Exception so it can be caught separately.
+ */
+class EntityMetadataWrapperException extends Exception {
+}
+
+
+/**
+ * Allows to easily iterate over existing child wrappers.
+ */
+class EntityMetadataWrapperIterator implements RecursiveIterator {
+
+ protected $position = 0;
+ protected $wrapper;
+ protected $keys;
+
+ /**
+ * Constructor.
+ */
+ public function __construct(EntityMetadataWrapper $wrapper, array $keys) {
+ $this->wrapper = $wrapper;
+ $this->keys = $keys;
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind() {
+ $this->position = 0;
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function current() {
+ return $this->wrapper->get($this->keys[$this->position]);
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function key() {
+ return $this->keys[$this->position];
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function next() {
+ $this->position++;
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function valid() {
+ return isset($this->keys[$this->position]);
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function hasChildren() {
+ return $this->current() instanceof IteratorAggregate;
+ }
+
+ /**
+ * Implements RecursiveIterator.
+ */
+ #[\ReturnTypeWillChange]
+ public function getChildren() {
+ return $this->current()->getIterator();
+ }
+}
+
+/**
+ * An array object implementation keeping the reference on the given array so
+ * changes to the object are reflected in the passed array.
+ */
+class EntityMetadataArrayObject implements ArrayAccess, Countable, IteratorAggregate {
+
+ protected $data;
+
+ /**
+ * Constructor.
+ */
+ public function __construct(&$array) {
+ $this->data =& $array;
+ }
+
+ /**
+ * Gets array data.
+ */
+ public function &getArray() {
+ return $this->data;
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetGet($delta) {
+ return $this->data[$delta];
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetExists($delta) {
+ return array_key_exists($delta, $this->data);
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetSet($delta, $value) {
+ $this->data[$delta] = $value;
+ }
+
+ /**
+ * Implements the ArrayAccess interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function offsetUnset($delta) {
+ unset($this->data[$delta]);
+ }
+
+ /**
+ * Implements the Coutable interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function count() {
+ return count($this->data);
+ }
+
+ /**
+ * Implements IteratorAggregate interface.
+ */
+ #[\ReturnTypeWillChange]
+ public function getIterator() {
+ return new ArrayIterator($this->data);
+ }
+}
+
+/**
+ * Default controller for generating some basic metadata for CRUD entity types.
+ */
+class EntityDefaultMetadataController {
+
+ protected $type;
+ protected $info;
+
+ /**
+ * Contructor.
+ */
+ public function __construct($type) {
+ $this->type = $type;
+ $this->info = entity_get_info($type);
+ }
+
+ /**
+ * Generates some basic metadata.
+ */
+ public function entityPropertyInfo() {
+ $entity_label = backdrop_strtolower($this->info['label']);
+
+ // Provide defaults based on the schema.
+ $info['properties'] = $this->convertSchema();
+ foreach ($info['properties'] as $name => &$property) {
+ // Add a description.
+ $property['description'] = t('@entity "@property" property.', array('@entity' => backdrop_ucfirst($entity_label), '@property' => $name));
+ }
+
+ // Set better metadata for known entity keys.
+ $id_key = $this->info['entity keys']['id'];
+
+ if (!empty($this->info['entity keys']['name']) && $key = $this->info['entity keys']['name']) {
+ $info['properties'][$key]['type'] = 'token';
+ $info['properties'][$key]['label'] = t('Machine-readable name');
+ $info['properties'][$key]['description'] = t('The machine-readable name identifying this @entity.', array('@entity' => $entity_label));
+ $info['properties'][$id_key]['label'] = t('Internal, numeric @entity ID', array('@entity' => $entity_label));
+ $info['properties'][$id_key]['description'] = t('The ID used to identify this @entity internally.', array('@entity' => $entity_label));
+ }
+ else {
+ $info['properties'][$id_key]['label'] = t('@entity ID', array('@entity' => backdrop_ucfirst($entity_label)));
+ $info['properties'][$id_key]['description'] = t('The unique ID of the @entity.', array('@entity' => $entity_label));
+ }
+ // Care for the bundle.
+ if (!empty($this->info['entity keys']['bundle']) && $key = $this->info['entity keys']['bundle']) {
+ $info['properties'][$key]['type'] = 'token';
+ $info['properties'][$key]['options list'] = array(get_class($this), 'bundleOptionsList');
+ }
+ // Care for the label.
+ if (!empty($this->info['entity keys']['label']) && $key = $this->info['entity keys']['label']) {
+ $info['properties'][$key]['label'] = t('Label');
+ $info['properties'][$key]['description'] = t('The human readable label.');
+ }
+
+ // Add a computed property for the entity URL and expose it to views.
+ if (empty($info['properties']['url']) && !empty($this->info['uri callback'])) {
+ $info['properties']['url'] = array(
+ 'label' => t('URL'),
+ 'description' => t('The URL of the entity.'),
+ 'getter callback' => 'entity_plus_metadata_entity_plus_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ 'entity views field' => TRUE,
+ );
+ }
+
+ return array($this->type => $info);
+ }
+
+ /**
+ * A options list callback returning all bundles for an entity type.
+ */
+ public static function bundleOptionsList($name, $info) {
+ if (!empty($info['parent']) && $type = $info['parent']) {
+ $entity_plus_info = $info['parent']->entityInfo();
+ $options = array();
+ foreach ($entity_plus_info['bundles'] as $name => $bundle_info) {
+ $options[$name] = $bundle_info['label'];
+ }
+ return $options;
+ }
+ }
+
+ /**
+ * Return a set of properties for an entity based on the schema definition.
+ */
+ protected function convertSchema() {
+ return entity_plus_metadata_convert_schema($this->info['base table']);
+ }
+}
+
+/**
+ * Interface for extra fields controller.
+ *
+ * Note: Displays extra fields exposed by this controller are rendered by
+ * default by the EntityPlusController.
+ */
+interface EntityExtraFieldsControllerInterface {
+
+ /**
+ * Returns extra fields for this entity type.
+ *
+ * @see hook_field_extra_fields()
+ */
+ public function fieldExtraFields();
+}
+
+/**
+ * Default controller for generating extra fields based on property metadata.
+ *
+ * By default a display extra field for each property not being a field, ID or
+ * bundle is generated.
+ */
+class EntityDefaultExtraFieldsController implements EntityExtraFieldsControllerInterface {
+
+ /**
+ * @var string
+ */
+ protected $entityType;
+
+ /**
+ * @var array
+ */
+ protected $entityInfo;
+
+ /**
+ * @var array
+ */
+ protected $propertyInfo;
+
+ /**
+ * Constructor.
+ */
+ public function __construct($type) {
+ $this->entityType = $type;
+ $this->entityInfo = entity_get_info($type);
+ $this->propertyInfo = entity_plus_get_property_info($type);
+ }
+
+ /**
+ * Implements EntityExtraFieldsControllerInterface::fieldExtraFields().
+ */
+ public function fieldExtraFields() {
+ $extra = array();
+ foreach ($this->propertyInfo['properties'] as $name => $property_info) {
+ // Skip adding the ID or bundle.
+ if ($this->entityInfo['entity keys']['id'] == $name || $this->entityInfo['entity keys']['bundle'] == $name) {
+ continue;
+ }
+ $extra[$this->entityType][$this->entityType]['display'][$name] = $this->generateExtraFieldInfo($name, $property_info);
+ }
+
+ // Handle bundle properties.
+ $this->propertyInfo += array('bundles' => array());
+ foreach ($this->propertyInfo['bundles'] as $bundle_name => $info) {
+ foreach ($info['properties'] as $name => $property_info) {
+ if (empty($property_info['field'])) {
+ $extra[$this->entityType][$bundle_name]['display'][$name] = $this->generateExtraFieldInfo($name, $property_info);
+ }
+ }
+ }
+ return $extra;
+ }
+
+ /**
+ * Generates the display field info for a given property.
+ */
+ protected function generateExtraFieldInfo($name, $property_info) {
+ $info = array(
+ 'label' => $property_info['label'],
+ 'weight' => 0,
+ );
+ if (!empty($property_info['description'])) {
+ $info['description'] = $property_info['description'];
+ }
+ return $info;
+ }
+}
diff --git a/www/modules/contrib/entity_plus/modules/book.info.inc b/www/modules/contrib/entity_plus/modules/book.info.inc
new file mode 100644
index 000000000..5062ec9e1
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/book.info.inc
@@ -0,0 +1,30 @@
+ t("Book"),
+ 'type' => 'node',
+ 'description' => t("If part of a book, the book to which this book page belongs."),
+ 'getter callback' => 'entity_plus_metadata_book_get_properties',
+ );
+ $properties['book_ancestors'] = array(
+ 'label' => t("Book ancestors"),
+ 'type' => 'list',
+ 'computed' => TRUE,
+ 'description' => t("If part of a book, a list of all book pages upwards in the book hierarchy."),
+ 'getter callback' => 'entity_plus_metadata_book_get_properties',
+ );
+}
diff --git a/www/modules/contrib/entity_plus/modules/callbacks.inc b/www/modules/contrib/entity_plus/modules/callbacks.inc
new file mode 100644
index 000000000..b865a48c2
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/callbacks.inc
@@ -0,0 +1,1230 @@
+ NULL,
+ 'options' => array()
+ );
+ return url($return['path'], $return['options'] + $options);
+ }
+}
+
+/**
+ * Callback for getting book node properties.
+ * @see entity_plus_metadata_book_entity_plus_info_alter()
+ */
+function entity_plus_metadata_book_get_properties($node, array $options, $name, $entity_type) {
+ switch ($name) {
+ case 'book':
+ if (isset($node->book['bid'])) {
+ return $node->book['bid'];
+ }
+ return NULL;
+
+ case 'book_ancestors':
+ $ancestors = array();
+ while (!empty($node->book['plid'])) {
+ $link = book_link_load($node->book['plid']);
+ array_unshift($ancestors, $link['nid']);
+ $node = node_load($link['nid']);
+ }
+ return $ancestors;
+ }
+}
+
+/**
+ * Callback for getting comment properties.
+ * @see entity_plus_metadata_comment_entity_plus_info_alter()
+ */
+function entity_plus_metadata_comment_get_properties($comment, array $options, $name) {
+ switch ($name) {
+ case 'name':
+ return $comment->name;
+
+ case 'mail':
+ if ($comment->uid != 0) {
+ $account = user_load($comment->uid);
+ return $account->mail;
+ }
+ return $comment->mail;
+
+ case 'edit_url':
+ return url('comment/edit/' . $comment->cid, $options);
+
+ case 'parent':
+ if (!empty($comment->pid)) {
+ return $comment->pid;
+ }
+ // There is no parent comment.
+ return NULL;
+ }
+}
+
+/**
+ * Callback for setting comment properties.
+ * @see entity_plus_metadata_comment_entity_plus_info_alter()
+ */
+function entity_plus_metadata_comment_setter($comment, $name, $value) {
+ switch ($name) {
+ case 'node':
+ $comment->nid = $value;
+ // Also set the bundle name.
+ $node = node_load($value);
+ $comment->node_type = 'comment_node_' . $node->type;
+ break;
+ }
+}
+
+/**
+ * Callback for getting comment related node properties.
+ * @see entity_plus_metadata_comment_entity_plus_info_alter()
+ */
+function entity_plus_metadata_comment_get_node_properties($node, array $options, $name, $entity_type) {
+ switch ($name) {
+ case 'comment_count':
+ return isset($node->comment_count) ? $node->comment_count : 0;
+
+ case 'comment_count_new':
+ return comment_num_new($node->nid);
+
+ case 'comments':
+ $select = db_select('comment', 'c')
+ ->fields('c', array('cid'))
+ ->condition('c.nid', $node->nid);
+ return array_keys($select->execute()->fetchAllKeyed(0, 0));
+ }
+}
+
+/**
+ * Getter callback for getting global languages.
+ */
+function entity_plus_metadata_locale_get_languages($data, array $options, $name) {
+ return isset($GLOBALS[$name]) ? $GLOBALS[$name]->langcode : NULL;
+}
+
+/**
+ * Getter callback for getting the preferred user language.
+ */
+function entity_plus_metadata_locale_get_user_language($account, array $options, $name) {
+ return user_preferred_language($account)->langcode;
+}
+
+/**
+ * Return the options lists for the node and comment status property.
+ */
+function entity_plus_metadata_status_options_list() {
+ return array(
+ NODE_PUBLISHED => t('Published'),
+ NODE_NOT_PUBLISHED => t('Unpublished'),
+ );
+}
+
+/**
+ * Return the options lists for the node and comment status property.
+ */
+function entity_plus_metadata_node_comment_settings_options_list() {
+ return array(
+ COMMENT_NODE_OPEN => t('Open'),
+ COMMENT_NODE_CLOSED => t('Closed'),
+ COMMENT_NODE_HIDDEN => t('Closed and hidden'),
+ );
+}
+
+/**
+ * Callback for getting node properties.
+ *
+ * @see entity_plus_metadata_node_entity_plus_info_alter()
+ */
+function entity_plus_metadata_node_get_properties($node, array $options, $name, $entity_type) {
+ switch ($name) {
+ case 'is_new':
+ return empty($node->nid) || !empty($node->is_new);
+
+ case 'source':
+ if (!empty($node->tnid) && $source = node_load($node->tnid)) {
+ return $source;
+ }
+ return NULL;
+
+ case 'edit_url':
+ return url('node/' . $node->nid . '/edit', $options);
+
+ case 'author':
+ return !empty($node->uid) ? $node->uid : backdrop_anonymous_user();
+ }
+}
+
+/**
+ * Callback for determing access for node revision related properties.
+ */
+function entity_plus_metadata_node_revision_access($op, $name, $entity = NULL, $account = NULL) {
+ return $op == 'view' ? user_access('view revisions', $account) : user_access('administer nodes', $account);
+}
+
+/**
+ * Callback for getting poll properties.
+ * @see entity_plus_metadata_poll_entity_plus_info_alter()
+ */
+function entity_plus_metadata_poll_node_get_properties($node, array $options, $name) {
+ $total_votes = $highest_votes = 0;
+ foreach ($node->choice as $choice) {
+ if ($choice['chvotes'] > $highest_votes) {
+ $winner = $choice;
+ $highest_votes = $choice['chvotes'];
+ }
+ $total_votes = $total_votes + $choice['chvotes'];
+ }
+
+ if ($name == 'poll_duration') {
+ return $node->runtime;
+ }
+ elseif ($name == 'poll_votes') {
+ return $total_votes;
+ }
+ elseif (!isset($winner)) {
+ // There is no poll winner yet.
+ return NULL;
+ }
+ switch ($name) {
+ case 'poll_winner_votes':
+ return $winner['chvotes'];
+
+ case 'poll_winner':
+ return $winner['chtext'];
+
+ case 'poll_winner_percent':
+ return ($winner['chvotes'] / $total_votes) * 100;
+ }
+}
+
+/**
+ * Callback for getting statistics properties.
+ * @see entity_plus_metadata_statistics_entity_plus_info_alter()
+ */
+function entity_plus_metadata_statistics_node_get_properties($node, array $options, $name) {
+ $statistics = (array) statistics_get($node->nid);
+ $statistics += array('totalcount' => 0, 'daycount' => 0, 'timestamp' => NULL);
+
+ switch ($name) {
+ case 'views':
+ return $statistics['totalcount'];
+
+ case 'day_views':
+ return $statistics['daycount'];
+
+ case 'last_view':
+ return $statistics['timestamp'];
+ }
+}
+
+/**
+ * Access callback for restricted node statistics properties.
+ */
+function entity_plus_metadata_statistics_properties_access($op, $property, $entity = NULL, $account = NULL) {
+ if ($property == 'views' && user_access('view post access counter', $account)) {
+ return TRUE;
+ }
+ return user_access('access statistics', $account);
+}
+
+/**
+ * Callback for getting site-wide properties.
+ * @see entity_plus_metadata_system_entity_plus_info_alter()
+ */
+function entity_plus_metadata_system_get_properties($data, array $options, $name) {
+ $system_core_config = config_get('system.core');
+ switch ($name) {
+ case 'name':
+ return !empty($system_core_config['site_name']) ? $system_core_config['site_name'] : 'Backdrop';
+
+ case 'url':
+ return url('', $options);
+
+ case 'login_url':
+ return url('user', $options);
+
+ case 'current_user':
+ return $GLOBALS['user']->uid ? $GLOBALS['user']->uid : backdrop_anonymous_user();
+
+ case 'current_date':
+ return REQUEST_TIME;
+
+ case 'current_page':
+ // Subsequent getters of the struct retrieve the actual values.
+ return array();
+
+ default:
+ return !empty($system_core_config['site_' . $name]) ? $system_core_config['site_' . $name] : '';
+ }
+}
+
+/**
+ * Callback for getting properties for the current page request.
+ * @see entity_plus_metadata_system_entity_plus_info_alter()
+ */
+function entity_plus_metadata_system_get_page_properties($data, array $options, $name) {
+ switch ($name) {
+ case 'url':
+ return $GLOBALS['base_root'] . request_uri();
+ }
+}
+
+/**
+ * Callback for getting file properties.
+ * @see entity_plus_metadata_system_entity_plus_info_alter()
+ */
+function entity_plus_metadata_system_get_file_properties($file, array $options, $name) {
+ switch ($name) {
+ case 'name':
+ return $file->filename;
+
+ case 'mime':
+ return $file->filemime;
+
+ case 'size':
+ return $file->filesize;
+
+ case 'url':
+ return url(file_create_url($file->uri), $options);
+
+ case 'owner':
+ return $file->uid;
+ }
+}
+
+/**
+ * Callback for getting term properties.
+ *
+ * @see entity_plus_metadata_taxonomy_entity_plus_info_alter()
+ */
+function entity_plus_metadata_taxonomy_term_get_properties($term, array $options, $name) {
+ switch ($name) {
+ case 'node_count':
+ return count(taxonomy_select_nodes($term->tid));
+
+ case 'description':
+ return check_markup($term->description, isset($term->format) ? $term->format : NULL, '', TRUE);
+
+ case 'parent':
+ if (isset($term->parent[0]) && !is_array(isset($term->parent[0]))) {
+ return $term->parent;
+ }
+ return array_keys(taxonomy_term_load_parents($term->tid));
+
+ case 'parents_all':
+ // We have to return an array of ids.
+ $tids = array();
+ foreach (taxonomy_term_load_parents_all($term->tid) as $parent) {
+ $tids[] = $parent->tid;
+ }
+ return $tids;
+
+ case 'vocabulary':
+ return taxonomy_vocabulary_load($term->vocabulary);
+ }
+}
+
+/**
+ * Callback for getting raw term properties.
+ */
+function entity_plus_metadata_taxonomy_term_get_raw_properties($term, array $options, $name) {
+ switch ($name) {
+ case 'vocabulary':
+ return $term->vocabulary;
+ }
+}
+
+/**
+ * Callback for setting term properties.
+ *
+ * @see entity_plus_metadata_taxonomy_entity_plus_info_alter()
+ */
+function entity_plus_metadata_taxonomy_term_setter($term, $name, $value) {
+ switch ($name) {
+ case 'vocabulary':
+ return $term->vocabulary = $value;
+
+ case 'parent':
+ return $term->parent = $value;
+ }
+}
+
+/**
+ * Callback for getting vocabulary properties.
+ * @see entity_plus_metadata_taxonomy_entity_plus_info_alter()
+ */
+function entity_plus_metadata_taxonomy_vocabulary_get_properties($vocabulary, array $options, $name) {
+ switch ($name) {
+ case 'term_count':
+ $sql = "SELECT COUNT (1) FROM {taxonomy_term_data} td WHERE td.vocabulary = :vocabulary";
+ return db_query($sql, array(':vocabulary' => $vocabulary->machine_name))->fetchField();
+ }
+}
+
+/**
+ * Callback for getting user properties.
+ * @see entity_plus_metadata_user_entity_plus_info_alter()
+ */
+function entity_plus_metadata_user_get_properties($account, array $options, $name, $entity_type) {
+ switch ($name) {
+ case 'last_access':
+ // In case there was no access the value is 0, but we have to return NULL.
+ return empty($account->access) ? NULL : $account->access;
+
+ case 'last_login':
+ return empty($account->login) ? NULL : $account->login;
+
+ case 'name':
+ return empty($account->uid) ? (!empty(config_get('system.core', 'anonymous')) ? config_get('system.core', 'anonymous') : t('Anonymous')) : $account->name;
+
+ case 'url':
+ if (empty($account->uid)) {
+ return NULL;
+ }
+ $return = entity_uri('user', $account);
+ return $return ? url($return['path'], $return['options'] + $options) : '';
+
+ case 'edit_url':
+ return empty($account->uid) ? NULL : url("user/$account->uid/edit", $options);
+
+ case 'roles':
+ return isset($account->roles) ? $account->roles : array();
+
+ case 'theme':
+ return empty($account->theme) ? (!empty(config_get('system.core', 'system_default')) ? config_get('system.core', 'system_default') : 'basis') : $account->theme;
+ }
+}
+
+/**
+ * Callback for setting user properties.
+ * @see entity_plus_metadata_user_entity_plus_info_alter()
+ */
+function entity_plus_metadata_user_set_properties($account, $name, $value) {
+ switch ($name) {
+ case 'roles':
+ $account->roles = array_intersect(array_keys(user_roles()), $value);
+ break;
+ }
+}
+
+/**
+ * Options list callback returning all user roles.
+ */
+function entity_plus_metadata_user_roles($property_name = 'roles', $info = array(), $op = 'edit') {
+ $roles = user_roles();
+ if ($op == 'edit') {
+ unset($roles[BACKDROP_AUTHENTICATED_ROLE], $roles[BACKDROP_ANONYMOUS_ROLE]);
+ }
+ return $roles;
+}
+
+/**
+ * Return the options lists for user status property.
+ */
+function entity_plus_metadata_user_status_options_list() {
+ return array(
+ 0 => t('Blocked'),
+ 1 => t('Active'),
+ );
+}
+
+/**
+ * Callback defining an options list for language properties.
+ */
+function entity_plus_metadata_language_list() {
+ $list = array();
+ $list[LANGUAGE_NONE] = t('Language neutral');
+ foreach (language_list() as $language) {
+ $list[$language->langcode] = t($language->name);
+ }
+ return $list;
+}
+
+/**
+ * Callback for getting field property values.
+ */
+function entity_plus_metadata_field_property_get($entity, array $options, $name, $entity_type, $info) {
+ $field = field_info_field($name);
+ $columns = array_keys($field['columns']);
+ $langcode = isset($options['language']) ? $options['language']->langcode : LANGUAGE_NONE;
+ $langcode = entity_plus_metadata_field_get_language($entity_type, $entity, $field, $langcode, TRUE);
+ $values = array();
+ if (isset($entity->{$name}[$langcode])) {
+ foreach ($entity->{$name}[$langcode] as $delta => $data) {
+ $values[$delta] = $data[$columns[0]];
+ if ($info['type'] == 'boolean' || $info['type'] == 'list') {
+ // Ensure that we have a clean boolean data type.
+ $values[$delta] = (bool) $values[$delta];
+ }
+ }
+ }
+ // For an empty single-valued field, we have to return NULL.
+ return $field['cardinality'] == 1 ? ($values ? reset($values) : NULL) : $values;
+}
+
+/**
+ * Callback for setting field property values.
+ */
+function entity_plus_metadata_field_property_set($entity, $name, $value, $langcode, $entity_type, $info) {
+ $field = field_info_field($name);
+ $columns = array_keys($field['columns']);
+ $langcode = entity_plus_metadata_field_get_language($entity_type, $entity, $field, $langcode);
+ $values = $field['cardinality'] == 1 ? array($value) : (array) $value;
+
+ $items = array();
+ foreach ($values as $delta => $value) {
+ if (isset($value)) {
+ $items[$delta][$columns[0]] = $value;
+ if ($info['type'] == 'boolean' || $info['type'] == 'list') {
+ // Convert boolean values back to an integer for writing.
+ $items[$delta][$columns[0]] = (int) $items[$delta][$columns[0]] = $value;
+ }
+ }
+ }
+ $entity->{$name}[$langcode] = $items;
+ // Empty the static field language cache, so the field system picks up any
+ // possible new languages.
+ backdrop_static_reset('field_language');
+}
+
+/**
+ * Callback returning the options list of a field.
+ */
+function entity_plus_metadata_field_options_list($name, $info) {
+ $field_property_info = $info;
+ if (is_numeric($name) && isset($info['parent'])) {
+ // The options list is to be returned for a single item of a multiple field.
+ $field_property_info = $info['parent']->info();
+ $name = $field_property_info['name'];
+ }
+ if (($field = field_info_field($name)) && isset($field_property_info['parent'])) {
+ // Retrieve the wrapped entity holding the field.
+ $wrapper = $field_property_info['parent'];
+ try {
+ $entity = $wrapper->value();
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // No data available.
+ $entity = NULL;
+ }
+
+ // Support translating labels via i18n field.
+ if (module_exists('i18n_field') && ($translate = i18n_field_type_info($field['type'], 'translate_options'))) {
+ return $translate($field);
+ }
+ else {
+ $instance = $wrapper->getBundle() ? field_info_instance($wrapper->type(), $name, $wrapper->getBundle()) : NULL;
+ return (array) module_invoke($field['module'], 'options_list', $field, $instance, $wrapper->type(), $entity);
+ }
+ }
+}
+
+/**
+ * Callback to verbatim get the data structure of a field. Useful for fields that add metadata for their own data structure.
+ */
+function entity_plus_metadata_field_verbatim_get($entity, array $options, $name, $entity_type, &$context) {
+ // Set contextual info useful for getters of any child properties.
+ $context['instance'] = field_info_instance($context['parent']->type(), $name, $context['parent']->getBundle());
+ $context['field'] = field_info_field($name);
+ $langcode = isset($options['language']) ? $options['language']->langcode : LANGUAGE_NONE;
+ $langcode = entity_plus_metadata_field_get_language($entity_type, $entity, $context['field'], $langcode, TRUE);
+
+ if ($context['field']['cardinality'] == 1) {
+ return isset($entity->{$name}[$langcode][0]) ? $entity->{$name}[$langcode][0] : NULL;
+ }
+ return isset($entity->{$name}[$langcode]) ? $entity->{$name}[$langcode] : array();
+}
+
+/**
+ * Writes the passed field items in the object. Useful as field level setter to set the whole data structure at once.
+ */
+function entity_plus_metadata_field_verbatim_set($entity, $name, $items, $langcode, $entity_type) {
+ $field = field_info_field($name);
+ $langcode = entity_plus_metadata_field_get_language($entity_type, $entity, $field, $langcode);
+ $value = $field['cardinality'] == 1 ? array($items) : (array) $items;
+ // Filter out any items set to NULL.
+ $entity->{$name}[$langcode] = array_filter($value);
+
+ // Empty the static field language cache, so the field system picks up any
+ // possible new languages.
+ backdrop_static_reset('field_language');
+}
+
+/**
+ * Helper for determining the field language to be used.
+ *
+ * Note that we cannot use field_language() as we are not about to display
+ * values, but generally read/write values.
+ *
+ * @param string $entity_type
+ * The entity type; e.g., 'node' or 'user'.
+ * @param Entity $entity
+ * The entity to which the field is attached.
+ * @param array $field
+ * The field info as returned by field_info_field().
+ * @param string $langcode
+ * (optional) The language code.
+ * @param bool $fallback
+ * (optional) Whether to fall back to the entity default language, if no
+ * value is available for the given language code yet.
+ *
+ * @return string
+ * The language code to use.
+ */
+function entity_plus_metadata_field_get_language($entity_type, $entity, $field, $langcode = LANGUAGE_NONE, $fallback = FALSE) {
+ $default_langcode = entity_plus_language($entity_type, $entity);
+
+ // Determine the right language to use.
+ if ($default_langcode != LANGUAGE_NONE && field_is_translatable($entity_type, $field)) {
+ $langcode = ($langcode != LANGUAGE_NONE) ? field_valid_language($langcode, $default_langcode) : $default_langcode;
+ if (!isset($entity->{$field['field_name']}[$langcode]) && $fallback) {
+ $langcode = $default_langcode;
+ }
+ return $langcode;
+ }
+ else {
+ return LANGUAGE_NONE;
+ }
+}
+
+/**
+ * Callback for getting the sanitized text of 'text_formatted' properties. This callback is used for both the 'value' and the 'summary'.
+ */
+function entity_plus_metadata_field_text_get($item, array $options, $name, $type, $context) {
+ // $name is either 'value' or 'summary'.
+ if (!isset($item['safe_' . $name])) {
+ // Apply input formats.
+ $langcode = isset($options['language']) ? $options['language']->langcode : LANGUAGE_NONE;
+ $format = isset($item['format']) ? $item['format'] : filter_default_format();
+ $item['safe_' . $name] = check_markup($item[$name], $format, $langcode);
+ // To speed up subsequent calls, update $item with the 'safe_value'.
+ $context['parent']->set($item);
+ }
+ return $item['safe_' . $name];
+}
+
+/**
+ * Defines the list of all available text formats.
+ */
+function entity_plus_metadata_field_text_formats() {
+ foreach (filter_formats() as $key => $format) {
+ $formats[$key] = $format->name;
+ }
+ return $formats;
+}
+
+/**
+ * Callback for getting the file entity of file fields.
+ */
+function entity_plus_metadata_field_file_get($item) {
+ return $item['fid'];
+}
+
+/**
+ * Callback for setting the file entity of file fields.
+ */
+function entity_plus_metadata_field_file_set(&$item, $property_name, $value) {
+ $item['fid'] = $value;
+}
+
+/**
+ * Callback for auto-creating file field $items.
+ */
+function entity_plus_metadata_field_file_create_item($property_name, $context) {
+ // 'fid' is required, so 'file' has to be set as initial property.
+ return array('display' => isset($context['field']['settings']['display_default']) ? $context['field']['settings']['display_default'] : 0);
+}
+
+/**
+ * Callback for validating file field $items.
+ */
+function entity_plus_metadata_field_file_validate_item($items, $context) {
+ // Allow NULL values.
+ if (!isset($items)) {
+ return TRUE;
+ }
+
+ // Stream-line $items for multiple vs non-multiple fields.
+ $items = !entity_plus_property_list_extract_type($context['type']) ? array($items) : (array) $items;
+
+ foreach ($items as $item) {
+ // File-field items require a valid file.
+ if (!isset($item['fid']) || !file_load($item['fid'])) {
+ return FALSE;
+ }
+ if (isset($context['property info']['display']) && !isset($item['display'])) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Access callback for the node entity.
+ *
+ * This function does not implement hook_node_access(), thus it may not be
+ * called entity_plus_metadata_node_access().
+ *
+ * @see entity_plus_access()
+ *
+ * @param string $op
+ * The operation being performed. One of 'view', 'update', 'create' or
+ * 'delete'.
+ * @param Node $node
+ * A node to check access for. Must be a node object. Must have nid,
+ * except in the case of 'create' operations.
+ * @param object $account
+ * The user to check for. Leave it to NULL to check for the global user.
+ *
+ * @throws EntityMalformedException
+ *
+ * @return bool
+ * TRUE if access is allowed, FALSE otherwise.
+ */
+function entity_plus_metadata_no_hook_node_access($op, $node = NULL, $account = NULL) {
+ // First deal with the case where a $node is provided.
+ if (isset($node)) {
+ if ($op == 'create') {
+ if (isset($node->type)) {
+ return node_access($op, $node->type, $account);
+ }
+ else {
+ throw new EntityMalformedException(t('Permission to create a node was requested but no node type was given.'));
+ }
+ }
+ // If a non-default revision is given, incorporate revision access.
+ $default_revision = node_load($node->nid);
+ if ($node->vid !== $default_revision->vid) {
+ return _node_revision_access($node, $op, $account);
+ }
+ else {
+ return node_access($op, $node, $account);
+ }
+ }
+ // No node is provided. Check for access to all nodes.
+ if (user_access('bypass node access', $account)) {
+ return TRUE;
+ }
+ if (!user_access('access content', $account)) {
+ return FALSE;
+ }
+ if ($op == 'view' && node_access_view_all_nodes($account)) {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for the user entity.
+ */
+function entity_plus_metadata_user_access($op, $entity = NULL, $account = NULL, $entity_type = NULL) {
+ $account = isset($account) ? $account : $GLOBALS['user'];
+ // Grant access to the users own user account and to the anonymous one.
+ if (isset($entity->uid) && $op != 'delete' && (($entity->uid == $account->uid && $entity->uid) || (!$entity->uid && $op == 'view'))) {
+ return TRUE;
+ }
+ if (user_access('administer users', $account)
+ || user_access('access user profiles', $account) && $op == 'view' && (empty($entity) || !empty($entity->status))) {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for restricted user properties.
+ */
+function entity_plus_metadata_user_properties_access($op, $property, $entity = NULL, $account = NULL) {
+ if (user_access('administer users', $account)) {
+ return TRUE;
+ }
+ $account = isset($account) ? $account : $GLOBALS['user'];
+ // Flag to indicate if this user entity is the own user account.
+ $is_own_account = isset($entity->uid) && $account->uid == $entity->uid;
+ switch ($property) {
+ case 'name':
+ // Allow view access to anyone with access to the entity.
+ if ($op == 'view') {
+ return TRUE;
+ }
+ // Allow edit access for own user name if the permission is satisfied.
+ return $is_own_account && user_access('change own username', $account);
+
+ case 'mail':
+ // Allow access to own mail address.
+ return $is_own_account;
+
+ case 'roles':
+ // Allow view access for own roles.
+ return ($op == 'view' && $is_own_account);
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for the comment entity.
+ */
+function entity_plus_metadata_comment_access($op, $entity = NULL, $account = NULL) {
+ // When determining access to a comment, 'comment_access' does not take any
+ // access restrictions to the comment's associated node into account. If a
+ // comment has an associated node, the user must be able to view it in order
+ // to access the comment.
+ if (isset($entity->nid)) {
+ if (!entity_plus_access('view', 'node', node_load($entity->nid), $account)) {
+ return FALSE;
+ }
+ }
+
+ // Comment administrators are allowed to perform all operations on all
+ // comments.
+ if (user_access('administer comments', $account)) {
+ return TRUE;
+ }
+
+ // Unpublished comments can never be accessed by non-admins.
+ if (isset($entity->status) && $entity->status == COMMENT_NOT_PUBLISHED) {
+ return FALSE;
+ }
+
+ if (isset($entity) && $op == 'update') {
+ // Because 'comment_access' only checks the current user, we need to do our
+ // own access checking if an account was specified.
+ if (!isset($account)) {
+ return comment_access('edit', $entity);
+ }
+ else {
+ return $account->uid && $account->uid == $entity->uid && user_access('edit own comments', $account);
+ }
+ }
+ if (user_access('access comments', $account) && $op == 'view') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for restricted comment properties.
+ */
+function entity_plus_metadata_comment_properties_access($op, $property, $entity = NULL, $account = NULL) {
+ return user_access('administer comments', $account);
+}
+
+/**
+ * Access callback for the taxonomy entities.
+ */
+function entity_plus_metadata_taxonomy_access($op, $entity = NULL, $account = NULL, $entity_type = NULL) {
+ if (isset($entity) && $op == 'update' && !isset($account) && user_access("edit terms in {$entity->vocabulary}")) {
+ return TRUE;
+ }
+ if (user_access('administer taxonomy', $account) || user_access('access content', $account) && $op == 'view') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for file entities.
+ *
+ * @see entity_plus_access()
+ *
+ * @param string $op
+ * The operation being performed. One of 'view', 'download', 'update', 'create' or
+ * 'delete'.
+ * @param File $file
+ * A file to check access for. Must be a file object. Must have fid,
+ * except in the case of 'create' operations.
+ * @param User $account
+ * The user to check for. Leave it to NULL to check for the global user.
+ *
+ * @throws EntityMalformedException
+ *
+ * @return bool
+ * TRUE if access is allowed, FALSE otherwise.
+ */
+function entity_plus_metadata_file_access($op, $file = NULL, $account = NULL) {
+ // First deal with the case where a $file is provided and op is 'create'.
+ if (isset($file) && $op == 'create') {
+ if (isset($file->type)) {
+ return file_access($op, $file->type, $account);
+ }
+ else {
+ throw new EntityMalformedException(t('Permission to create a file was requested but no file type was given.'));
+ }
+ }
+ // No file is provided. Check for access to all files.
+ if (file_access($op, $file, $account)) {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Callback to determine access for properties which are fields.
+ */
+function entity_plus_metadata_field_access_callback($op, $name, $entity = NULL, $account = NULL, $entity_type = NULL) {
+ $field = field_info_field($name);
+ return field_access($op, $field, $entity_type, $entity, $account);
+}
+
+/**
+ * Callback to create entity objects.
+ */
+function entity_plus_metadata_create_object($values = array(), $entity_type = NULL) {
+ $info = entity_get_info($entity_type);
+ // Make sure at least the bundle and label properties are set.
+ if (isset($info['entity keys']['bundle']) && $key = $info['entity keys']['bundle']) {
+ $values += array($key => NULL);
+ }
+ if (isset($info['entity keys']['label']) && $key = $info['entity keys']['label']) {
+ $values += array($key => NULL);
+ }
+ $entity = (object) $values;
+ $entity->is_new = TRUE;
+ return $entity;
+}
+
+/**
+ * Callback to create a new comment.
+ */
+function entity_plus_metadata_create_comment($values = array()) {
+ $comment = (object) ($values + array(
+ 'status' => COMMENT_PUBLISHED,
+ 'pid' => 0,
+ 'subject' => '',
+ 'uid' => 0,
+ 'langcode' => LANGUAGE_NONE,
+ 'node_type' => NULL,
+ 'is_new' => TRUE,
+ ));
+ $comment->cid = FALSE;
+ return $comment;
+}
+
+/**
+ * Callback to create a new node.
+ */
+function entity_plus_metadata_create_node($values = array()) {
+ $node = (object) array(
+ 'type' => $values['type'],
+ 'langcode' => LANGUAGE_NONE,
+ 'is_new' => TRUE,
+ );
+ // Set some defaults.
+ $node_options_settings = config_get('node.type.' . $node->type, 'settings');
+ foreach (array('status', 'promote', 'sticky') as $key) {
+ $node->$key = (int) $node_options_settings[$key . '_default'];
+ }
+ if (module_exists('comment') && !isset($node->comment)) {
+ $node->comment = !empty($node_options_settings['comment_default']) ? $node_options_settings['comment_default'] : COMMENT_NODE_OPEN;
+ }
+ // Apply the given values.
+ foreach ($values as $key => $value) {
+ $node->$key = $value;
+ }
+ return $node;
+}
+
+/**
+ * Callback to delete a file. Watch out to not accidentilly implement hook_file_delete().
+ */
+function entity_plus_metadata_delete_file($fid) {
+ file_delete(file_load($fid), TRUE);
+}
+
+/**
+ * Callback to view nodes.
+ */
+function entity_plus_metadata_view_node($entities, $view_mode = 'full', $langcode = NULL) {
+ $result = node_view_multiple($entities, $view_mode, 0, $langcode);
+ // Make sure to key the result with 'node' instead of 'nodes'.
+ return array('node' => reset($result));
+}
+
+/**
+ * Callback to view comments.
+ */
+function entity_plus_metadata_view_comment($entities, $view_mode = 'full', $langcode = NULL) {
+ $build = array();
+ $nodes = array();
+ // The comments, indexed by nid and then by cid.
+ $nid_comments = array();
+ foreach ($entities as $cid => $comment) {
+ $nid = $comment->nid;
+ $nodes[$nid] = $nid;
+ $nid_comments[$nid][$cid] = $comment;
+ }
+ $nodes = node_load_multiple(array_keys($nodes));
+ foreach ($nid_comments as $nid => $comments) {
+ $node = isset($nodes[$nid]) ? $nodes[$nid] : NULL;
+ $build += comment_view_multiple($comments, $node, $view_mode, 0, $langcode);
+ }
+ return array('comment' => $build);
+}
+
+/**
+ * Callback to view an entity, for which just ENTITYTYPE_view() is available.
+ */
+function entity_plus_metadata_view_single($entities, $view_mode = 'full', $langcode = NULL, $entity_type = NULL) {
+ $function = $entity_type . '_view';
+ $build = array();
+ foreach ($entities as $key => $entity) {
+ $build[$entity_type][$key] = $function($entity, $view_mode, $langcode);
+ }
+ return $build;
+}
+
+/**
+ * Callback to get the form of a node.
+ */
+function entity_plus_metadata_form_node($node) {
+ // Pre-populate the form-state with the right form include.
+ $form_state['build_info']['args'] = array($node);
+ form_load_include($form_state, 'inc', 'node', 'node.pages');
+ return backdrop_build_form($node->type . '_node_form', $form_state);
+}
+
+/**
+ * Callback to get the form of a comment.
+ */
+function entity_plus_metadata_form_comment($comment) {
+ if (!isset($comment->node_type)) {
+ $node = node_load($comment->nid);
+ $comment->node_type = 'comment_node_' . $node->type;
+ }
+ return backdrop_get_form($comment->node_type . '_form', $comment);
+}
+
+/**
+ * Callback to get the form of a user account.
+ */
+function entity_plus_metadata_form_user($account) {
+ // If $account->uid is set then we want a user edit form.
+ // Otherwise we want the user register form.
+ if (isset($account->uid)) {
+ $form_id = 'user_profile_form';
+ form_load_include($form_state, 'inc', 'user', 'user.pages');
+ }
+ else {
+ $form_id = 'user_register_form';
+ }
+ $form_state['build_info']['args'] = array($account);
+ return backdrop_build_form($form_id, $form_state);
+}
+
+/**
+ * Callback to get the form of a term.
+ */
+function entity_plus_metadata_form_taxonomy_term($term) {
+ // Pre-populate the form-state with the right form include.
+ $form_state['build_info']['args'] = array($term);
+ form_load_include($form_state, 'inc', 'taxonomy', 'taxonomy.admin');
+ return backdrop_build_form('taxonomy_form_term', $form_state);
+}
+
+/**
+ * Callback to get the form of a vocabulary.
+ */
+function entity_plus_metadata_form_taxonomy_vocabulary($vocab) {
+ // Pre-populate the form-state with the right form include.
+ $form_state['build_info']['args'] = array($vocab);
+ form_load_include($form_state, 'inc', 'taxonomy', 'taxonomy.admin');
+ return backdrop_build_form('taxonomy_form_vocabulary', $form_state);
+}
+
+/**
+ * Callback to get the form for entities using the entity API admin ui.
+ */
+function entity_plus_metadata_form_entity_plus_ui($entity, $entity_type) {
+ $info = entity_get_info($entity_type);
+ $form_state = form_state_defaults();
+ // Add in the include file as the form API does else with the include file
+ // specified for the active menu item.
+ if (!empty($info['admin ui']['file'])) {
+ $path = isset($info['admin ui']['file path']) ? $info['admin ui']['file path'] : backdrop_get_path('module', $info['module']);
+ $form_state['build_info']['files']['entity_plus_ui'] = $path . '/' . $info['admin ui']['file'];
+ // Also load the include file.
+ if (file_exists($form_state['build_info']['files']['entity_plus_ui'])) {
+ require_once BACKDROP_ROOT . '/' . $form_state['build_info']['files']['entity_plus_ui'];
+ }
+ }
+ return entity_plus_ui_get_form($entity_type, $entity, $op = 'edit', $form_state);
+}
+
+/**
+ * Callback for querying entity properties having their values stored in the entities main db table.
+ */
+function entity_plus_metadata_table_query($entity_type, $property, $value, $limit) {
+ $properties = entity_plus_get_all_property_info($entity_type);
+ $info = $properties[$property] + array('schema field' => $property);
+
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $entity_type, '=')
+ ->propertyCondition($info['schema field'], $value, is_array($value) ? 'IN' : '=')
+ ->range(0, $limit);
+
+ $result = $query->execute();
+ return !empty($result[$entity_type]) ? array_keys($result[$entity_type]) : array();
+}
+
+/**
+ * Callback for querying entities by field values. This function just queries for the value of the first specified column. Also it is only suitable for fields that don't process the data, so it's stored the same way as returned.
+ */
+function entity_plus_metadata_field_query($entity_type, $property, $value, $limit) {
+ $query = new EntityFieldQuery();
+ $field = field_info_field($property);
+ $columns = array_keys($field['columns']);
+
+ $query->entityCondition('entity_type', $entity_type, '=')
+ ->fieldCondition($field, $columns[0], $value, is_array($value) ? 'IN' : '=')
+ ->range(0, $limit);
+
+ $result = $query->execute();
+ return !empty($result[$entity_type]) ? array_keys($result[$entity_type]) : array();
+}
+
+/**
+ * Implements entity_uri() callback for file entities.
+ */
+function entity_plus_metadata_uri_file($file) {
+ return array(
+ 'path' => file_create_url($file->uri),
+ );
+}
+
+/**
+ * Getter callback to return date values as datestamp in UTC from the field.
+ */
+function entity_plus_metadata_field_date_getter($entity, array $options, $name, $entity_type, &$context) {
+ $return = entity_plus_metadata_field_verbatim_get($entity, $options, $name, $entity_type, $context);
+ $items = ($context['field']['cardinality'] == 1) ? array($return) : $return;
+ foreach ($items as $key => $item) {
+ $items[$key] = entity_plus_metadata_date_struct_getter($item, $options, 'value', 'struct', $context);
+ }
+ return ($context['field']['cardinality'] == 1) ? $items[0] : $items;
+}
+
+/**
+ * Getter callback to return date values as datestamp in UTC.
+ */
+function entity_plus_metadata_date_struct_getter($item, array $options, $name, $type, $info) {
+ if (empty($item) || !isset($item[$name])) {
+ return NULL;
+ }
+ $value = trim($item[$name]);
+ if (empty($value)) {
+ return NULL;
+ }
+
+ $timezone_db = !empty($item['timezone_db']) ? $item['timezone_db'] : 'UTC';
+ $date = new BackdropDateTime($value, $timezone_db);
+ return !empty($date) ? date_format_date($date, 'custom', 'U') : NULL;
+}
+
+/**
+ * Getter callback to return the duration of the time period given by the dates.
+ */
+function entity_plus_metadata_date_duration_getter($item, array $options, $name, $type, $info) {
+ $value = entity_plus_metadata_date_struct_getter($item, $options, 'value', 'struct', $info);
+ $value2 = entity_plus_metadata_date_struct_getter($item, $options, 'value2', 'struct', $info);
+ if ($value && $value2) {
+ return $value2 - $value;
+ }
+}
+
+/**
+ * Callback for setting field property values.
+ *
+ * Based on entity_metadata_field_property_set(), the original property setter,
+ * adapted to transform non-timestamp date values to timestamps.
+ */
+function entity_plus_metadata_field_date_setter(&$entity, $name, $value, $langcode, $entity_type, $info) {
+ $field = field_info_field($name);
+ if (!isset($langcode)) {
+ // Try to figure out the default language used by the entity.
+ // @todo: Update once http://backdrop.org/node/1260640 has been fixed.
+ $langcode = isset($entity->language) ? $entity->language : LANGUAGE_NONE;
+ }
+ $values = $field['cardinality'] == 1 ? array($value) : (array) $value;
+
+ $items = array();
+ foreach ($values as $delta => $value) {
+ // Make use of the struct setter to convert the date back to a timestamp.
+ $info['field_name'] = $name;
+ entity_plus_metadata_date_struct_setter($items[$delta], 'value', $value, $langcode, 'struct', $info);
+ }
+ $entity->{$name}[$langcode] = $items;
+ // Empty the static field language cache, so the field system picks up any
+ // possible new languages.
+ backdrop_static_reset('field_language');
+}
+
+/**
+ * Auto creation callback for fields which contain two date values in one.
+ */
+function entity_plus_metadata_date_struct_create($name, $property_info) {
+ return array(
+ 'date_type' => $property_info['field']['columns'][$name]['type'],
+ 'timezone_db' => $property_info['field']['settings']['timezone_db'],
+ );
+}
+
+/**
+ * Callback for setting an individual field value if a to-date may be there too.
+ *
+ * Based on entity_property_verbatim_set().
+ *
+ * The passed in unix timestamp (UTC) is converted to the right value and format dependent on the field.
+ *
+ * $name is either 'value' or 'value2'.
+ */
+function entity_plus_metadata_date_struct_setter(&$item, $name, $value, $langcode, $type, $info) {
+ if (!isset($value)) {
+ $item[$name] = NULL;
+ }
+ else {
+ $field = field_info_field($info['field_name']);
+ $format = date_type_format($field['type']);
+ $timezone_db = date_get_timezone_db($field['settings']['tz_handling']);
+
+ $date = new BackdropDateTime($value, 'UTC');
+ if ($timezone_db != 'UTC') {
+ date_timezone_set($date, timezone_open($timezone_db));
+ }
+ $item[$name] = $date->format($format);
+ }
+}
+
+/**
+ * Callback for creating a new, empty link field item.
+ *
+ * @see entity_plus_metadata_field_link_callback()
+ */
+function entity_plus_metadata_field_link_create_item() {
+ return array('title' => NULL, 'url' => NULL, 'display_url' => NULL);
+}
+
+
+/**
+ * Callback for getting the link URL property.
+ */
+function entity_plus_link_url_property_get($data, array $options, $name, $type, $info) {
+ $url = entity_plus_property_verbatim_get($data, $options, $name, $type, $info);
+
+ return url($url, $data + $options);
+}
+
+/**
+ * Entity property info getter callback for link attributes.
+ */
+function entity_plus_link_attribute_property_get($data, array $options, $name, $type, $info) {
+ return isset($data[$name]) ? array_filter($data[$name]) : array();
+}
diff --git a/www/modules/contrib/entity_plus/modules/comment.info.inc b/www/modules/contrib/entity_plus/modules/comment.info.inc
new file mode 100644
index 000000000..0f4d8138b
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/comment.info.inc
@@ -0,0 +1,173 @@
+ t("Comment ID"),
+ 'type' => 'integer',
+ 'description' => t("The unique ID of the comment."),
+ 'schema field' => 'cid',
+ );
+ $properties['hostname'] = array(
+ 'label' => t("IP Address"),
+ 'description' => t("The IP address of the computer the comment was posted from."),
+ 'access callback' => 'entity_plus_metadata_comment_properties_access',
+ 'schema field' => 'hostname',
+ );
+ $properties['name'] = array(
+ 'label' => t("Name"),
+ 'description' => t("The name left by the comment author."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer comments',
+ 'sanitize' => 'filter_xss',
+ 'schema field' => 'name',
+ );
+ $properties['mail'] = array(
+ 'label' => t("Email address"),
+ 'description' => t("The email address left by the comment author."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'validation callback' => 'valid_email_address',
+ 'access callback' => 'entity_plus_metadata_comment_properties_access',
+ 'schema field' => 'mail',
+ );
+ $properties['homepage'] = array(
+ 'label' => t("Home page"),
+ 'description' => t("The home page URL left by the comment author."),
+ 'sanitize' => 'filter_xss_bad_protocol',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer comments',
+ 'schema field' => 'homepage',
+ );
+ $properties['subject'] = array(
+ 'label' => t("Subject"),
+ 'description' => t("The subject of the comment."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'sanitize' => 'filter_xss',
+ 'required' => TRUE,
+ 'schema field' => 'subject',
+ );
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The URL of the comment."),
+ 'getter callback' => 'entity_plus_metadata_entity_plus_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['edit_url'] = array(
+ 'label' => t("Edit URL"),
+ 'description' => t("The URL of the comment's edit page."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['created'] = array(
+ 'label' => t("Date created"),
+ 'description' => t("The date the comment was posted."),
+ 'type' => 'date',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer comments',
+ 'schema field' => 'created',
+ );
+ $properties['parent'] = array(
+ 'label' => t("Parent"),
+ 'description' => t("The comment's parent, if comment threading is active."),
+ 'type' => 'comment',
+ 'getter callback' => 'entity_plus_metadata_comment_get_properties',
+ 'setter permission' => 'administer comments',
+ 'schema field' => 'pid',
+ );
+ $properties['node'] = array(
+ 'label' => t("Node"),
+ 'description' => t("The node the comment was posted to."),
+ 'type' => 'node',
+ 'setter callback' => 'entity_plus_metadata_comment_setter',
+ 'setter permission' => 'administer comments',
+ 'required' => TRUE,
+ 'schema field' => 'nid',
+ );
+ $properties['author'] = array(
+ 'label' => t("Author"),
+ 'description' => t("The author of the comment."),
+ 'type' => 'user',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer comments',
+ 'required' => TRUE,
+ 'schema field' => 'uid',
+ );
+ $properties['status'] = array(
+ 'label' => t("Status"),
+ 'description' => t("Whether the comment is published or unpublished."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ // Although the status is expected to be boolean, its schema suggests
+ // it is an integer, so we follow the schema definition.
+ 'type' => 'integer',
+ 'options list' => 'entity_plus_metadata_status_options_list',
+ 'access callback' => 'entity_plus_metadata_comment_properties_access',
+ 'schema field' => 'status',
+ );
+ return $info;
+}
+
+/**
+ * Implements hook_entity_property_info_alter() on top of comment module.
+ * @see entity_plus_entity_property_info_alter()
+ */
+function entity_plus_metadata_comment_entity_property_info_alter(&$info) {
+ // Add info about comment module related properties to the node entity.
+ $properties = &$info['node']['properties'];
+ $properties['comment'] = array(
+ 'label' => t("Comments settings"),
+ 'description' => t("Whether comments are allowed on this node."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer comment settings',
+ 'type' => 'integer',
+ 'options list' => 'entity_plus_metadata_node_comment_settings_options_list',
+ );
+ $properties['comments'] = array(
+ 'label' => t("Comments"),
+ 'type' => 'list',
+ 'description' => t("The node comments."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_node_properties',
+ 'computed' => TRUE,
+ );
+ $properties['comment_count'] = array(
+ 'label' => t("Comment count"),
+ 'description' => t("The number of comments posted on a node."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_node_properties',
+ 'type' => 'integer',
+ );
+ $properties['comment_count_new'] = array(
+ 'label' => t("New comment count"),
+ 'description' => t("The number of comments posted on a node since the reader last viewed it."),
+ 'getter callback' => 'entity_plus_metadata_comment_get_node_properties',
+ 'type' => 'integer',
+ );
+
+ // The comment body field is usually available for all bundles, so add it
+ // directly to the comment entity.
+ $info['comment']['properties']['comment_body'] = array(
+ 'type' => 'text_formatted',
+ 'label' => t('The main body text'),
+ 'getter callback' => 'entity_plus_metadata_field_verbatim_get',
+ 'setter callback' => 'entity_plus_metadata_field_verbatim_set',
+ 'property info' => entity_plus_property_text_formatted_info(),
+ 'field' => TRUE,
+ 'required' => TRUE,
+ );
+ unset($info['comment']['properties']['comment_body']['property info']['summary']);
+}
diff --git a/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.info b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.info
new file mode 100644
index 000000000..4c69fead3
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.info
@@ -0,0 +1,14 @@
+name = Entity Plus Internationalization Integration
+description = Integration with Internationalization (i18n) for custom entities.
+
+backdrop = 1.x
+type = module
+
+dependencies[] = entity_plus
+dependencies[] = entity_ui
+dependencies[] = i18n_string
+
+; Added by Backdrop CMS packaging script on 2026-01-06
+project = entity_plus
+version = 1.x-1.0.23
+timestamp = 1767717936
diff --git a/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.module b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.module
new file mode 100644
index 000000000..56f2b297b
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/entity_plus_i18n.module
@@ -0,0 +1,71 @@
+ $info) {
+ entity_plus_i18n_i18n_controller($entity_type);
+ }
+ return array_filter($static);
+ }
+
+ if (!isset($static[$type])) {
+ $info = entity_get_info($type);
+ // Do not activate it by default. Modules have to explicitly enable it by
+ // specifying EntityDefaultI18nStringController or their customization.
+ $class = isset($info['i18n controller class']) ? $info['i18n controller class'] : FALSE;
+ $static[$type] = $class ? new $class($type, $info) : FALSE;
+ }
+
+ return $static[$type];
+}
+
+/**
+ * Implements hook_i18n_string_info().
+ */
+function entity_plus_i18n_i18n_string_info() {
+ $groups = array();
+ foreach (entity_plus_i18n_i18n_controller() as $entity_type => $controller) {
+ $groups += $controller->hook_string_info();
+ }
+ return $groups;
+}
+
+/**
+ * Implements hook_i18n_object_info().
+ */
+function entity_plus_i18n_i18n_object_info() {
+ $info = array();
+ foreach (entity_plus_i18n_i18n_controller() as $entity_type => $controller) {
+ $info += $controller->hook_object_info();
+ }
+ return $info;
+}
+
+/**
+ * Implements hook_i18n_string_objects().
+ */
+function entity_plus_i18n_i18n_string_objects($type) {
+ if ($controller = entity_plus_i18n_i18n_controller($type)) {
+ return $controller->hook_string_objects();
+ }
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function entity_plus_i18n_autoload_info() {
+ return array(
+ 'EntityDefaultI18nStringController' => 'includes/entity_plus_i18n.i18n.inc',
+ );
+}
diff --git a/www/modules/contrib/entity_plus/modules/entity_plus_i18n/includes/entity_plus_i18n.i18n.inc b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/includes/entity_plus_i18n.i18n.inc
new file mode 100644
index 000000000..fcdbb3a6f
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/entity_plus_i18n/includes/entity_plus_i18n.i18n.inc
@@ -0,0 +1,150 @@
+entityType = $type;
+ $this->entityInfo = entity_get_info($type);
+ // By default we go with the module name as textgroup.
+ $this->textgroup = $this->entityInfo['module'];
+ }
+
+ /**
+ * Implements hook_i18n_string_info() via entity_i18n_string_info().
+ */
+ public function hook_string_info() {
+ $list = system_list('module_enabled');
+ $info = $list[$this->textgroup]->info;
+
+ $groups[$this->textgroup] = array(
+ 'title' => $info['name'],
+ 'description' => !empty($info['description']) ? $info['description'] : NULL,
+ 'format' => FALSE,
+ 'list' => TRUE,
+ );
+ return $groups;
+ }
+
+ /**
+ * Implements hook_i18n_object_info() via entity_i18n_object_info().
+ *
+ * Go with the same default values as the admin UI as far as possible.
+ */
+ public function hook_object_info() {
+ $wildcard = $this->menuWildcard();
+ $id_key = !empty($this->entityInfo['entity keys']['name']) ? $this->entityInfo['entity keys']['name'] : $this->entityInfo['entity keys']['id'];
+
+ $info[$this->entityType] = array(
+ // Generic object title.
+ 'title' => $this->entityInfo['label'],
+ // The object key field.
+ 'key' => $id_key,
+ // Placeholders for automatic paths.
+ 'placeholders' => array(
+ $wildcard => $id_key,
+ ),
+
+ // Properties for string translation.
+ 'string translation' => array(
+ // Text group that will handle this object's strings.
+ 'textgroup' => $this->textgroup,
+ // Object type property for string translation.
+ 'type' => $this->entityType,
+ // Translatable properties of these objects.
+ 'properties' => $this->translatableProperties(),
+ ),
+ );
+
+ // Integrate the translate tab into the admin-UI if enabled.
+ if ($base_path = $this->menuBasePath()) {
+ $info[$this->entityType] += array(
+ // To produce edit links automatically.
+ 'edit path' => $base_path . '/manage/' . $wildcard,
+ // Auto-generate translate tab.
+ 'translate tab' => $base_path . '/manage/' . $wildcard . '/translate',
+ );
+ $info[$this->entityType]['string translation'] += array(
+ // Path to translate strings to every language.
+ 'translate path' => $base_path . '/manage/' . $wildcard . '/translate/%i18n_language',
+ );
+ }
+ return $info;
+ }
+
+ /**
+ * Defines the menu base path used by self::hook_object_info().
+ */
+ protected function menuBasePath() {
+ return !empty($this->entityInfo['admin ui']['path']) ? $this->entityInfo['admin ui']['path'] : FALSE;
+ }
+
+ /**
+ * Defines the menu wildcard used by self::hook_object_info().
+ */
+ protected function menuWildcard() {
+ return isset($this->entityInfo['admin ui']['menu wildcard']) ? $this->entityInfo['admin ui']['menu wildcard'] : '%entity_ui_object';
+ }
+
+ /**
+ * Defines translatable properties used by self::hook_object_info().
+ */
+ protected function translatableProperties() {
+ $list = array();
+ foreach (entity_plus_get_all_property_info($this->entityType) as $name => $info) {
+ if (!empty($info['translatable']) && !empty($info['i18n string'])) {
+ $list[$name] = array(
+ 'title' => $info['label'],
+ );
+ }
+ }
+ return $list;
+ }
+
+ /**
+ * Implements hook_i18n_string_objects() via entity_i18n_string_objects().
+ */
+ public function hook_string_objects() {
+ return entity_load_multiple_by_name($this->entityType, FALSE);
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/modules/field.info.inc b/www/modules/contrib/entity_plus/modules/field.info.inc
new file mode 100644
index 000000000..18a4fc818
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/field.info.inc
@@ -0,0 +1,288 @@
+ $field) {
+ $field += array('bundles' => array());
+ if ($field_type = field_info_field_types($field['type'])) {
+ // Add in our default callback as the first one.
+ $field_type += array('property_callbacks' => array());
+ array_unshift($field_type['property_callbacks'], 'entity_plus_metadata_field_default_property_callback');
+
+ foreach ($field['bundles'] as $entity_type => $bundles) {
+ foreach ($bundles as $bundle) {
+ $instance = field_info_instance($entity_type, $field_name, $bundle);
+
+ if ($instance && empty($instance['deleted'])) {
+ foreach ($field_type['property_callbacks'] as $callback) {
+ if (function_exists($callback)) {
+ $callback($info, $entity_type, $field, $instance, $field_type);
+ }
+ else {
+ watchdog($entity_type, 'Missing property callback %callback.', array('%callback' => $callback), WATCHDOG_ERROR);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return $info;
+}
+
+/**
+ * Callback to add in property info defaults per field instance.
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_default_property_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ if (!empty($field_type['property_type'])) {
+ if ($field['cardinality'] != 1) {
+ $field_type['property_type'] = 'list<' . $field_type['property_type'] . '>';
+ }
+ // Add in instance specific property info, if given and apply defaults.
+ $name = $field['field_name'];
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$name];
+ $instance += array('property info' => array());
+ $property = $instance['property info'] + array(
+ // Since the label will be exposed via hook_token_info() and it is not
+ // clearly defined if that should be sanitized already we prevent XSS
+ // right here (field labels are user provided text).
+ 'label' => filter_xss_admin($instance['label']),
+ 'type' => $field_type['property_type'],
+ 'description' => t('Field "@name".', array('@name' => $name)),
+ 'getter callback' => 'entity_plus_metadata_field_property_get',
+ 'setter callback' => 'entity_plus_metadata_field_property_set',
+ 'access callback' => 'entity_plus_metadata_field_access_callback',
+ 'query callback' => 'entity_plus_metadata_field_query',
+ 'translatable' => !empty($field['translatable']),
+ // Specify that this property stems from a field.
+ 'field' => TRUE,
+ 'required' => !empty($instance['required']),
+ );
+ // For field types of the list module add in the options list callback.
+ if (strpos($field['type'], 'list') === 0) {
+ $property['options list'] = 'entity_plus_metadata_field_options_list';
+ }
+ }
+}
+
+/**
+ * Additional callback to adapt the property info for text fields. If a text field is processed we make use of a separate data structure so that format filters are available too. For the text value the sanitized, thus processed value is returned by default.
+ *
+ * @see entity_plus_metadata_field_entity_property_info()
+ * @see entity_plus_field_info_alter()
+ * @see entity_plus_property_text_formatted_info()
+ */
+function entity_plus_metadata_field_text_property_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ if (!empty($instance['settings']['text_processing']) || $field['type'] == 'text_with_summary') {
+ // Define a data structure for dealing with text that is formatted or has
+ // a summary.
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']];
+
+ $property['getter callback'] = 'entity_plus_metadata_field_verbatim_get';
+ $property['setter callback'] = 'entity_plus_metadata_field_verbatim_set';
+ unset($property['query callback']);
+
+ if (empty($instance['settings']['text_processing'])) {
+ $property['property info'] = entity_plus_property_field_item_textsummary_info();
+ }
+ else {
+ // For formatted text we use the type name 'text_formatted'.
+ $property['type'] = ($field['cardinality'] != 1) ? 'list' : 'text_formatted';
+ $property['property info'] = entity_plus_property_text_formatted_info();
+ }
+ // Enable auto-creation of the item, so that it is possible to just set
+ // the textual or summary value.
+ $property['auto creation'] = 'entity_plus_property_create_array';
+
+ if ($field['type'] != 'text_with_summary') {
+ unset($property['property info']['summary']);
+ }
+ }
+}
+
+/**
+ * Additional callback to adapt the property info for term reference fields.
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_term_reference_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']];
+ if (count($field['settings']['allowed_values']) == 1) {
+ $settings = reset($field['settings']['allowed_values']);
+ $property['bundle'] = $settings['vocabulary'];
+ }
+ // Only add the options list callback for controlled vocabularies, thus
+ // vocabularies not using the autocomplete widget.
+ if ($instance['widget']['type'] != 'taxonomy_autocomplete') {
+ $property['options list'] = 'entity_plus_metadata_field_options_list';
+ }
+}
+
+/**
+ * Additional callback to adapt the property info for file fields.
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_file_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']];
+ // Define a data structure so it's possible to deal with files and their
+ // descriptions.
+ $property['getter callback'] = 'entity_plus_metadata_field_verbatim_get';
+ $property['setter callback'] = 'entity_plus_metadata_field_verbatim_set';
+
+ // Auto-create the field $items as soon as a property is set.
+ $property['auto creation'] = 'entity_plus_metadata_field_file_create_item';
+ $property['validation callback'] = 'entity_plus_metadata_field_file_validate_item';
+
+ $property['property info'] = entity_plus_property_field_item_file_info();
+
+ if (empty($instance['settings']['description_field'])) {
+ unset($property['property info']['description']);
+ }
+ if (empty($field['settings']['display_field'])) {
+ unset($property['property info']['display']);
+ }
+ unset($property['query callback']);
+}
+
+/**
+ * Additional callback to adapt the property info for image fields. This callback gets invoked after entity_plus_metadata_field_file_callback().
+ *
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_image_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']];
+ // Update the property info with the info for image fields.
+ $property['property info'] = entity_plus_property_field_item_image_info();
+
+ if (empty($instance['settings']['alt_field'])) {
+ unset($property['property info']['alt']);
+ }
+ if (empty($instance['settings']['title_field'])) {
+ unset($property['property info']['title']);
+ }
+}
+
+/**
+ * Callback to alter the property info of date fields.
+ *
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_date_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ $name = $field['field_name'];
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$name];
+
+ if ($field['type'] != 'datestamp' || $field['settings']['timezone_db'] != 'UTC') {
+ // Add a getter callback to convert the date into the right format.
+ $property['getter callback'] = 'entity_plus_metadata_field_date_getter';
+ $property['setter callback'] = 'entity_plus_metadata_field_date_setter';
+ unset($property['query callback']);
+ }
+ if (!empty($field['settings']['todate'])) {
+ // Define a simple data structure containing both dates.
+ $property['type'] = ($field['cardinality'] != 1) ? 'list' : 'struct';
+ $property['auto creation'] = 'entity_plus_metadata_date_struct_create';
+ $property['getter callback'] = 'entity_plus_metadata_field_verbatim_get';
+ $property['setter callback'] = 'entity_plus_metadata_field_verbatim_set';
+ $property['property info'] = array(
+ 'value' => array(
+ 'type' => 'date',
+ 'label' => t('Start date'),
+ 'getter callback' => 'entity_plus_metadata_date_struct_getter',
+ 'setter callback' => 'entity_plus_metadata_date_struct_setter',
+ // The getter and setter callbacks for 'value' and 'value2'
+ // will not provide the field name as $name, we'll add it to $info.
+ 'field_name' => $field['field_name'],
+ // Alert Microdata module that this value can be exposed in microdata.
+ 'microdata' => TRUE,
+ ),
+ 'value2' => array(
+ 'type' => 'date',
+ 'label' => t('End date'),
+ 'getter callback' => 'entity_plus_metadata_date_struct_getter',
+ 'setter callback' => 'entity_plus_metadata_date_struct_setter',
+ // The getter and setter callbacks for 'value' and 'value2'
+ // will not provide the field name as $name, we'll add it to $info.
+ 'field_name' => $field['field_name'],
+ // Alert Microdata module that this value can be exposed in microdata.
+ // While microdata has not been ported to Backdrop, leave this here just in case.
+ 'microdata' => TRUE,
+ ),
+ 'duration' => array(
+ 'type' => 'duration',
+ 'label' => t('Duration'),
+ 'desription' => t('The duration of the time period given by the dates.'),
+ 'getter callback' => 'entity_plus_metadata_date_duration_getter',
+ // No setter callback for duration.
+ // The getter callback for duration will not provide the field name
+ // as $name, we'll add it to $info.
+ 'field_name' => $field['field_name'],
+ ),
+ );
+ unset($property['query callback']);
+ }
+ else {
+ // If this doesn't have a todate, it is handled as a date rather than a
+ // struct. Enable microdata on the field itself rather than the properties.
+ // While microdata has not been ported to Backdrop, leave this here just in case.
+ $property['microdata'] = TRUE;
+ }
+}
+
+/**
+ * Callback to alter the property info of link fields.
+ *
+ * @see entity_plus_metadata_field_entity_property_info()
+ */
+function entity_plus_metadata_field_link_callback(&$info, $entity_type, $field, $instance, $field_type) {
+ $property = &$info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']];
+ // Define a data structure so it's possible to deal with both the link title
+ // and URL.
+ $property['getter callback'] = 'entity_plus_metadata_field_verbatim_get';
+ $property['setter callback'] = 'entity_plus_metadata_field_verbatim_set';
+
+ // Auto-create the field item as soon as a property is set.
+ $property['auto creation'] = 'entity_plus_metadata_field_link_create_item';
+
+ $property['property info'] = array(
+ 'title' => array(
+ 'type' => 'text',
+ 'label' => t('The title of the link.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'required' => $instance['required'] && ($instance['settings']['title'] == 'required'),
+ ),
+ 'url' => array(
+ 'type' => 'text',
+ 'label' => t('The URL of the link.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'getter callback' => 'entity_plus_link_url_property_get',
+ 'required' => $instance['required'] && !$instance['settings']['url'],
+ ),
+ 'attributes' => array(
+ 'type' => 'struct',
+ 'label' => t('The attributes of the link.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'getter callback' => 'entity_plus_link_attribute_property_get',
+ ),
+ 'display_url' => array(
+ 'type' => 'uri',
+ 'label' => t('The full URL of the link.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ ),
+ );
+ if ($instance['settings']['title'] == 'none') {
+ unset($property['property info']['title']);
+ }
+ unset($property['query callback']);
+}
diff --git a/www/modules/contrib/entity_plus/modules/locale.info.inc b/www/modules/contrib/entity_plus/modules/locale.info.inc
new file mode 100644
index 000000000..2b29070f8
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/locale.info.inc
@@ -0,0 +1,40 @@
+ t("Language"),
+ 'description' => t("This account's default language for e-mails, and preferred language for site presentation."),
+ 'type' => 'token',
+ 'getter callback' => 'entity_plus_metadata_locale_get_user_language',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'options list' => 'entity_plus_metadata_language_list',
+ 'schema field' => 'language',
+ 'setter permission' => 'administer users',
+ );
+
+ $info['site']['properties']['current_page']['property info']['language'] = array(
+ 'label' => t("Interface language"),
+ 'description' => t("The language code of the current user interface language."),
+ 'type' => 'token',
+ 'getter callback' => 'entity_plus_metadata_locale_get_languages',
+ 'options list' => 'entity_plus_metadata_language_list',
+ );
+ $info['site']['properties']['current_page']['property info']['language_content'] = array(
+ 'label' => t("Content language"),
+ 'description' => t("The language code of the current content language."),
+ 'type' => 'token',
+ 'getter callback' => 'entity_plus_metadata_locale_get_languages',
+ 'options list' => 'entity_plus_metadata_language_list',
+ );
+}
diff --git a/www/modules/contrib/entity_plus/modules/node.info.inc b/www/modules/contrib/entity_plus/modules/node.info.inc
new file mode 100644
index 000000000..2ef3b10c2
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/node.info.inc
@@ -0,0 +1,180 @@
+ t("Node ID"),
+ 'type' => 'integer',
+ 'description' => t("The unique ID of the node."),
+ 'schema field' => 'nid',
+ );
+ $properties['vid'] = array(
+ 'label' => t("Revision ID"),
+ 'type' => 'integer',
+ 'description' => t("The unique ID of the node's revision."),
+ 'schema field' => 'vid',
+ );
+ $properties['is_new'] = array(
+ 'label' => t("Is new"),
+ 'type' => 'boolean',
+ 'description' => t("Whether the node is new and not saved to the database yet."),
+ 'getter callback' => 'entity_plus_metadata_node_get_properties',
+ );
+ $properties['type'] = array(
+ 'label' => t("Content type"),
+ 'type' => 'token',
+ 'description' => t("The type of the node."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'options list' => 'node_type_get_names',
+ 'required' => TRUE,
+ 'schema field' => 'type',
+ );
+ $properties['title'] = array(
+ 'label' => t("Title"),
+ 'description' => t("The title of the node."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'schema field' => 'title',
+ 'required' => TRUE,
+ );
+
+ // For backward compatibility this property name is kept.
+ $properties['langcode'] = array(
+ 'label' => t("Language code"),
+ 'type' => 'token',
+ 'description' => t("The code of the language the node is written in."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'options list' => 'entity_plus_metadata_language_list',
+ 'schema field' => 'langcode',
+ 'setter permission' => 'administer nodes',
+ );
+
+ // Provide a langcode property that matches the actual node field.
+ $properties['language'] = array(
+ 'label' => t('Language (deprecated - use "Language code")'),
+ ) + $properties['langcode'];
+
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The URL of the node."),
+ 'getter callback' => 'entity_plus_metadata_entity_plus_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['edit_url'] = array(
+ 'label' => t("Edit URL"),
+ 'description' => t("The URL of the node's edit page."),
+ 'getter callback' => 'entity_plus_metadata_node_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['status'] = array(
+ 'label' => t("Status"),
+ 'description' => t("Whether the node is published or unpublished."),
+ // Although the status is expected to be boolean, its schema suggests
+ // it is an integer, so we follow the schema definition.
+ 'type' => 'integer',
+ 'options list' => 'entity_plus_metadata_status_options_list',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'schema field' => 'status',
+ );
+ $properties['promote'] = array(
+ 'label' => t("Promoted to frontpage"),
+ 'description' => t("Whether the node is promoted to the frontpage."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'schema field' => 'promote',
+ 'type' => 'boolean',
+ );
+ $properties['sticky'] = array(
+ 'label' => t("Sticky in lists"),
+ 'description' => t("Whether the node is displayed at the top of lists in which it appears."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'schema field' => 'sticky',
+ 'type' => 'boolean',
+ );
+ $properties['created'] = array(
+ 'label' => t("Date created"),
+ 'type' => 'date',
+ 'description' => t("The date the node was posted."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'schema field' => 'created',
+ );
+ $properties['changed'] = array(
+ 'label' => t("Date changed"),
+ 'type' => 'date',
+ 'schema field' => 'changed',
+ 'description' => t("The date the node was most recently updated."),
+ );
+ $properties['author'] = array(
+ 'label' => t("Author"),
+ 'type' => 'user',
+ 'description' => t("The author of the node."),
+ 'getter callback' => 'entity_plus_metadata_node_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer nodes',
+ 'required' => TRUE,
+ 'schema field' => 'uid',
+ );
+ $properties['source'] = array(
+ 'label' => t("Translation source node"),
+ 'type' => 'node',
+ 'description' => t("The original-language version of this node, if one exists."),
+ 'getter callback' => 'entity_plus_metadata_node_get_properties',
+ );
+ $properties['log'] = array(
+ 'label' => t("Revision log message"),
+ 'type' => 'text',
+ 'description' => t("In case a new revision is to be saved, the log entry explaining the changes for this version."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'access callback' => 'entity_plus_metadata_node_revision_access',
+ );
+ $properties['revision'] = array(
+ 'label' => t("Creates revision"),
+ 'type' => 'boolean',
+ 'description' => t("Whether saving this node creates a new revision."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'access callback' => 'entity_plus_metadata_node_revision_access',
+ );
+ return $info;
+}
+
+/**
+ * Implements hook_entity_property_info_alter() on top of node module.
+ * @see entity_plus_metadata_entity_property_info_alter()
+ */
+function entity_plus_metadata_node_entity_property_info_alter(&$info) {
+ // Move the body property to the node by default, as its usually there this
+ // makes dealing with it more convenient.
+ $info['node']['properties']['body'] = array(
+ 'type' => 'text_formatted',
+ 'label' => t('The main body text'),
+ 'getter callback' => 'entity_plus_metadata_field_verbatim_get',
+ 'setter callback' => 'entity_plus_metadata_field_verbatim_set',
+ 'property info' => entity_plus_property_text_formatted_info(),
+ 'auto creation' => 'entity_plus_property_create_array',
+ 'field' => TRUE,
+ );
+
+ // Make it a list if cardinality is not 1.
+ $field_body = field_info_field('body');
+ if (isset($field_body) && $field_body['cardinality'] != 1) {
+ $info['node']['properties']['body']['type'] = 'list';
+ }
+}
diff --git a/www/modules/contrib/entity_plus/modules/poll.info.inc b/www/modules/contrib/entity_plus/modules/poll.info.inc
new file mode 100644
index 000000000..27f21e772
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/poll.info.inc
@@ -0,0 +1,50 @@
+ t("Poll votes"),
+ 'description' => t("The number of votes that have been cast on a poll node."),
+ 'type' => 'integer',
+ 'getter callback' => 'entity_plus_metadata_poll_node_get_properties',
+ 'computed' => TRUE,
+ );
+ $properties['poll_winner'] = array(
+ 'label' => t("Poll winner"),
+ 'description' => t("The winning poll answer."),
+ 'getter callback' => 'entity_plus_metadata_poll_node_get_properties',
+ 'sanitize' => 'filter_xss',
+ 'computed' => TRUE,
+ );
+ $properties['poll_winner_votes'] = array(
+ 'label' => t("Poll winner votes"),
+ 'description' => t("The number of votes received by the winning poll answer."),
+ 'type' => 'integer',
+ 'getter callback' => 'entity_plus_metadata_poll_node_get_properties',
+ 'computed' => TRUE,
+ );
+ $properties['poll_winner_percent'] = array(
+ 'label' => t("Poll winner percent"),
+ 'description' => t("The percentage of votes received by the winning poll answer."),
+ 'getter callback' => 'entity_plus_metadata_poll_node_get_properties',
+ 'type' => 'decimal',
+ 'computed' => TRUE,
+ );
+ $properties['poll_duration'] = array(
+ 'label' => t("Poll duration"),
+ 'description' => t("The length of time the poll node is set to run."),
+ 'getter callback' => 'entity_plus_metadata_poll_node_get_properties',
+ 'type' => 'duration',
+ );
+}
diff --git a/www/modules/contrib/entity_plus/modules/statistics.info.inc b/www/modules/contrib/entity_plus/modules/statistics.info.inc
new file mode 100644
index 000000000..a47f447d2
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/statistics.info.inc
@@ -0,0 +1,40 @@
+ t("Number of views"),
+ 'description' => t("The number of visitors who have read the node."),
+ 'type' => 'integer',
+ 'getter callback' => 'entity_plus_metadata_statistics_node_get_properties',
+ 'computed' => TRUE,
+ 'access callback' => 'entity_plus_metadata_statistics_properties_access',
+ );
+ $properties['day_views'] = array(
+ 'label' => t("Views today"),
+ 'description' => t("The number of visitors who have read the node today."),
+ 'type' => 'integer',
+ 'getter callback' => 'entity_plus_metadata_statistics_node_get_properties',
+ 'computed' => TRUE,
+ 'access callback' => 'entity_plus_metadata_statistics_properties_access',
+ );
+ $properties['last_view'] = array(
+ 'label' => t("Last view"),
+ 'description' => t("The date on which a visitor last read the node."),
+ 'type' => 'date',
+ 'getter callback' => 'entity_plus_metadata_statistics_node_get_properties',
+ 'computed' => TRUE,
+ 'access callback' => 'entity_plus_metadata_statistics_properties_access',
+ );
+}
diff --git a/www/modules/contrib/entity_plus/modules/system.info.inc b/www/modules/contrib/entity_plus/modules/system.info.inc
new file mode 100644
index 000000000..ce00aa3bb
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/system.info.inc
@@ -0,0 +1,159 @@
+ t("Name"),
+ 'description' => t("The name of the site."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'sanitize' => 'check_plain',
+ );
+ $properties['slogan'] = array(
+ 'label' => t("Slogan"),
+ 'description' => t("The slogan of the site."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'sanitize' => 'check_plain',
+ );
+ $properties['mail'] = array(
+ 'label' => t("Email"),
+ 'description' => t("The administrative email address for the site."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ );
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The URL of the site's front page."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'type' => 'uri',
+ );
+ $properties['login_url'] = array(
+ 'label' => t("Login page"),
+ 'description' => t("The URL of the site's login page."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'type' => 'uri',
+ );
+ $properties['current_user'] = array(
+ 'label' => t("Logged in user"),
+ 'description' => t("The currently logged in user."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'type' => 'user',
+ );
+ $properties['current_date'] = array(
+ 'label' => t("Current date"),
+ 'description' => t("The current date and time."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'type' => 'date',
+ );
+ $properties['current_page'] = array(
+ 'label' => t("Current page"),
+ 'description' => t("Information related to the current page request."),
+ 'getter callback' => 'entity_plus_metadata_system_get_properties',
+ 'type' => 'struct',
+ 'property info' => array(
+ 'path' => array(
+ 'label' => t("Path"),
+ 'description' => t("The internal Backdrop path of the current page request."),
+ 'getter callback' => 'current_path',
+ 'type' => 'text',
+ ),
+ 'url' => array(
+ 'label' => t("URL"),
+ 'description' => t("The full URL of the current page request."),
+ 'getter callback' => 'entity_plus_metadata_system_get_page_properties',
+ 'type' => 'uri',
+ ),
+ ),
+ );
+ if (module_exists('locale')) {
+ $properties['current_page']['property info']['language'] = array(
+ 'label' => t("Interface language"),
+ 'description' => t("The language code of the current user interface language."),
+ 'type' => 'token',
+ 'getter callback' => 'entity_plus_metadata_locale_get_languages',
+ 'options list' => 'entity_plus_metadata_language_list',
+ );
+ $properties['current_page']['property info']['language_content'] = array(
+ 'label' => t("Content language"),
+ 'description' => t("The language code of the current content language."),
+ 'type' => 'token',
+ 'getter callback' => 'entity_plus_metadata_locale_get_languages',
+ 'options list' => 'entity_plus_metadata_language_list',
+ );
+ }
+
+ // Files.
+ $properties = &$info['file']['properties'];
+ $properties['fid'] = array(
+ 'label' => t("File ID"),
+ 'description' => t("The unique ID of the uploaded file."),
+ 'type' => 'integer',
+ 'validation callback' => 'entity_plus_metadata_validate_integer_positive',
+ 'schema field' => 'fid',
+ );
+ $properties['name'] = array(
+ 'label' => t("File name"),
+ 'description' => t("The name of the file on disk."),
+ 'getter callback' => 'entity_plus_metadata_system_get_file_properties',
+ 'schema field' => 'filename',
+ );
+ $properties['mime'] = array(
+ 'label' => t("MIME type"),
+ 'description' => t("The MIME type of the file."),
+ 'getter callback' => 'entity_plus_metadata_system_get_file_properties',
+ 'sanitize' => 'filter_xss',
+ 'schema field' => 'filemime',
+ );
+ $properties['size'] = array(
+ 'label' => t("File size"),
+ 'description' => t("The size of the file, in kilobytes."),
+ 'getter callback' => 'entity_plus_metadata_system_get_file_properties',
+ 'type' => 'integer',
+ 'schema field' => 'filesize',
+ );
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The web-accessible URL for the file."),
+ 'getter callback' => 'entity_plus_metadata_system_get_file_properties',
+ );
+ $properties['timestamp'] = array(
+ 'label' => t("Timestamp"),
+ 'description' => t("The date the file was most recently changed."),
+ 'type' => 'date',
+ 'schema field' => 'timestamp',
+ );
+ $properties['owner'] = array(
+ 'label' => t("Owner"),
+ 'description' => t("The user who originally uploaded the file."),
+ 'type' => 'user',
+ 'getter callback' => 'entity_plus_metadata_system_get_file_properties',
+ 'schema field' => 'uid',
+ );
+ $properties['type'] = array(
+ 'label' => t('File type'),
+ 'type' => 'token',
+ 'description' => t('The type of the file.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'setter permission' => 'administer files',
+ 'options list' => 'file_type_get_names',
+ 'required' => TRUE,
+ 'schema field' => 'type',
+ );
+
+ return $info;
+}
diff --git a/www/modules/contrib/entity_plus/modules/taxonomy.info.inc b/www/modules/contrib/entity_plus/modules/taxonomy.info.inc
new file mode 100644
index 000000000..e781b15f1
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/taxonomy.info.inc
@@ -0,0 +1,127 @@
+ t("Term ID"),
+ 'description' => t("The unique ID of the taxonomy term."),
+ 'type' => 'integer',
+ 'schema field' => 'tid',
+ );
+ $properties['name'] = array(
+ 'label' => t("Name"),
+ 'description' => t("The name of the taxonomy term."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'required' => TRUE,
+ 'schema field' => 'name',
+ );
+ $properties['description'] = array(
+ 'label' => t("Description"),
+ 'description' => t("The optional description of the taxonomy term."),
+ 'sanitized' => TRUE,
+ 'raw getter callback' => 'entity_plus_property_verbatim_get',
+ 'getter callback' => 'entity_plus_metadata_taxonomy_term_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'schema field' => 'description',
+ );
+ $properties['weight'] = array(
+ 'label' => t("Weight"),
+ 'type' => 'integer',
+ 'description' => t('The weight of the term, which is used for ordering terms during display.'),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'schema field' => 'weight',
+ );
+ $properties['node_count'] = array(
+ 'label' => t("Node count"),
+ 'type' => 'integer',
+ 'description' => t("The number of nodes tagged with the taxonomy term."),
+ 'getter callback' => 'entity_plus_metadata_taxonomy_term_get_properties',
+ 'computed' => TRUE,
+ );
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The URL of the taxonomy term."),
+ 'getter callback' => 'entity_plus_metadata_entity_plus_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['vocabulary'] = array(
+ 'label' => t("Vocabulary"),
+ 'description' => t("The vocabulary the taxonomy term belongs to."),
+ 'type' => 'taxonomy_vocabulary',
+ 'setter callback' => 'entity_plus_metadata_taxonomy_term_setter',
+ 'getter callback' => 'entity_plus_metadata_taxonomy_term_get_properties',
+ 'raw getter callback' => 'entity_plus_metadata_taxonomy_term_get_raw_properties',
+ 'required' => TRUE,
+ 'schema field' => 'vocabulary',
+ );
+ $properties['parent'] = array(
+ 'label' => t("Parent terms"),
+ 'description' => t("The parent terms of the taxonomy term."),
+ 'getter callback' => 'entity_plus_metadata_taxonomy_term_get_properties',
+ 'setter callback' => 'entity_plus_metadata_taxonomy_term_setter',
+ 'type' => 'list',
+ );
+ $properties['parents_all'] = array(
+ 'label' => t("All parent terms"),
+ 'description' => t("Ancestors of the term, i.e. parent of all above hierarchy levels."),
+ 'getter callback' => 'entity_plus_metadata_taxonomy_term_get_properties',
+ 'type' => 'list',
+ 'computed' => TRUE,
+ );
+
+ // Add meta-data about the basic vocabulary properties.
+ $properties = &$info['taxonomy_vocabulary']['properties'];
+
+ // Taxonomy vocabulary related variables.
+ $properties['name'] = array(
+ 'label' => t("Name"),
+ 'description' => t("The name of the taxonomy vocabulary."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'required' => TRUE,
+ );
+ $properties['machine_name'] = array(
+ 'label' => t("Machine name"),
+ 'type' => 'token',
+ 'description' => t("The machine name of the taxonomy vocabulary."),
+ 'required' => TRUE,
+ );
+ $properties['hierarchy'] = array(
+ 'label' => t('Hierarchy'),
+ 'type' => 'integer',
+ 'description' => t('The type of hierarchy allowed within the vocabulary.'),
+ );
+ $properties['description'] = array(
+ 'label' => t("Description"),
+ 'description' => t("The optional description of the taxonomy vocabulary."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'sanitize' => 'filter_xss',
+ );
+ $properties['weight'] = array(
+ 'label' => t('Weight'),
+ 'description' => t('The weight of this vocabulary in relation to other vocabularies.'),
+ 'type' => 'integer',
+ );
+ $properties['term_count'] = array(
+ 'label' => t("Term count"),
+ 'type' => 'integer',
+ 'description' => t("The number of terms belonging to the taxonomy vocabulary."),
+ 'getter callback' => 'entity_plus_metadata_taxonomy_vocabulary_get_properties',
+ 'computed' => TRUE,
+ );
+
+ return $info;
+}
diff --git a/www/modules/contrib/entity_plus/modules/user.info.inc b/www/modules/contrib/entity_plus/modules/user.info.inc
new file mode 100644
index 000000000..8d2996893
--- /dev/null
+++ b/www/modules/contrib/entity_plus/modules/user.info.inc
@@ -0,0 +1,109 @@
+ t("User ID"),
+ 'type' => 'integer',
+ 'description' => t("The unique ID of the user account."),
+ 'schema field' => 'uid',
+ );
+ $properties['name'] = array(
+ 'label' => t("Name"),
+ 'description' => t("The login name of the user account."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'sanitize' => 'filter_xss',
+ 'required' => TRUE,
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'name',
+ );
+ $properties['mail'] = array(
+ 'label' => t("Email"),
+ 'description' => t("The email address of the user account."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'validation callback' => 'valid_email_address',
+ 'required' => TRUE,
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'mail',
+ );
+ $properties['url'] = array(
+ 'label' => t("URL"),
+ 'description' => t("The URL of the account profile page."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['edit_url'] = array(
+ 'label' => t("Edit URL"),
+ 'description' => t("The url of the account edit page."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'type' => 'uri',
+ 'computed' => TRUE,
+ );
+ $properties['last_access'] = array(
+ 'label' => t("Last access"),
+ 'description' => t("The date the user last accessed the site."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'type' => 'date',
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'access',
+ );
+ $properties['last_login'] = array(
+ 'label' => t("Last login"),
+ 'description' => t("The date the user last logged in to the site."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'type' => 'date',
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'login',
+ );
+ $properties['created'] = array(
+ 'label' => t("Created"),
+ 'description' => t("The date the user account was created."),
+ 'type' => 'date',
+ 'schema field' => 'created',
+ 'setter permission' => 'administer users',
+ );
+ $properties['roles'] = array(
+ 'label' => t("User roles"),
+ 'description' => t("The roles of the user."),
+ 'type' => 'list',
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'setter callback' => 'entity_plus_metadata_user_set_properties',
+ 'options list' => 'entity_plus_metadata_user_roles',
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ );
+ $properties['status'] = array(
+ 'label' => t("Status"),
+ 'description' => t("Whether the user is active or blocked."),
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ // Although the status is expected to be boolean, its schema suggests
+ // it is an integer, so we follow the schema definition.
+ 'type' => 'integer',
+ 'options list' => 'entity_plus_metadata_user_status_options_list',
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'status',
+ );
+ $properties['theme'] = array(
+ 'label' => t("Default theme"),
+ 'description' => t("The user's default theme."),
+ 'getter callback' => 'entity_plus_metadata_user_get_properties',
+ 'setter callback' => 'entity_plus_property_verbatim_set',
+ 'access callback' => 'entity_plus_metadata_user_properties_access',
+ 'schema field' => 'theme',
+ );
+ return $info;
+}
diff --git a/www/modules/contrib/entity_plus/tests/entity_plus.test b/www/modules/contrib/entity_plus/tests/entity_plus.test
new file mode 100644
index 000000000..ec519b086
--- /dev/null
+++ b/www/modules/contrib/entity_plus/tests/entity_plus.test
@@ -0,0 +1,525 @@
+ $this->randomName(),
+ 'description' => $this->randomName(),
+ 'machine_name' => backdrop_strtolower($this->randomName()),
+ 'weight' => mt_rand(0, 10),
+ ));
+ taxonomy_vocabulary_save($vocabulary);
+ return $vocabulary;
+ }
+
+ /**
+ * Creates a random file of the given type.
+ */
+ protected function createFile($file_type = 'text') {
+ // Create a managed file.
+ $file = current($this->backdropGetTestFiles($file_type));
+
+ // Set additional file properties and save it.
+ $file->filemime = file_get_mimetype($file->filename);
+ $file->uid = 1;
+ $file->timestamp = REQUEST_TIME;
+ $file->filesize = filesize($file->uri);
+ $file->status = 0;
+ $file->save();
+ return $file;
+ }
+}
+
+
+/**
+ * Tests metadata wrappers.
+ */
+class EntityPlusMetadataTestCase extends EntityPlusHelperTestCase {
+
+ function setUp() {
+ parent::setUp('entity_plus', 'locale');
+ // Create a field having 4 values for testing multiple value support.
+ $this->field_name = backdrop_strtolower($this->randomName() . '_field_name');
+ $this->field = array('field_name' => $this->field_name, 'type' => 'text', 'cardinality' => 4);
+ $this->field = field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'node',
+ 'bundle' => 'page',
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ 'settings' => array(
+ 'text_processing' => FALSE,
+ ),
+ 'widget' => array(
+ 'type' => 'text_textfield',
+ 'label' => 'Test Field',
+ 'settings' => array(
+ 'size' => 64,
+ )
+ )
+ );
+ field_create_instance($this->instance);
+
+ // Make the body field and the node type 'page' translatable.
+ $field = field_info_field('body');
+ $field['translatable'] = TRUE;
+ field_update_field($field);
+ // Set page type language.
+ $config = config('node.type.page');
+ $config->set('settings.language', 1);
+ $config->save();
+ }
+
+ /**
+ * Creates a user and a node, then tests getting the properties.
+ */
+ function testEntityMetadataWrapper() {
+ $account = $this->backdropCreateUser();
+ // For testing sanitizing give the user a malicious user name
+ $account->name = 'BadName';
+ $account->save();
+ $title = 'Is it bold?';
+ $body[LANGUAGE_NONE][0] = array('value' => 'The body & nothing.', 'summary' => 'The body.');
+ $node = $this->backdropCreateNode(array('uid' => $account->uid, 'name' => $account->name, 'body' => $body, 'title' => $title, 'summary' => '', 'type' => 'page'));
+
+ // First test without sanitizing.
+ $wrapper = entity_metadata_wrapper('node', $node);
+
+ $this->assertEqual('Is it bold?', $wrapper->title->value(), 'Getting a field value.');
+ $this->assertEqual($node->title, $wrapper->title->raw(), 'Getting a raw property value.');
+
+ // Test chaining.
+ $this->assertEqual($account->mail, $wrapper->author->mail->value(), 'Testing chained usage.');
+ $this->assertEqual($account->name, $wrapper->author->name->value(), 'Testing chained usage with callback and sanitizing.');
+
+ // Test sanitized output.
+ $options = array('sanitize' => TRUE);
+ $this->assertEqual(check_plain('Is it bold?'), $wrapper->title->value($options), 'Getting sanitized field.');
+ $this->assertEqual(filter_xss($node->name), $wrapper->author->name->value($options), 'Getting sanitized property with getter callback.');
+
+ // Test getting an not existing property.
+ try {
+ echo $wrapper->dummy;
+ $this->fail('Getting an not existing property.');
+ }
+ catch (EntityMetadataWrapperException $e) {
+ $this->pass('Getting an not existing property.');
+ }
+
+ // Test setting.
+ $wrapper->author = 0;
+ $this->assertEqual(0, $wrapper->author->uid->value(), 'Setting a property.');
+ try {
+ $wrapper->url = 'dummy';
+ $this->fail('Setting an unsupported property.');
+ }
+ catch (EntityMetadataWrapperException $e) {
+ $this->pass('Setting an unsupported property.');
+ }
+
+ // Test value validation.
+ $this->assertFalse($wrapper->author->name->validate(array(3)), 'Validation correctly checks for valid data types.');
+ try {
+ $wrapper->author->mail = 'foo';
+ $this->fail('An invalid mail address has been set.');
+ }
+ catch (EntityMetadataWrapperException $e) {
+ $this->pass('Setting an invalid mail address throws exception.');
+ }
+ // Test unsetting a required property.
+ try {
+ $wrapper->author = NULL;
+ $this->fail('The required node author has been unset.');
+ }
+ catch (EntityMetadataWrapperException $e) {
+ $this->pass('Unsetting the required node author throws an exception.');
+ }
+
+ // Test setting a referenced entity by id.
+ $wrapper->author->set($GLOBALS['user']->uid);
+ $this->assertEqual($wrapper->author->getIdentifier(), $GLOBALS['user']->uid, 'Get the identifier of a referenced entity.');
+ $this->assertEqual($wrapper->author->uid->value(), $GLOBALS['user']->uid, 'Successfully set referenced entity using the identifier.');
+ // Set by object.
+ $wrapper->author->set($GLOBALS['user']);
+ $this->assertEqual($wrapper->author->uid->value(), $GLOBALS['user']->uid, 'Successfully set referenced entity using the entity.');
+
+
+ // Test getting by the field API processed values like the node body.
+ $body_value = $wrapper->body->value;
+ $this->assertEqual("The body & nothing.
\n", $body_value->value(), "Getting processed value.");
+ $this->assertEqual("The body & nothing.\n", $body_value->value(array('decode' => TRUE)), "Decoded value.");
+ $this->assertEqual("The body & nothing.", $body_value->raw(), "Raw body returned.");
+
+ // Test getting the summary.
+ $this->assertEqual("The body.
\n", $wrapper->body->summary->value(), "Getting body summary.");
+
+ $wrapper->body->set(array('value' => "The second body."));
+ $this->assertEqual("The second body.
\n", $wrapper->body->value->value(), "Setting a processed field value and reading it again.");
+ $this->assertEqual($node->body[LANGUAGE_NONE][0]['value'], "The second body.", 'Update appears in the wrapped entity.');
+ $this->assert(isset($node->body[LANGUAGE_NONE][0]['safe_value']), 'Formatted text has been processed.');
+
+ // Test translating the body on an English node.
+ // Add a new language and optionally set it as default.
+ require_once BACKDROP_ROOT . '/core/includes/locale.inc';
+ $language = (object) array(
+ 'langcode' => 'de',
+ );
+ language_save($language);
+
+ $body['en'][0] = array('value' => 'English body.', 'summary' => 'The body.');
+ $node = $this->backdropCreateNode(array('body' => $body, 'langcode' => 'en', 'type' => 'page'));
+ $wrapper = entity_metadata_wrapper('node', $node);
+
+ $wrapper->language('de');
+
+ $languages = language_list();
+ $this->assertEqual($wrapper->getPropertyLanguage(), $languages['de'], 'Wrapper language has been set to German');
+ $this->assertEqual($wrapper->body->value->value(), "English body.
\n", 'Language fallback on default language.');
+
+ // Set a German text using the wrapper.
+ $wrapper->body->set(array('value' => "Der zweite Text."));
+ $this->assertEqual($wrapper->body->value->value(), "Der zweite Text.
\n", 'German body set and retrieved.');
+
+ $wrapper->language(LANGUAGE_NONE);
+ $this->assertEqual($wrapper->body->value->value(), "English body.
\n", 'Default language text is still there.');
+
+ // Test iterator.
+ $type_info = entity_plus_get_property_info('node');
+ $this->assertFalse(array_diff_key($type_info['properties'], iterator_to_array($wrapper->getIterator())), 'Iterator is working.');
+ foreach ($wrapper as $property) {
+ $this->assertTrue($property instanceof EntityMetadataWrapper, 'Iterate over wrapper properties.');
+ }
+
+ // Test setting a new node.
+ $node->title = 'foo';
+ $wrapper->set($node);
+ $this->assertEqual($wrapper->title->value(), 'foo', 'Changed the wrapped node.');
+
+ // Test getting options lists.
+ $this->assertEqual($wrapper->type->optionsList(), node_type_get_names(), 'Options list returned.');
+
+ // Test making use of a generic 'entity' reference property the
+ // 'entity_plus_test' module provides. The property defaults to the node author.
+/* $this->assertEqual($wrapper->reference->uid->value(), $wrapper->author->getIdentifier(), 'Used generic entity reference property.');
+ // Test updating a property of the generic entity reference.
+ $wrapper->reference->name->set('foo');
+ $this->assertEqual($wrapper->reference->name->value(), 'foo', 'Updated property of generic entity reference');
+ // For testing, just point the reference to the node itself now.
+ $wrapper->reference->set($wrapper);
+ $this->assertEqual($wrapper->reference->nid->value(), $wrapper->getIdentifier(), 'Correctly updated the generic entity referenced property.');
+ */
+ // Test saving and deleting.
+ $wrapper->save();
+ $wrapper->delete();
+ $return = node_load($wrapper->getIdentifier());
+ $this->assertFalse($return, "Node has been successfully deleted.");
+
+ // Ensure changing the bundle changes available wrapper properties.
+ $wrapper->type->set('post');
+ $this->assertTrue(isset($wrapper->field_tags), 'Changing bundle changes available wrapper properties.');
+
+ // Test labels.
+ $user = $this->backdropCreateUser();
+ user_save($user, array('roles' => array()));
+ $wrapper->author = $user->uid;
+ $this->assertEqual($wrapper->label(), $node->title, 'Entity label returned.');
+ $this->assertEqual($wrapper->author->roles[0]->label(), t('Authenticated'), 'Label from options list returned');
+ $this->assertEqual($wrapper->author->roles->label(), t('Authenticated'), 'Label for a list from options list returned');
+ }
+
+ /**
+ * Test supporting multi-valued fields.
+ */
+ function testListMetadataWrappers() {
+ $property = $this->field_name;
+ $values = array();
+ $values[LANGUAGE_NONE][0] = array('value' => '2009-09-05');
+ $values[LANGUAGE_NONE][1] = array('value' => '2009-09-05');
+ $values[LANGUAGE_NONE][2] = array('value' => '2009-08-05');
+
+ $node = $this->backdropCreateNode(array('type' => 'page', $property => $values));
+ $wrapper = entity_metadata_wrapper('node', $node);
+
+ $this->assertEqual('2009-09-05', $wrapper->{$property}[0]->value(), 'Getting array entry.');
+ $this->assertEqual('2009-09-05', $wrapper->{$property}->get(1)->value(), 'Getting array entry.');
+ $this->assertEqual(3, count($wrapper->{$property}->value()), 'Getting the whole array.');
+
+ // Test sanitizing
+ $this->assertEqual(check_plain('2009-09-05'), $wrapper->{$property}[0]->value(array('sanitize' => TRUE)), 'Getting array entry.');
+
+ // Test iterator
+ $this->assertEqual(array_keys(iterator_to_array($wrapper->$property->getIterator())), array_keys($wrapper->$property->value()), 'Iterator is working.');
+ foreach ($wrapper->$property as $p) {
+ $this->assertTrue($p instanceof EntityMetadataWrapper, 'Iterate over list wrapper properties.');
+ }
+
+ // Make sure changing the array changes the actual entity property.
+ $wrapper->{$property}[0] = '2009-10-05';
+ unset($wrapper->{$property}[1], $wrapper->{$property}[2]);
+ $this->assertEqual($wrapper->{$property}->value(), array('2009-10-05'), 'Setting multiple property values.');
+
+ // Test setting an arbitrary list item.
+ $list = array(0 => REQUEST_TIME);
+ $wrapper = entity_metadata_wrapper('list', $list);
+ $wrapper[1] = strtotime('2009-09-05');
+ $this->assertEqual($wrapper->value(), array(REQUEST_TIME, strtotime('2009-09-05')), 'Setting a list item.');
+ $this->assertEqual($wrapper->count(), 2, 'List count is correct.');
+
+ // Test using a list wrapper without data.
+ $wrapper = entity_metadata_wrapper('list');
+ $info = array();
+ foreach ($wrapper as $item) {
+ $info[] = $item->info();
+ }
+ $this->assertTrue($info[0]['type'] == 'date', 'Iterated over empty list wrapper.');
+
+ // Test using a list of entities with a list of term objects.
+ $vocab = $this->createVocabulary();
+ $list = array();
+ $list[] = entity_plus_property_values_create_entity('taxonomy_term', array(
+ 'name' => 'term 1',
+ 'vocabulary' => $vocab->machine_name,
+ ))->save()->value();
+ $list[] = entity_plus_property_values_create_entity('taxonomy_term', array(
+ 'name' => 'term 2',
+ 'vocabulary' => $vocab->machine_name,
+ ))->save()->value();
+ $wrapper = entity_metadata_wrapper('list', $list);
+ $this->assertTrue($wrapper[0]->name->value() == 'term 1', 'Used a list of entities.');
+ // Test getting a list of identifiers.
+ $ids = $wrapper->value(array('identifier' => TRUE));
+ $this->assertTrue(!is_object($ids[0]), 'Get a list of entity ids.');
+
+ $wrapper = entity_metadata_wrapper('list', $ids);
+ $this->assertTrue($wrapper[0]->name->value() == 'term 1', 'Created a list of entities with ids.');
+
+ // Test with a list of generic entities. The list is expected to be a list
+ // of entity wrappers, otherwise the entity type is unknown.
+ $node = $this->backdropCreateNode(array('title' => 'node 1'));
+ $list = array();
+ $list[] = entity_metadata_wrapper('node', $node);
+ $wrapper = entity_metadata_wrapper('list', $list);
+ $this->assertEqual($wrapper[0]->title->value(), 'node 1', 'Wrapped node was found in generic list of entities.');
+ }
+
+ /**
+ * Tests using the wrapper without any data.
+ */
+ function testWithoutData() {
+ $wrapper = entity_metadata_wrapper('node', NULL, array('bundle' => 'page'));
+ $this->assertTrue(isset($wrapper->title), 'Bundle properties have been added.');
+ $info = $wrapper->author->mail->info();
+ $this->assertTrue(!empty($info) && is_array($info) && isset($info['label']), 'Property info returned.');
+ }
+
+ /**
+ * Test using access() method.
+ */
+ function testAccess() {
+ // Test without data.
+ $account = $this->backdropCreateUser(array('bypass node access'));
+ $this->assertTrue(entity_plus_access('view', 'node', NULL, $account), 'Access without data checked.');
+
+ // Test with actual data.
+ $values[LANGUAGE_NONE][0] = array('value' => '2009-09-05');
+ $values[LANGUAGE_NONE][1] = array('value' => '2009-09-05');
+ $node = $this->backdropCreateNode(array('type' => 'page', $this->field_name => $values));
+ $this->assertTrue(entity_plus_access('delete', 'node', $node, $account), 'Access with data checked.');
+
+ // Test per property access without data.
+ $account2 = $this->backdropCreateUser(array('bypass node access', 'administer nodes'));
+ $wrapper = entity_metadata_wrapper('node', NULL, array('bundle' => 'page'));
+ $this->assertTrue($wrapper->access('edit', $account), 'Access to node granted.');
+ $this->assertFalse($wrapper->status->access('edit', $account), 'Access for admin property denied.');
+ $this->assertTrue($wrapper->status->access('edit', $account2), 'Access for admin property allowed for the admin.');
+
+ // Test per property access with data.
+ $wrapper = entity_metadata_wrapper('node', $node, array('bundle' => 'page'));
+ $this->assertFalse($wrapper->status->access('edit', $account), 'Access for admin property denied.');
+ $this->assertTrue($wrapper->status->access('edit', $account2), 'Access for admin property allowed for the admin.');
+
+ // Test field level access.
+ $this->assertTrue($wrapper->{$this->field_name}->access('view'), 'Field access granted.');
+
+ // Create node owned by anonymous and test access() method on each of its
+ // properties.
+ $node = $this->backdropCreateNode(array('type' => 'page', 'uid' => 0));
+ $wrapper = entity_metadata_wrapper('node', $node->nid);
+ foreach ($wrapper as $name => $property) {
+ $property->access('view');
+ }
+
+ // Property access of entity references takes entity access into account.
+ $node = $this->backdropCreateNode(array('type' => 'post'));
+ $wrapper = entity_metadata_wrapper('node', $node);
+ $unprivileged_user = $this->backdropCreateUser();
+ $privileged_user = $this->backdropCreateUser(array('access user profiles'));
+
+ $this->assertTrue($wrapper->author->access('view', $privileged_user));
+ $this->assertFalse($wrapper->author->access('view', $unprivileged_user));
+
+ // Ensure the same works with multiple entity references by testing the
+ // $node->field_tags example.
+ $privileged_user = $this->backdropCreateUser(array('administer taxonomy'));
+ // Terms are view-able with access content, so make sure to remove this
+ // permission first.
+ user_role_revoke_permissions(BACKDROP_ANONYMOUS_ROLE, array('access content'));
+ $unprivileged_user = backdrop_anonymous_user();
+
+ $this->assertTrue($wrapper->field_tags->access('view', $privileged_user), 'Privileged user has access.');
+ $this->assertTrue($wrapper->field_tags->access('view', $unprivileged_user), 'Unprivileged user has access.');
+ $this->assertTrue($wrapper->field_tags[0]->access('view', $privileged_user), 'Privileged user has access.');
+ $this->assertFalse($wrapper->field_tags[0]->access('view', $unprivileged_user), 'Unprivileged user has no access.');
+ }
+
+ /**
+ * Tests using a data structure with passed in metadata.
+ */
+ function testDataStructureWrapper() {
+ $log_entry = array(
+ 'type' => 'entity',
+ 'message' => $this->randomName(8),
+ 'variables' => array(),
+ 'severity' => WATCHDOG_NOTICE,
+ 'link' => '',
+ 'user' => $GLOBALS['user'],
+ );
+ $info['property info'] = array(
+ 'type' => array('type' => 'text', 'label' => 'The category to which this message belongs.'),
+ 'message' => array('type' => 'text', 'label' => 'The log message.'),
+ 'user' => array('type' => 'user', 'label' => 'The user causing the log entry.'),
+ );
+ $wrapper = entity_metadata_wrapper('log_entry', $log_entry, $info);
+ $this->assertEqual($wrapper->user->name->value(), $GLOBALS['user']->name, 'Wrapped custom entity.');
+ }
+
+ /**
+ * Tests using entity_plus_property_query().
+ */
+ function testEntityQuery() {
+ // Creat a test node.
+ $title = 'Is it bold?';
+ $values[LANGUAGE_NONE][0] = array('value' => 'foo');
+ $node = $this->backdropCreateNode(array($this->field_name => $values, 'title' => $title, 'uid' => $GLOBALS['user']->uid));
+
+ $results = entity_plus_property_query('node', 'title', $title);
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a given title.');
+
+ $results = entity_plus_property_query('node', $this->field_name, 'foo');
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a given field value.');
+
+ $results = entity_plus_property_query('node', $this->field_name, array('foo', 'bar'));
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a list of possible values.');
+
+ $results = entity_plus_property_query('node', 'author', $GLOBALS['user']->uid);
+ // Backdrop provides 2 nodes with uid == 1 on clean install, meaning that assertEqual will not work
+ $this->assertTrue(in_array($node->nid, $results), 'Queried nodes with a given author.');
+
+ // Create another test node and try querying for tags.
+ $vocab = $this->createVocabulary();
+ $tag = entity_plus_property_values_create_entity('taxonomy_term', array(
+ 'name' => $this->randomName(),
+ 'vocabulary' => $vocab->machine_name,
+ ))->save();
+ $field_tag_value[LANGUAGE_NONE][0]['tid'] = $tag->getIdentifier();
+ $node = $this->backdropCreateNode(array('type' => 'post', 'field_tags' => $field_tag_value));
+
+ // Try query-ing with a single value.
+ $results = entity_plus_property_query('node', 'field_tags', $tag->getIdentifier());
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a given term id.');
+
+ $results = entity_plus_property_query('node', 'field_tags', $tag->value());
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a given term object.');
+
+ // Try query-ing with a list of possible values.
+ $results = entity_plus_property_query('node', 'field_tags', array($tag->getIdentifier()));
+ $this->assertEqual($results, array($node->nid), 'Queried nodes with a list of term ids.');
+ }
+
+ /**
+ * Tests serializing data wrappers, in particular for EntityBackdropWrapper.
+ */
+ function testWrapperSerialization() {
+ $node = $this->backdropCreateNode();
+ $wrapper = entity_metadata_wrapper('node', $node);
+ $this->assertTrue($wrapper->value() == $node, 'Data correctly wrapped.');
+
+ // Test serializing and make sure only the node id is stored.
+ $this->assertTrue(strpos(serialize($wrapper), $node->title) === FALSE, 'Node has been correctly serialized.');
+ $this->assertEqual(unserialize(serialize($wrapper))->title->value(), $node->title, 'Serializing works right.');
+
+ $wrapper2 = unserialize(serialize($wrapper));
+ // Test serializing the unloaded wrapper.
+ $this->assertEqual(unserialize(serialize($wrapper2))->title->value(), $node->title, 'Serializing works right.');
+
+ // Test loading a not more existing node.
+ $s = serialize($wrapper2);
+ node_delete($node->nid);
+ $this->assertFalse(node_load($node->nid), 'Node deleted.');
+
+ $value = unserialize($s)->value();
+ $this->assertNull($value, 'Tried to load not existing node.');
+ }
+
+ /**
+ * Tests the functionality of the vocabulary metadata wrapper.
+ */
+ public function testEntityVocabularyWrapper() {
+ $vocabulary = $this->createVocabulary();
+ $machine_name = $vocabulary->machine_name;
+ $vocabulary_wrapper = emw('taxonomy_vocabulary', $vocabulary);
+
+ // Check that wrapper has correctly wrapped the vocabulary.
+ $this->assertEqual($vocabulary, $vocabulary_wrapper->value(), 'Vocabulary wrapper value matches the original vocabulary.');
+
+ // Access the name property.
+ $this->assertEqual($vocabulary->name, $vocabulary_wrapper->name->value(), 'Vocabulary name matches the value of the name property of the wrapper.');
+
+ // Change the name property via wrapper.
+ $new_name = 'A new name';
+ $vocabulary_wrapper->name = $new_name;
+ $this->assertEqual($new_name, $vocabulary_wrapper->name->value(), 'Vocabulary name was changed correctly.');
+
+ // Save vocabulary with wrapper save method.
+ $vocabulary_wrapper->save();
+
+ // Assign NULL to 'original' to allow comparison.
+ // Note: after https://github.com/backdrop/backdrop-issues/issues/6197, all
+ // vocabulary objects contain an `original` property with a null value
+ // by default.
+ $vocabulary->original = NULL;
+ $vocabulary2 = taxonomy_vocabulary_load($machine_name);
+ $this->assertEqual($vocabulary2, $vocabulary, 'Vocabulary was saved correctly.');
+
+ // Lazy-load a vocabulary.
+ $vocabulary_wrapper2 = emw('taxonomy_vocabulary', $machine_name);
+ $this->assertEqual($vocabulary, $vocabulary_wrapper2->value(), 'Wrapper lazy-loaded the vocabulary from machine_name.');
+
+ // Access the vocabulary and vocabulary name via a term.
+ $term_wrapper = entity_plus_property_values_create_entity('taxonomy_term', array('vocabulary' => $machine_name, 'name' => 'A term',));
+ $this->assertEqual($term_wrapper->vocabulary->value(), $vocabulary, 'Accessed the vocabulary object through a term wrapper.');
+ $this->assertEqual($term_wrapper->vocabulary->name->value(), $vocabulary->name, 'Accessed the vocabulary name through a term wrapper.');
+
+ // Test the term_count calculated property of the wrapper.
+ $term_wrapper->save();
+ $efq = new EntityFieldQuery();
+ $count = $efq->entityCondition('entity_type', 'taxonomy_term')
+ ->propertyCondition('vocabulary', $machine_name)
+ ->count()->execute();
+ $this->assertEqual($vocabulary_wrapper->term_count->value(), $count, 'Correctly calculated the total number of terms in vocabulary.');
+ }
+}
diff --git a/www/modules/contrib/entity_plus/tests/entity_plus.tests.info b/www/modules/contrib/entity_plus/tests/entity_plus.tests.info
new file mode 100644
index 000000000..1b1e5a8d2
--- /dev/null
+++ b/www/modules/contrib/entity_plus/tests/entity_plus.tests.info
@@ -0,0 +1,10 @@
+[EntityPlusMetadataTestCase]
+name = Entity Plus
+description = Tests for Entity Plus functionality.
+group = Entity Plus
+file = entity_plus.test
+
+; Added by Backdrop CMS packaging script on 2026-01-06
+project = entity_plus
+version = 1.x-1.0.23
+timestamp = 1767717936
diff --git a/www/modules/contrib/entity_plus/theme/entity_plus.theme.css b/www/modules/contrib/entity_plus/theme/entity_plus.theme.css
new file mode 100644
index 000000000..b74498826
--- /dev/null
+++ b/www/modules/contrib/entity_plus/theme/entity_plus.theme.css
@@ -0,0 +1,16 @@
+
+/* Property display */
+
+.entity-property-label {
+ font-weight: bold;
+}
+
+.entity-property-inline .entity-property-label,
+.entity-property-inline .entity-property-item {
+ float:left; /*LTR*/
+}
+
+[dir="rtl"] .entity-property-inline .entity-property-label,
+[dir="rtl"] .entity-property-inline .entity-property-item {
+ float:left; /*LTR*/
+}
diff --git a/www/modules/contrib/entity_plus/theme/entity_plus.theme.inc b/www/modules/contrib/entity_plus/theme/entity_plus.theme.inc
new file mode 100644
index 000000000..ccd342b60
--- /dev/null
+++ b/www/modules/contrib/entity_plus/theme/entity_plus.theme.inc
@@ -0,0 +1,231 @@
+ 'EntityDefaultExtraFieldsController'
+ * within hook_entity_info for a given custom entity will call this theme to render all its properties.
+ *
+ * @param array $variables
+ * An associative array containing:
+ * - label_hidden: A boolean indicating to show or hide the property label.
+ * - label: The label for the property
+ * - property_name: The name of the property
+ * - content: The rendered property value.
+ * - title_attributes_array: An array containing the attributes for the title.
+ * - content_attributes_array: An array containing the attributes for the content's
+ * div.
+ * - attributes_array: A string containing the attributes for the top-level wrapping div.
+ *
+ * @ingroup themeable
+ */
+function theme_entity_plus_property($variables) {
+ $output = '';
+
+ $attributes = empty($variables['attributes_array']) ? '' : backdrop_attributes($variables['attributes_array']);
+ $title_attributes = empty($variables['title_attributes_array']) ? '' : backdrop_attributes($variables['title_attributes_array']);
+ $content_attributes = empty($variables['content_attributes_array']) ? '' : backdrop_attributes($variables['content_attributes_array']);
+
+ // Render the label, if it's not hidden.
+ if (!$variables['label_hidden']) {
+ $output .= '' . $variables['label'] . ':
';
+ }
+
+ // Render the content.
+ $content_suffix = '';
+ if (!$variables['label_hidden'] || $content_attributes) {
+ $output .= '';
+ $content_suffix = '
';
+ }
+ $output .= $variables['content'] . $content_suffix;
+
+ // Render the top-level DIV.
+ return '' . $output . '
';
+}
+
+
+/**
+ * Theme preprocess function for theme_entity_plus_property().
+ *
+ * @see theme_entity_plus_property()
+ */
+function template_preprocess_entity_plus_property(&$variables, $hook) {
+ $element = $variables['elements'];
+
+ $variables += array(
+ 'theme_hook_suggestions' => array(),
+ 'attributes_array' => array(),
+ );
+ // Generate variables from element properties.
+ foreach (array('label_hidden', 'label', 'property_name') as $name) {
+ if (isset($element['#' . $name])) {
+ $variables[$name] = check_plain($element['#' . $name]);
+ }
+ }
+
+ if (isset($element['#label_display'])) {
+ $variables['label_hidden'] = ($element['#label_display'] == 'hidden');
+ }
+
+ $variables['title_attributes_array']['class'][] = 'entity-property-label';
+ $variables['content_attributes_array']['class'][] = 'entity-property-item';
+ $variables['attributes_array'] = array_merge($variables['attributes_array'], isset($element['#attributes']) ? $element['#attributes'] : array());
+
+ $variables['property_name_css'] = strtr($element['#property_name'], '_', '-');
+ $variables['attributes_array']['class'][] = 'entity-property';
+ $variables['attributes_array']['class'][] = 'entity-property-' . $variables['property_name_css'];
+
+ if (isset($element['#label_display']) && $element['#label_display'] == 'inline') {
+ $variables['attributes_array']['class'][] = 'entity-property-' . $element['#label_display'];
+ $variables['attributes_array']['class'][] = 'clearfix';
+ }
+
+ // Add specific suggestions that can override the default implementation.
+ $variables['theme_hook_suggestions'] += array(
+ 'entity_plus_property__' . $element['#property_name'],
+ 'entity_plus_property__' . $element['#entity_type'] . '__' . $element['#property_name'],
+ );
+ // Populate the content with sensible defaults.
+ if (!isset($element['#content'])) {
+ $variables['content'] = entity_plus_property_default_render_value_by_type($element['#entity_plus_wrapped']->{$element['#property_name']});
+ }
+ else {
+ $variables['content'] = $element['#content'];
+ }
+}
+
+/**
+ * Renders a property using simple defaults based upon the property type.
+ *
+ * @return string
+ * The rendered property value.
+ */
+function entity_plus_property_default_render_value_by_type(EntityMetadataWrapper $property) {
+ // If there is an options list or entity label, render that by default.
+ if ($label = $property->label()) {
+ if ($property instanceof EntityBackdropWrapper && $uri = entity_uri($property->type(), $property->value())) {
+ return l($label, $uri['path'], $uri['options']);
+ }
+ else {
+ return check_plain($label);
+ }
+ }
+ switch ($property->type()) {
+ case 'boolean':
+ return $property->value() ? t('yes') : t('no');
+
+ default:
+ return check_plain($property->value());
+ }
+}
+
+/**
+ * Process variables for entity_plus.tpl.php.
+ */
+function template_preprocess_entity_plus(&$variables) {
+ $variables['view_mode'] = $variables['elements']['#view_mode'];
+ // Backwards compatibility is included below for potential contrib or custom
+ // code that adapted to the non-standard `entity_plus_type' key.
+ // See https://github.com/backdrop-contrib/entity_plus/issues/44.
+ $entity_type = !empty($variables['elements']['#entity_type']) ? $variables['elements']['#entity_type'] : $variables['elements']['#entity_plus_type'];
+ $variables['entity_type'] = $entity_type;
+ $entity = $variables['elements']['#entity'];
+ $variables[$entity_type] = $entity;
+ $info = entity_get_info($entity_type);
+
+ $variables['title'] = check_plain(entity_label($entity_type, $entity));
+
+ $uri = entity_uri($entity_type, $entity);
+ $variables['url'] = $uri && !empty($uri['path']) ? url($uri['path'], $uri['options']) : FALSE;
+
+ if (isset($variables['elements']['#page'])) {
+ // If set by the caller, respect the page property.
+ $variables['page'] = $variables['elements']['#page'];
+ }
+ else {
+ // Else, try to automatically detect it.
+ $variables['page'] = $uri && !empty($uri['path']) && $uri['path'] == $_GET['q'];
+ }
+
+ // Helpful $content variable for templates.
+ $variables['content'] = array();
+ foreach (element_children($variables['elements']) as $key) {
+ $variables['content'][$key] = $variables['elements'][$key];
+ }
+
+ if (!empty($info['fieldable'])) {
+ // Make the field variables available with the appropriate language.
+ field_attach_preprocess($entity_type, $entity, $variables['content'], $variables);
+ }
+ list(, , $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Set 'classes_array' as an array if it isn't set.
+ if (!isset($variables['classes_array'])) {
+ $variables['classes_array'] = array();
+ };
+ // Merge the two class arrays.
+ $css_classes = array_merge($variables['classes'], $variables['classes_array']);
+ $css_classes[] = backdrop_html_class('entity-' . $entity_type);
+ $css_classes[] = backdrop_html_class($entity_type . '-' . $bundle);
+
+ // Add css classes to the standard 'classes' array used by Backdrop entities.
+ // For backward compatibility, add the same classes to 'classes_array'.
+ $variables['classes'] = $variables['classes_array'] = $css_classes;
+
+ // Add RDF type and about URI.
+ if (module_exists('rdf')) {
+ $variables['attributes_array']['about'] = empty($uri['path']) ? NULL : url($uri['path']);
+ $variables['attributes_array']['typeof'] = empty($entity->rdf_mapping['rdftype']) ? NULL : $entity->rdf_mapping['rdftype'];
+ }
+
+ // Add suggestions.
+ $variables['theme_hook_suggestions'][] = $entity_type;
+ $variables['theme_hook_suggestions'][] = $entity_type . '__' . $bundle;
+ $variables['theme_hook_suggestions'][] = $entity_type . '__' . $bundle . '__' . $variables['view_mode'];
+ if ($id = entity_plus_id($entity_type, $entity)) {
+ $variables['theme_hook_suggestions'][] = $entity_type . '__' . $id;
+ }
+}
+
+/**
+ * Themes the exportable status of an entity.
+ */
+function theme_entity_plus_status($variables) {
+ $status = $variables['status'];
+ $html = $variables['html'];
+ if (($status & ENTITY_PLUS_FIXED) == ENTITY_PLUS_FIXED) {
+ $label = t('Fixed');
+ $help = t('The configuration is fixed and cannot be changed.');
+ return $html ? "" . $label . "" : $label;
+ }
+ elseif (($status & ENTITY_PLUS_OVERRIDDEN) == ENTITY_PLUS_OVERRIDDEN) {
+ $label = t('Overridden');
+ $help = t('This configuration is provided by a module, but has been changed.');
+ return $html ? "" . $label . "" : $label;
+ }
+ elseif ($status & ENTITY_PLUS_IN_CODE) {
+ $label = t('Default');
+ $help = t('A module provides this configuration.');
+ return $html ? "" . $label . "" : $label;
+ }
+ elseif ($status & ENTITY_PLUS_CUSTOM) {
+ $label = t('Custom');
+ $help = t('A custom configuration by a user.');
+ return $html ? "" . $label . "" : $label;
+ }
+}
diff --git a/www/modules/contrib/entity_plus/theme/entity_plus.tpl.php b/www/modules/contrib/entity_plus/theme/entity_plus.tpl.php
new file mode 100644
index 000000000..bd5b19803
--- /dev/null
+++ b/www/modules/contrib/entity_plus/theme/entity_plus.tpl.php
@@ -0,0 +1,47 @@
+
+>
+
+
>
+
+
+
+
+
+
+
+
+
>
+
+
+
diff --git a/www/modules/contrib/entity_plus/views/entity_plus.views.inc b/www/modules/contrib/entity_plus/views/entity_plus.views.inc
new file mode 100644
index 000000000..beb8e5c37
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/entity_plus.views.inc
@@ -0,0 +1,723 @@
+ $info) {
+ // Provide default integration with the basic controller class if we know
+ // the module providing the entity and it does not provide views integration.
+ if (!isset($info['views controller class'])) {
+ $info['views controller class'] = isset($info['module']) && !module_hook($info['module'], 'views_data') ? 'EntityPlusDefaultViewsController' : FALSE;
+ }
+ if ($info['views controller class']) {
+ $controller = new $info['views controller class']($type);
+ // Relationship data may return views data for already existing tables,
+ // so merge results on the second level.
+ foreach ($controller->views_data() as $table => $table_data) {
+ $data += array($table => array());
+ $data[$table] = array_merge($data[$table], $table_data);
+ }
+ }
+ }
+
+ // Add tables based upon data selection "queries" for all entity types.
+ foreach (entity_get_info() as $type => $info) {
+ $table = entity_plus_views_table_definition($type);
+ if ($table) {
+ $data['entity_' . $type] = $table;
+ }
+ // Generally expose properties marked as 'entity views field'.
+ $data['views_entity_' . $type] = array();
+ foreach (entity_plus_get_all_property_info($type) as $key => $property) {
+ if (!empty($property['entity views field'])) {
+ entity_plus_views_field_definition($key, $property, $data['views_entity_' . $type]);
+ }
+ }
+ }
+
+ // Expose generally usable entity-related fields.
+ foreach (entity_get_info() as $entity_type => $info) {
+ if (entity_plus_type_supports($entity_type, 'view')) {
+ // Expose a field allowing to display the rendered entity.
+ $data['views_entity_' . $entity_type]['rendered_entity'] = array(
+ 'title' => t('Rendered @entity-type', array('@entity-type' => $info['label'])),
+ 'help' => t('The @entity-type of the current relationship rendered using a view mode.', array('@entity-type' => $info['label'])),
+ 'field' => array(
+ 'handler' => 'entity_plus_views_handler_field_entity',
+ 'type' => $entity_type,
+ // The EntityPlusFieldHandlerHelper treats the 'entity object' data
+ // selector as special case for loading the base entity.
+ 'real field' => 'entity object',
+ ),
+ );
+ }
+ }
+
+ $data['entity__global']['table']['group'] = t('Entity');
+ $data['entity__global']['table']['join'] = array(
+ // #global let's it appear all the time.
+ '#global' => array(),
+ );
+ $data['entity__global']['entity'] = array(
+ 'title' => t('Rendered entity'),
+ 'help' => t('Displays a single chosen entity.'),
+ 'area' => array(
+ 'handler' => 'entity_plus_views_handler_area_entity',
+ ),
+ );
+
+ return $data;
+}
+
+/**
+ * Helper function for getting data selection based entity Views table definitions.
+ *
+ * This creates extra tables for each entity type that are not associated with a
+ * query plugin (and thus are not base tables) and just rely on the entities to
+ * retrieve the displayed data. To obtain the entities corresponding to a
+ * certain result set, the field handlers defined on the table use a generic
+ * interface defined for query plugins that are based on entity handling, and
+ * which is described in the entity_views_example_query class.
+ *
+ * These tables are called "data selection tables".
+ *
+ * Other modules providing Views integration with new query plugins that are
+ * based on entities can then use these tables as a base for their own tables
+ * (by directly using this method and modifying the returned table) and/or by
+ * specifying relationships to them. The tables returned here already specify
+ * relationships to each other wherever an entity contains a reference to
+ * another (e.g., the node author constructs a relationship from nodes to
+ * users).
+ *
+ * As filtering and other query manipulation is potentially more plugin-specific
+ * than the display, only field handlers and relationships are provided with
+ * these tables. By providing a add_selector_orderby() method, the query plugin
+ * can, however, support click-sorting for the field handlers in these tables.
+ *
+ * For a detailed discussion see http://drupal.org/node/1266036
+ *
+ * For example use see the Search API views module in the Search API project:
+ * http://drupal.org/project/search_api
+ *
+ * @param $type
+ * The entity type whose table definition should be returned.
+ * @param $exclude
+ * Whether properties already exposed as 'entity views field' should be
+ * excluded. Defaults to TRUE, as they are available for all views tables for
+ * the entity type anyways.
+ *
+ * @return
+ * An array containing the data selection Views table definition for the
+ * entity type.
+ *
+ * @see entity_views_field_definition()
+ */
+function entity_plus_views_table_definition($type, $exclude = TRUE) {
+ // As other modules might want to copy these tables as a base for their own
+ // Views integration, we statically cache the tables to save some time.
+ $tables = &backdrop_static(__FUNCTION__, array());
+
+ if (!isset($tables[$type])) {
+ // Work-a-round to fix updating, see http://drupal.org/node/1330874.
+ // Views data might be rebuilt on update.php before the registry is rebuilt,
+ // thus the class cannot be auto-loaded.
+ if (!class_exists('EntityPlusFieldHandlerHelper')) {
+ module_load_include('inc', 'entity_plus', 'views/handlers/entity_plus_views_field_handler_helper');
+ }
+
+ $info = entity_get_info($type);
+ $tables[$type]['table'] = array(
+ 'group' => $info['label'],
+ 'entity type' => $type,
+ );
+ foreach (entity_plus_get_all_property_info($type) as $key => $property) {
+ if (!$exclude || empty($property['entity views field'])) {
+ entity_plus_views_field_definition($key, $property, $tables[$type]);
+ }
+ }
+ }
+
+ return $tables[$type];
+}
+
+/**
+ * Helper function for adding a Views field definition to data selection based Views tables.
+ *
+ * @param $field
+ * The data selector of the field to add. E.g. "title" would derive the node
+ * title property, "body:summary" the node body's summary.
+ * @param array $property_info
+ * The property information for which to create a field definition.
+ * @param array $table
+ * The table into which the definition should be inserted.
+ * @param $title_prefix
+ * Internal use only.
+ *
+ * @see entity_views_table_definition()
+ */
+function entity_plus_views_field_definition($field, array $property_info, array &$table, $title_prefix = '') {
+ $additional = array();
+ $additional_field = array();
+
+ // Create a valid Views field identifier (no colons, etc.). Keep the original
+ // data selector as real field though.
+ $key = _entity_plus_views_field_identifier($field, $table);
+ if ($key != $field) {
+ $additional['real field'] = $field;
+ }
+ $field_name = EntityPlusFieldHandlerHelper::get_selector_field_name($field);
+
+ $field_handlers = entity_plus_views_get_field_handlers();
+
+ $property_info += entity_plus_property_info_defaults();
+ $type = entity_plus_property_extract_innermost_type($property_info['type']);
+ $title = $title_prefix . $property_info['label'];
+ if ($info = entity_get_info($type)) {
+ $additional['relationship'] = array(
+ 'handler' => $field_handlers['relationship'],
+ 'base' => 'entity_' . $type,
+ 'base field' => $info['entity keys']['id'],
+ 'relationship field' => $field,
+ 'label' => $title,
+ );
+ if ($property_info['type'] != $type) {
+ // This is a list of entities, so we should mark the relationship as such.
+ $additional['relationship']['multiple'] = TRUE;
+ }
+ // Implementers of the field handlers alter hook could add handlers for
+ // specific entity types.
+ if (!isset($field_handlers[$type])) {
+ $type = 'entity';
+ }
+ }
+ elseif (!empty($property_info['field'])) {
+ $type = 'field';
+ // Views' Field API field handler needs some extra definitions to work.
+ $additional_field['field_name'] = $field_name;
+ $additional_field['entity_tables'] = array();
+ $additional_field['entity type'] = $table['table']['entity type'];
+ $additional_field['is revision'] = FALSE;
+ }
+ // Copied from EntityMetadataWrapper::optionsList()
+ elseif (isset($property_info['options list']) && is_callable($property_info['options list'])) {
+ // If this is a nested property, we need to get rid of all prefixes first.
+ $type = 'options';
+ $additional_field['options callback'] = array(
+ 'function' => $property_info['options list'],
+ 'info' => $property_info,
+ );
+ }
+ elseif ($type == 'decimal') {
+ $additional_field['float'] = TRUE;
+ }
+
+ if (isset($field_handlers[$type])) {
+ $table += array($key => array());
+ $table[$key] += array(
+ 'title' => $title,
+ 'help' => empty($property_info['description']) ? t('(No information available)') : $property_info['description'],
+ 'field' => array(),
+ );
+ $table[$key]['field'] += array(
+ 'handler' => $field_handlers[$type],
+ 'type' => $property_info['type'],
+ );
+ $table[$key] += $additional;
+ $table[$key]['field'] += $additional_field;
+ }
+ if (!empty($property_info['property info'])) {
+ foreach ($property_info['property info'] as $nested_key => $nested_property) {
+ entity_plus_views_field_definition($field . ':' . $nested_key, $nested_property, $table, $title . ' » ');
+ }
+ }
+}
+
+/**
+ * @return array
+ * The handlers to use for the data selection based Views tables.
+ *
+ * @see hook_entity_views_field_handlers_alter()
+ */
+function entity_plus_views_get_field_handlers() {
+ $field_handlers = backdrop_static(__FUNCTION__);
+ if (!isset($field_handlers)) {
+ // Field handlers for the entity tables, by type.
+ $field_handlers = array(
+ 'text' => 'entity_plus_views_handler_field_text',
+ 'token' => 'entity_plus_views_handler_field_text',
+ 'integer' => 'entity_plus_views_handler_field_numeric',
+ 'decimal' => 'entity_plus_views_handler_field_numeric',
+ 'date' => 'entity_plus_views_handler_field_date',
+ 'duration' => 'entity_plus_views_handler_field_duration',
+ 'boolean' => 'entity_plus_views_handler_field_boolean',
+ 'uri' => 'entity_plus_views_handler_field_uri',
+ 'options' => 'entity_plus_views_handler_field_options',
+ 'field' => 'entity_plus_views_handler_field_field',
+ 'entity' => 'entity_plus_views_handler_field_entity',
+ 'relationship' => 'entity_plus_views_handler_relationship',
+ );
+ backdrop_alter('entity_plus_views_field_handlers', $field_handlers);
+ }
+ return $field_handlers;
+}
+
+/**
+ * Helper function for creating valid Views field identifiers out of data selectors.
+ *
+ * Uses $table to test whether the identifier is already used, and also
+ * recognizes if a definition for the same field is already present and returns
+ * that definition's identifier.
+ *
+ * @return string
+ * A valid Views field identifier that is not yet used as a key in $table.
+ */
+function _entity_plus_views_field_identifier($field, array $table) {
+ $key = $base = preg_replace('/[^a-zA-Z0-9]+/S', '_', $field);
+ $i = 0;
+ // The condition checks whether this sanitized field identifier is already
+ // used for another field in this table (and whether the identifier is
+ // "table", which can never be used).
+ // If $table[$key] is set, the identifier is already used, but this might be
+ // already for the same field. To test that, we need the original field name,
+ // which is either $table[$key]['real field'], if set, or $key. If this
+ // original field name is equal to $field, we can use that key. Otherwise, we
+ // append numeric suffixes until we reach an unused key.
+ while ($key == 'table' || (isset($table[$key]) && (isset($table[$key]['real field']) ? $table[$key]['real field'] : $key) != $field)) {
+ $key = $base . '_' . ++$i;
+ }
+ return $key;
+}
+
+/**
+ * Implements hook_views_plugins().
+ */
+function entity_plus_views_plugins() {
+ // Have views cache the table list for us so it gets
+ // cleared at the appropriate times.
+ $data = views_cache_get('entity_base_tables', TRUE);
+ if (!empty($data->data)) {
+ $base_tables = $data->data;
+ }
+ else {
+ $base_tables = array();
+ foreach (views_fetch_data() as $table => $data) {
+ if (!empty($data['table']['entity type']) && !empty($data['table']['base'])) {
+ $base_tables[] = $table;
+ }
+ }
+ views_cache_set('entity_base_tables', $base_tables, TRUE);
+ }
+ if (!empty($base_tables)) {
+ return array(
+ 'module' => 'entity_plus',
+ 'row' => array(
+ 'entity' => array(
+ 'title' => t('Rendered entity'),
+ 'help' => t('Renders a single entity in a specific view mode (e.g. teaser).'),
+ 'handler' => 'entity_plus_views_plugin_row_entity_view',
+ 'uses fields' => FALSE,
+ 'uses options' => TRUE,
+ 'type' => 'normal',
+ 'base' => $base_tables,
+ ),
+ ),
+ );
+ }
+}
+
+/**
+ * Default controller for generating basic views integration.
+ *
+ * The controller tries to generate suiting views integration for the entity
+ * based upon the schema information of its base table and the provided entity
+ * property information.
+ * For that it is possible to map a property name to its schema/views field
+ * name by adding a 'schema field' key with the name of the field as value to
+ * the property info.
+ */
+class EntityPlusDefaultViewsController {
+
+ protected $type, $info, $relationships;
+
+ public function __construct($type) {
+ $this->type = $type;
+ $this->info = entity_get_info($type);
+ }
+
+ /**
+ * Defines the result for hook_views_data().
+ */
+ public function views_data() {
+ $data = array();
+ $this->relationships = array();
+
+ if (!empty($this->info['base table'])) {
+ $table = $this->info['base table'];
+ // Define the base group of this table. Fields that don't
+ // have a group defined will go into this field by default.
+ $data[$table]['table']['group'] = backdrop_ucfirst($this->info['label']);
+ $data[$table]['table']['entity type'] = $this->type;
+
+ // If the plural label isn't available, use the regular label.
+ $label = isset($this->info['plural label']) ? $this->info['plural label'] : $this->info['label'];
+ $data[$table]['table']['base'] = array(
+ 'field' => $this->info['entity keys']['id'],
+ 'access query tag' => $this->type . '_access',
+ 'title' => backdrop_ucfirst($label),
+ 'help' => isset($this->info['description']) ? $this->info['description'] : '',
+ );
+ $data[$table]['table']['entity type'] = $this->type;
+ $data[$table] += $this->schema_fields();
+
+ // Add in any reverse-relationships which have been determined.
+ $data += $this->relationships;
+ }
+ if (!empty($this->info['revision table']) && !empty($this->info['entity keys']['revision'])) {
+ $revision_table = $this->info['revision table'];
+
+ $data[$table]['table']['default_relationship'] = array(
+ $revision_table => array(
+ 'table' => $revision_table,
+ 'field' => $this->info['entity keys']['revision'],
+ ),
+ );
+
+ // Define the base group of this table. Fields that don't
+ // have a group defined will go into this field by default.
+ $data[$revision_table]['table']['group'] = backdrop_ucfirst($this->info['label']) . ' ' . t('Revisions');
+ $data[$revision_table]['table']['entity type'] = $this->type;
+
+ // If the plural label isn't available, use the regular label.
+ $label = isset($this->info['plural label']) ? $this->info['plural label'] : $this->info['label'];
+ $data[$revision_table]['table']['base'] = array(
+ 'field' => $this->info['entity keys']['revision'],
+ 'access query tag' => $this->type . '_access',
+ 'title' => backdrop_ucfirst($label) . ' ' . t('Revisions'),
+ 'help' => (isset($this->info['description']) ? $this->info['description'] . ' ' : '') . t('Revisions'),
+ );
+ $data[$revision_table]['table']['entity type'] = $this->type;
+ $data[$revision_table] += $this->schema_revision_fields();
+
+ // Add in any reverse-relationships which have been determined.
+ $data += $this->relationships;
+
+ // For other base tables, explain how we join.
+ $data[$revision_table]['table']['join'] = array(
+ // Directly links to base table.
+ $table => array(
+ 'left_field' => $this->info['entity keys']['revision'],
+ 'field' => $this->info['entity keys']['revision'],
+ ),
+ );
+ $data[$revision_table]['table']['default_relationship'] = array(
+ $table => array(
+ 'table' => $table,
+ 'field' => $this->info['entity keys']['id'],
+ ),
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Try to come up with some views fields with the help of the schema and
+ * the entity property information.
+ */
+ protected function schema_fields() {
+ $schema = backdrop_get_schema($this->info['base table']);
+ $properties = entity_plus_get_property_info($this->type) + array('properties' => array());
+ $data = array();
+
+ foreach ($properties['properties'] as $name => $property_info) {
+ if (isset($property_info['schema field']) && isset($schema['fields'][$property_info['schema field']])) {
+ if ($views_info = $this->map_from_schema_info($name, $schema['fields'][$property_info['schema field']], $property_info)) {
+ $data[$name] = $views_info;
+ }
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Try to come up with some views fields with the help of the revision schema
+ * and the entity property information.
+ */
+ protected function schema_revision_fields() {
+ $data = array();
+ if (!empty($this->info['revision table'])) {
+ $schema = backdrop_get_schema($this->info['revision table']);
+ $properties = entity_plus_get_property_info($this->type) + array('properties' => array());
+
+ foreach ($properties['properties'] as $name => $property_info) {
+ if (isset($property_info['schema field']) && isset($schema['fields'][$property_info['schema field']])) {
+ if ($views_info = $this->map_from_schema_info($name, $schema['fields'][$property_info['schema field']], $property_info)) {
+ $data[$name] = $views_info;
+ }
+ }
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Comes up with views information based on the given schema and property
+ * info.
+ */
+ protected function map_from_schema_info($property_name, $schema_field_info, $property_info) {
+ $type = isset($property_info['type']) ? $property_info['type'] : 'text';
+ $views_field_name = $property_info['schema field'];
+
+ $return = array();
+
+ if (!empty($schema_field_info['serialize'])) {
+ return FALSE;
+ }
+
+ $description = array(
+ 'title' => $property_info['label'],
+ 'help' => isset($property_info['description']) ? $property_info['description'] : NULL,
+ );
+
+ // Add in relationships to related entities.
+ if (($info = entity_get_info($type)) && !empty($info['base table'])) {
+
+ // Prepare reversed relationship data.
+ $label_lowercase = backdrop_strtolower($this->info['label'][0]) . backdrop_substr($this->info['label'], 1);
+ $property_label_lowercase = backdrop_strtolower($property_info['label'][0]) . backdrop_substr($property_info['label'], 1);
+
+ // We name the field of the first reverse-relationship just with the
+ // base table to be backward compatible, for subsequents relationships we
+ // append the views field name in order to get a unique name.
+ $name = !isset($this->relationships[$info['base table']][$this->info['base table']]) ? $this->info['base table'] : $this->info['base table'] . '_' . $views_field_name;
+ $this->relationships[$info['base table']][$name] = array(
+ 'title' => $this->info['label'],
+ 'help' => t("Associated @label via the @label's @property.", array('@label' => $label_lowercase, '@property' => $property_label_lowercase)),
+ 'relationship' => array(
+ 'label' => $this->info['label'],
+ 'handler' => $this->getRelationshipHandlerClass($this->type, $type),
+ 'base' => $this->info['base table'],
+ 'base field' => $views_field_name,
+ 'relationship field' => isset($info['entity keys']['name']) ? $info['entity keys']['name'] : $info['entity keys']['id'],
+ ),
+ );
+
+ $return['relationship'] = array(
+ 'label' => backdrop_ucfirst($info['label']),
+ 'handler' => $this->getRelationshipHandlerClass($type, $this->type),
+ 'base' => $info['base table'],
+ 'base field' => isset($info['entity keys']['name']) ? $info['entity keys']['name'] : $info['entity keys']['id'],
+ 'relationship field' => $views_field_name,
+ );
+
+ // Add in direct field/filters/sorts for the id itself too.
+ $type = isset($info['entity keys']['name']) ? 'token' : 'integer';
+ // Append the views-field-name to the title if it is different to the
+ // property name.
+ if ($property_name != $views_field_name) {
+ $description['title'] .= ' ' . $views_field_name;
+ }
+ }
+
+ switch ($type) {
+ case 'token':
+ case 'text':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_field',
+ 'click sortable' => TRUE,
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_string',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_string',
+ ),
+ );
+ break;
+
+ case 'decimal':
+ case 'integer':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_field_numeric',
+ 'click sortable' => TRUE,
+ 'float' => ($type == 'decimal'),
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_numeric',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_numeric',
+ ),
+ );
+ break;
+
+ case 'date':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_field_date',
+ 'click sortable' => TRUE,
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort_date',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_date',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_date',
+ ),
+ );
+ break;
+
+ case 'duration':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'entity_plus_views_handler_field_duration',
+ 'click sortable' => TRUE,
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_numeric',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_numeric',
+ ),
+ );
+ break;
+
+ case 'uri':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_field_url',
+ 'click sortable' => TRUE,
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_string',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_string',
+ ),
+ );
+ break;
+
+ case 'boolean':
+ $return += $description + array(
+ 'field' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_field_boolean',
+ 'click sortable' => TRUE,
+ ),
+ 'sort' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_sort',
+ ),
+ 'filter' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_filter_boolean_operator',
+ ),
+ 'argument' => array(
+ 'real field' => $views_field_name,
+ 'handler' => 'views_handler_argument_string',
+ ),
+ );
+ break;
+ }
+
+ // If there is an options list callback, add to the filter and field.
+ if (isset($return['filter']) && !empty($property_info['options list'])) {
+ $return['filter']['handler'] = 'views_handler_filter_in_operator';
+ $return['filter']['options callback'] = array('EntityPlusDefaultViewsController', 'optionsListCallback');
+ $return['filter']['options arguments'] = array($this->type, $property_name, 'view');
+ }
+ // @todo: This class_exists is needed until views 3.2.
+ if (isset($return['field']) && !empty($property_info['options list']) && class_exists('views_handler_field_machine_name')) {
+ $return['field']['handler'] = 'views_handler_field_machine_name';
+ $return['field']['options callback'] = array('EntityPlusDefaultViewsController', 'optionsListCallback');
+ $return['field']['options arguments'] = array($this->type, $property_name, 'view');
+ }
+ return $return;
+ }
+
+ /**
+ * Determines the handler to use for a relationship to an entity type.
+ *
+ * @param $entity_type
+ * The entity type to join to.
+ * @param $left_type
+ * The data type from which to join.
+ */
+ function getRelationshipHandlerClass($entity_type, $left_type) {
+ // Look for an entity type which is used as bundle for the given entity
+ // type. If there is one, allow filtering the relation by bundle by using
+ // our own handler.
+ foreach (entity_get_info() as $type => $info) {
+ // In case we already join from the bundle entity we do not need to filter
+ // by bundle entity any more, so we stay with the general handler.
+ if (!empty($info['bundle of']) && $info['bundle of'] == $entity_type && $type != $left_type) {
+ return 'entity_plus_views_handler_relationship_by_bundle';
+ }
+ }
+ return 'views_handler_relationship';
+ }
+
+ /**
+ * A callback returning property options, suitable to be used as views options callback.
+ */
+ public static function optionsListCallback($type, $selector, $op = 'view') {
+ $wrapper = entity_metadata_wrapper($type, NULL);
+ $parts = explode(':', $selector);
+ foreach ($parts as $part) {
+ $wrapper = $wrapper->get($part);
+ }
+ return $wrapper->optionsList($op);
+ }
+}
diff --git a/www/modules/contrib/entity_plus/views/entity_plus_views_example_query.php b/www/modules/contrib/entity_plus/views/entity_plus_views_example_query.php
new file mode 100644
index 000000000..a086bb843
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/entity_plus_views_example_query.php
@@ -0,0 +1,88 @@
+definition['type']) && entity_plus_property_list_extract_type($handler->definition['type'])) {
+ $options['list']['contains']['mode'] = array('default' => 'collapse');
+ $options['list']['contains']['separator'] = array('default' => ', ');
+ $options['list']['contains']['type'] = array('default' => 'ul');
+ }
+ $options['link_to_entity'] = array('default' => FALSE);
+
+ return $options;
+ }
+
+ /**
+ * Provide an appropriate default option form for a handler.
+ */
+ public static function options_form($handler, &$form, &$form_state) {
+ if (isset($handler->definition['type']) && entity_plus_property_list_extract_type($handler->definition['type'])) {
+ $form['list']['mode'] = array(
+ '#type' => 'select',
+ '#title' => t('List handling'),
+ '#options' => array(
+ 'collapse' => t('Concatenate values using a seperator'),
+ 'list' => t('Output values as list'),
+ 'first' => t('Show first (if present)'),
+ 'count' => t('Show item count'),
+ ),
+ '#default_value' => $handler->options['list']['mode'],
+ );
+ $form['list']['separator'] = array(
+ '#type' => 'textfield',
+ '#title' => t('List seperator'),
+ '#default_value' => $handler->options['list']['separator'],
+ '#dependency' => array('edit-options-list-mode' => array('collapse')),
+ );
+ $form['list']['type'] = array(
+ '#type' => 'select',
+ '#title' => t('List type'),
+ '#options' => array(
+ 'ul' => t('Unordered'),
+ 'ol' => t('Ordered'),
+ ),
+ '#default_value' => $handler->options['list']['type'],
+ '#dependency' => array('edit-options-list-mode' => array('list')),
+ );
+ }
+ $form['link_to_entity'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Link this field to its entity'),
+ '#description' => t("When using this, you should not set any other link on the field."),
+ '#default_value' => $handler->options['link_to_entity'],
+ );
+ }
+
+ /**
+ * Add the field for the entity ID (if necessary).
+ */
+ public static function query($handler) {
+ // Copied over from views_handler_field_entity::query().
+ // Sets table_alias (entity table), base_field (entity id field) and
+ // field_alias (the field's alias).
+ $handler->table_alias = $base_table = $handler->view->base_table;
+ $handler->base_field = $handler->view->base_field;
+
+ if (!empty($handler->relationship)) {
+ foreach ($handler->view->relationship as $relationship) {
+ if ($relationship->alias == $handler->relationship) {
+ $base_table = $relationship->definition['base'];
+ $handler->table_alias = $relationship->alias;
+
+ $table_data = views_fetch_data($base_table);
+ $handler->base_field = empty($relationship->definition['base field']) ? $table_data['table']['base']['field'] : $relationship->definition['base field'];
+ }
+ }
+ }
+
+ // Add the field if the query back-end implements an add_field() method,
+ // just like the default back-end.
+ if (method_exists($handler->query, 'add_field')) {
+ $handler->field_alias = $handler->query->add_field($handler->table_alias, $handler->base_field, '');
+ }
+ else {
+ // To ensure there is an alias just set the field alias to the real field.
+ $handler->field_alias = $handler->real_field;
+ }
+ }
+
+ /**
+ * Extracts the innermost field name from a data selector.
+ *
+ * @param $selector
+ * The data selector.
+ *
+ * @return
+ * The last component of the data selector.
+ */
+ public static function get_selector_field_name($selector) {
+ return ltrim(substr($selector, strrpos($selector, ':')), ':');
+ }
+
+ /**
+ * Adds a click-sort to the query.
+ *
+ * @param $order
+ * Either 'ASC' or 'DESC'.
+ */
+ public static function click_sort($handler, $order) {
+ // The normal orderby() method for this usually won't work here. So we need
+ // query plugins to provide their own method for this.
+ if (method_exists($handler->query, 'add_selector_orderby')) {
+ $selector = self::construct_property_selector($handler, TRUE);
+ $handler->query->add_selector_orderby($selector, $order);
+ }
+ }
+
+ /**
+ * Load the entities for all rows that are about to be displayed.
+ *
+ * Automatically takes care of relationships, including data selection
+ * relationships. Results are written into @code $handler->wrappers @endcode
+ * and @code $handler->entity_type @endcode is set.
+ */
+ public static function pre_render($handler, &$values, $load_always = FALSE) {
+ if (empty($values)) {
+ return;
+ }
+ if (!$load_always && empty($handler->options['link_to_entity'])) {
+ // Check whether we even need to load the entities.
+ $selector = self::construct_property_selector($handler, TRUE);
+ $load = FALSE;
+ foreach ($values as $row) {
+ if (empty($row->_entity_properties) || !array_key_exists($selector, $row->_entity_properties)) {
+ $load = TRUE;
+ break;
+ }
+ }
+ if (!$load) {
+ return;
+ }
+ }
+
+ if (method_exists($handler->query, 'get_result_wrappers')) {
+ list($handler->entity_type, $handler->wrappers) = $handler->query->get_result_wrappers($values, $handler->relationship, $handler->real_field);
+ }
+ else {
+ list($handler->entity_type, $entities) = $handler->query->get_result_entities($values, $handler->relationship, $handler->real_field);
+ $handler->wrappers = array();
+ foreach ($entities as $id => $entity) {
+ $handler->wrappers[$id] = entity_metadata_wrapper($handler->entity_type, $entity);
+ }
+ }
+ }
+
+ /**
+ * Return an Entity API data selector for the given handler's relationship.
+ *
+ * A data selector is a concatenation of properties which should be followed
+ * to arrive at a desired property that may be nested in related entities or
+ * structures. The separate properties are herein concatenated with colons.
+ *
+ * For instance, a data selector of "author:roles" would mean to first
+ * access the "author" property of the given wrapper, and then for this new
+ * wrapper to access and return the "roles" property.
+ *
+ * Lists of entities are handled automatically by always returning only the
+ * first entity.
+ *
+ * @param $handler
+ * The handler for which to construct the selector.
+ * @param $complete
+ * If TRUE, the complete selector for the field is returned, not just the
+ * one for its parent. Defaults to FALSE.
+ *
+ * @return
+ * An Entity API data selector for the given handler's relationship.
+ */
+ public static function construct_property_selector($handler, $complete = FALSE) {
+ $return = '';
+ if ($handler->relationship) {
+ $current_handler = $handler;
+ $view = $current_handler->view;
+ $relationships = array();
+ // Collect all relationships, keyed by alias.
+ foreach ($view->relationship as $key => $relationship) {
+ $key = $relationship->alias ? $relationship->alias : $key;
+ $relationships[$key] = $relationship;
+ }
+ while (!empty($current_handler->relationship) && !empty($relationships[$current_handler->relationship])) {
+ $current_handler = $relationships[$current_handler->relationship];
+ $return = $current_handler->real_field . ($return ? ":$return" : '');
+ }
+ }
+
+ if ($complete) {
+ $return .= ($return ? ':' : '') . $handler->real_field;
+ }
+ elseif ($pos = strrpos($handler->real_field, ':')) {
+ // If we have a selector as the real_field, append this to the returned
+ // relationship selector.
+ $return .= ($return ? ':' : '') . substr($handler->real_field, 0, $pos);
+ }
+
+ return $return;
+ }
+
+ /**
+ * Extracts data from several metadata wrappers based on a data selector.
+ *
+ * All metadata wrappers passed to this function have to be based on the exact
+ * same property information. The data will be returned wrapped by one or more
+ * metadata wrappers.
+ *
+ * Can be used in query plugins for the get_result_entities() and
+ * get_result_wrappers() methods.
+ *
+ * @param array $wrappers
+ * The EntityMetadataWrapper objects from which to extract data.
+ * @param $selector
+ * The selector specifying the data to extract.
+ *
+ * @return array
+ * An array with numeric indices, containing the type of the extracted
+ * wrappers in the first element. The second element of the array contains
+ * the extracted property value(s) for each wrapper, keyed to the same key
+ * that was used for the respecive wrapper in $wrappers. All extracted
+ * properties are returned as metadata wrappers.
+ */
+ public static function extract_property_multiple(array $wrappers, $selector) {
+ $parts = explode(':', $selector, 2);
+ $name = $parts[0];
+
+ $results = array();
+ $entities = array();
+ $type = '';
+ foreach ($wrappers as $i => $wrapper) {
+ try {
+ $property = $wrapper->$name;
+ $type = $property->type();
+ if ($property instanceof EntityBackdropWrapper) {
+ // Remember the entity IDs to later load all at once (so as to
+ // properly utilize multiple load functionality).
+ $id = $property->getIdentifier();
+ // Only accept valid ids. $id can be FALSE for entity values that are
+ // NULL.
+ if ($id) {
+ $entities[$type][$i] = $id;
+ }
+ }
+ elseif ($property instanceof EntityStructureWrapper) {
+ $results[$i] = $property;
+ }
+ elseif ($property instanceof EntityListWrapper) {
+ foreach ($property as $item) {
+ $results[$i] = $item;
+ $type = $item->type();
+ break;
+ }
+ }
+ // Do nothing in case it cannot be applied.
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // Skip single empty properties.
+ }
+ }
+
+ if ($entities) {
+ // Map back the loaded entities back to the results array.
+ foreach ($entities as $type => $id_map) {
+ $loaded = entity_load($type, $id_map);
+ foreach ($id_map as $i => $id) {
+ if (isset($loaded[$id])) {
+ $results[$i] = entity_metadata_wrapper($type, $loaded[$id]);
+ }
+ }
+ }
+ }
+
+ // If there are no further parts in the selector, we are done now.
+ if (empty($parts[1])) {
+ return array($type, $results);
+ }
+ return self::extract_property_multiple($results, $parts[1]);
+ }
+
+ /**
+ * Get the value of a certain data selector.
+ *
+ * Uses $values->_entity_properties to look for already extracted properties.
+ *
+ * @param $handler
+ * The field handler for which to return a value.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ * @param $field
+ * The field to extract. If no value is given, the field of the given
+ * handler is used instead. The special "entity object" value can be used to
+ * get the base entity instead of a special field.
+ * @param $default
+ * The value to return if the entity or field are not present.
+ */
+ public static function get_value($handler, $values, $field = NULL, $default = NULL) {
+ // There is a value cache on each handler so parent handlers rendering a
+ // single field value from a list will get the single value, not the whole
+ // list.
+ if (!isset($field) && isset($handler->current_value)) {
+ return $handler->current_value;
+ }
+ $field = isset($field) ? $field : self::get_selector_field_name($handler->real_field);
+ $selector = self::construct_property_selector($handler);
+ $selector = $selector ? "$selector:$field" : $field;
+ if (!isset($values->_entity_properties)) {
+ $values->_entity_properties = array();
+ }
+ if (!array_key_exists($selector, $values->_entity_properties)) {
+ if (!isset($handler->wrappers[$handler->view->row_index])) {
+ $values->_entity_properties[$selector] = $default;
+ }
+ elseif (is_array($handler->wrappers[$handler->view->row_index])) {
+ $values->_entity_properties[$selector] = self::extract_list_wrapper_values($handler->wrappers[$handler->view->row_index], $field);
+ }
+ else {
+ $wrapper = $handler->wrappers[$handler->view->row_index];
+ try {
+ if ($field === 'entity object') {
+ $values->_entity_properties[$selector] = $wrapper->value();
+ }
+ else {
+ $values->_entity_properties[$selector] = isset($wrapper->$field) ? $wrapper->$field->value(array('identifier' => TRUE, 'sanitize' => TRUE)) : $default;
+ }
+ }
+ catch (EntityMetadataWrapperException $e) {
+ $values->_entity_properties[$selector] = $default;
+ }
+ }
+ }
+ return $values->_entity_properties[$selector];
+ }
+
+ /**
+ * Helper method for extracting the values from an array of wrappers.
+ *
+ * Nested arrays of wrappers are also handled, the values are returned in a
+ * flat (not nested) array.
+ */
+ public static function extract_list_wrapper_values(array $wrappers, $field) {
+ $return = array();
+ foreach ($wrappers as $wrapper) {
+ if (is_array($wrapper)) {
+ $values = self::extract_list_wrapper_values($wrapper, $field);
+ if ($values) {
+ $return = array_merge($return, $values);
+ }
+ }
+ else {
+ try {
+ if ($field == 'entity object') {
+ $return[] = $wrapper->value();
+ }
+ elseif (isset($wrapper->$field)) {
+ $return[] = $wrapper->$field->value(array('identifier' => TRUE));
+ }
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // An exception probably signifies a non-present property, so we just
+ // ignore it.
+ }
+ }
+ }
+ return $return;
+ }
+
+ /**
+ * Render the field.
+ *
+ * Implements the entity link functionality and list handling. Basic handling
+ * of the single values is delegated back to the field handler.
+ *
+ * @param $handler
+ * The field handler whose field should be rendered.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ *
+ * @return
+ * The rendered value for the field.
+ */
+ public static function render($handler, $values) {
+ $value = $handler->get_value($values);
+ if (is_array($value)) {
+ return self::render_list($handler, $value, $values);
+ }
+ return self::render_entity_link($handler, $value, $values);
+ }
+
+ /**
+ * Render a list of values.
+ *
+ * @param $handler
+ * The field handler whose field is rendered.
+ * @param $list
+ * The list of values to render.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ *
+ * @return
+ * The rendered value for the given list.
+ */
+ public static function render_list($handler, $list, $values) {
+ // Allow easy overriding of this behaviour in the specific field handler.
+ if (method_exists($handler, 'render_list')) {
+ return $handler->render_list($list, $values);
+ }
+ $mode = isset($handler->options['list']['mode']) ? $handler->options['list']['mode'] : NULL;
+ switch ($mode) {
+ case 'first':
+ $list = count($list) ? array_shift($list) : NULL;
+ if (is_array($list)) {
+ return self::render_list($handler, $list, $values);
+ }
+ elseif (isset($list)) {
+ return self::render_entity_link($handler, $list, $values);
+ }
+ return NULL;
+
+ case 'count':
+ return count($list);
+
+ // Handles both collapse and list output. Fallback is to collapse.
+ default:
+ $inner_values = array();
+ foreach ($list as $value) {
+ $value = is_array($value) ? self::render_list($handler, $value, $values) : self::render_entity_link($handler, $value, $values);
+ if ($value) {
+ $inner_values[] = $value;
+ }
+ }
+
+ // Format output as list.
+ if ($mode == 'list') {
+ $type = isset($handler->options['list']['type']) ? $handler->options['list']['type'] : 'ul';
+ return theme('item_list', array(
+ 'items' => $inner_values,
+ 'type' => $type,
+ ));
+ }
+
+ $separator = isset($handler->options['list']['separator']) ? $handler->options['list']['separator'] : ', ';
+ return implode($separator, $inner_values);
+ }
+ }
+
+ /**
+ * Render a single value as a link to the entity if applicable.
+ *
+ * @param $handler
+ * The field handler whose field is rendered.
+ * @param $value
+ * The single value to render.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ *
+ * @return
+ * The rendered value.
+ */
+ public static function render_entity_link($handler, $value, $values) {
+ // Allow easy overriding of this behaviour in the specific field handler.
+ if (method_exists($handler, 'render_entity_link')) {
+ return $handler->render_entity_link($value, $values);
+ }
+ $render = self::render_single_value($handler, $value, $values);
+ if (!$handler->options['link_to_entity']) {
+ return $render;
+ }
+ $entity = $handler->get_value($values, 'entity object');
+ if (is_object($entity) && ($url = entity_uri($handler->entity_type, $entity))) {
+ return l($render, $url['path'], array('html' => TRUE) + $url['options']);
+ }
+ return $render;
+ }
+
+ /**
+ * Render a single value.
+ *
+ * @param $handler
+ * The field handler whose field is rendered.
+ * @param $value
+ * The single value to render.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ *
+ * @return
+ * The rendered value.
+ */
+ public static function render_single_value($handler, $value, $values) {
+ // Try to use the method in the specific field handler.
+ if (method_exists($handler, 'render_single_value')) {
+ $handler->current_value = $value;
+ $return = $handler->render_single_value($value, $values);
+ unset($handler->current_value);
+ return $return;
+ }
+ // Default fallback in case the field handler doesn't provide the method.
+ return is_scalar($value) ? check_plain($value) : nl2br(check_plain(print_r($value, TRUE)));
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_area_entity.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_area_entity.inc
new file mode 100644
index 000000000..0bce92069
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_area_entity.inc
@@ -0,0 +1,150 @@
+ 'node');
+ $options['entity_id'] = array('default' => '');
+ $options['view_mode'] = array('default' => 'full');
+ $options['bypass_access'] = array('default' => FALSE);
+ return $options;
+ }
+
+ function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+
+ $entity_type_options = array();
+ foreach (entity_get_info() as $entity_type => $entity_info) {
+ $entity_type_options[$entity_type] = $entity_info['label'];
+ }
+
+ $entity_type = $this->options['entity_type'];
+
+ $form['entity_type'] = array(
+ '#type' => 'select',
+ '#title' => t('Entity type'),
+ '#options' => $entity_type_options,
+ '#description' => t('Choose the entity type you want to display in the area.'),
+ '#default_value' => $entity_type,
+ '#ajax' => array(
+ 'path' => views_ui_build_form_path($form_state),
+ ),
+ '#submit' => array('views_ui_config_item_form_submit_temporary'),
+ '#executes_submit_callback' => TRUE,
+ );
+
+ $form['entity_id'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Entity id'),
+ '#description' => t('Choose the entity you want to display in the area. To render an entity given by a contextual filter use "%1" for the first argument, "%2" for the second, etc.'),
+ '#default_value' => $this->options['entity_id'],
+ );
+
+ if ($entity_type) {
+ $entity_info = entity_get_info($entity_type);
+ $options = array();
+ if (!empty($entity_info['view modes'])) {
+ foreach ($entity_info['view modes'] as $mode => $settings) {
+ $options[$mode] = $settings['label'];
+ }
+ }
+
+ if (count($options) > 1) {
+ $form['view_mode'] = array(
+ '#type' => 'select',
+ '#options' => $options,
+ '#title' => t('View mode'),
+ '#default_value' => $this->options['view_mode'],
+ );
+ }
+ else {
+ $form['view_mode_info'] = array(
+ '#type' => 'item',
+ '#title' => t('View mode'),
+ '#description' => t('Only one view mode is available for this entity type.'),
+ '#markup' => $options ? current($options) : t('Default'),
+ );
+ $form['view_mode'] = array(
+ '#type' => 'value',
+ '#value' => $options ? key($options) : 'default',
+ );
+ }
+ }
+ $form['bypass_access'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Bypass access checks'),
+ '#description' => t('If enabled, access permissions for rendering the entity are not checked.'),
+ '#default_value' => !empty($this->options['bypass_access']),
+ );
+ return $form;
+ }
+
+ public function admin_summary() {
+ $label = parent::admin_summary();
+ if (!empty($this->options['entity_id'])) {
+ return t('@label @entity_type:@entity_id', array(
+ '@label' => $label,
+ '@entity_type' => $this->options['entity_type'],
+ '@entity_id' => $this->options['entity_id'],
+ ));
+ }
+ }
+
+ public function render($empty = FALSE) {
+ if (!$empty || !empty($this->options['empty'])) {
+ return $this->render_entity($this->options['entity_type'], $this->options['entity_id'], $this->options['view_mode']);
+ }
+ return '';
+ }
+
+ /**
+ * Render an entity using the view mode.
+ */
+ public function render_entity($entity_type, $entity_id, $view_mode) {
+ $tokens = $this->get_render_tokens();
+ // Replace argument tokens in entity id.
+ $entity_id = strtr($entity_id, $tokens);
+ if (!empty($entity_type) && !empty($entity_id) && !empty($view_mode)) {
+ $entity = entity_load($entity_type, $entity_id);
+ if (!empty($this->options['bypass_access']) || entity_access('view', $entity_type, $entity)) {
+ $render = entity_plus_view($entity_type, array($entity), $view_mode);
+ $render_entity = reset($render);
+ return backdrop_render($render_entity);
+ }
+ }
+ else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the 'render' tokens to use for advanced rendering.
+ *
+ * This runs through all of the fields and arguments that
+ * are available and gets their values. This will then be
+ * used in one giant str_replace().
+ */
+ function get_render_tokens() {
+ $tokens = array();
+ if (!empty($this->view->build_info['substitutions'])) {
+ $tokens = $this->view->build_info['substitutions'];
+ }
+ $count = 0;
+ foreach ($this->view->display_handler->get_handlers('argument') as $arg => $handler) {
+ $token = '%' . ++$count;
+ if (!isset($tokens[$token])) {
+ $tokens[$token] = '';
+ }
+ // Use strip tags as there should never be HTML in the path.
+ // However, we need to preserve special characters like " that
+ // were removed by check_plain().
+ $tokens['%' . $count] = $handler->argument;
+ }
+
+ return $tokens;
+ }
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_boolean.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_boolean.inc
new file mode 100644
index 000000000..e22176c63
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_boolean.inc
@@ -0,0 +1,99 @@
+ TRUE);
+ $options['granularity'] = array('default' => 2);
+ $options['prefix'] = array('default' => '', 'translatable' => TRUE);
+ $options['suffix'] = array('default' => '', 'translatable' => TRUE);
+
+ return $options;
+ }
+
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+ EntityPlusFieldHandlerHelper::options_form($this, $form, $form_state);
+
+ $form['format_interval'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Format interval'),
+ '#description' => t('If checked, the value will be formatted as a time interval. Otherwise, just the number of seconds will be displayed.'),
+ '#default_value' => $this->options['format_interval'],
+ );
+ $form['granularity'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Granularity'),
+ '#default_value' => $this->options['granularity'],
+ '#description' => t('Specify how many different units to display.'),
+ '#dependency' => array('edit-options-format-interval' => array(TRUE)),
+ '#size' => 2,
+ );
+ $form['prefix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Prefix'),
+ '#default_value' => $this->options['prefix'],
+ '#description' => t('Text to put before the duration text.'),
+ );
+ $form['suffix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Suffix'),
+ '#default_value' => $this->options['suffix'],
+ '#description' => t('Text to put after the duration text.'),
+ );
+ }
+
+ /**
+ * Render the field.
+ *
+ * @param $values
+ * The values retrieved from the database.
+ */
+ public function render($values) {
+ return EntityPlusFieldHandlerHelper::render($this, $values);
+ }
+
+ /**
+ * Render a single field value.
+ */
+ public function render_single_value($value, $values) {
+ if ($this->options['format_interval']) {
+ $value = format_interval($value, (int) $this->options['granularity']);
+ }
+ // Value sanitization is handled by the wrapper, see
+ // EntityPlusFieldHandlerHelper::get_value().
+ return $this->sanitize_value($this->options['prefix'], 'xss') .
+ $value .
+ $this->sanitize_value($this->options['suffix'], 'xss');
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_entity.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_entity.inc
new file mode 100644
index 000000000..ddc275592
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_entity.inc
@@ -0,0 +1,207 @@
+field_entity_type = entity_plus_property_extract_innermost_type($this->definition['type']);
+ }
+
+ /**
+ * Overridden to add the field for the entity ID (if necessary).
+ */
+ public function query() {
+ EntityPlusFieldHandlerHelper::query($this);
+ }
+
+ /**
+ * Adds a click-sort to the query.
+ */
+ public function click_sort($order) {
+ EntityPlusFieldHandlerHelper::click_sort($this, $order);
+ }
+
+ /**
+ * Load the entities for all rows that are about to be displayed.
+ */
+ public function pre_render(&$values) {
+ EntityPlusFieldHandlerHelper::pre_render($this, $values);
+ }
+
+ /**
+ * Overridden to use a metadata wrapper.
+ */
+ public function get_value($values, $field = NULL) {
+ return EntityPlusFieldHandlerHelper::get_value($this, $values, $field);
+ }
+
+ public function option_definition() {
+ $options = parent::option_definition();
+ $options += EntityPlusFieldHandlerHelper::option_definition($this);
+
+ $options['display'] = array('default' => 'label');
+ $options['link_to_entity']['default'] = TRUE;
+ $options['view_mode'] = array('default' => 'default');
+ $options['bypass_access'] = array('default' => FALSE);
+
+ return $options;
+ }
+
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+ EntityPlusFieldHandlerHelper::options_form($this, $form, $form_state);
+ // We want a different form field at a different place.
+ unset($form['link_to_entity']);
+
+ $options = array(
+ 'label' => t('Show entity label'),
+ 'id' => t('Show entity ID'),
+ 'view' => t('Show complete entity'),
+ );
+ $form['display'] = array(
+ '#type' => 'select',
+ '#title' => t('Display'),
+ '#description' => t('Decide how this field will be displayed.'),
+ '#options' => $options,
+ '#default_value' => $this->options['display'],
+ );
+ $form['link_to_entity'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Link to entity'),
+ '#description' => t('Link this field to the entity.'),
+ '#default_value' => $this->options['link_to_entity'],
+ '#dependency' => array('edit-options-display' => array('label', 'id')),
+ );
+
+ // Stolen from entity_views_plugin_row_entity_view.
+ $entity_info = entity_get_info($this->field_entity_type);
+ $options = array();
+ if (!empty($entity_info['view modes'])) {
+ foreach ($entity_info['view modes'] as $mode => $settings) {
+ $options[$mode] = $settings['label'];
+ }
+ }
+
+ if (count($options) > 1) {
+ $form['view_mode'] = array(
+ '#type' => 'select',
+ '#options' => $options,
+ '#title' => t('View mode'),
+ '#default_value' => $this->options['view_mode'],
+ '#dependency' => array('edit-options-display' => array('view')),
+ );
+ }
+ else {
+ $form['view_mode'] = array(
+ '#type' => 'value',
+ '#value' => $options ? key($options) : 'default',
+ );
+ }
+ $form['bypass_access'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Bypass access checks'),
+ '#description' => t('If enabled, access permissions for rendering the entity are not checked.'),
+ '#default_value' => !empty($this->options['bypass_access']),
+ );
+ }
+
+ public function render($values) {
+ return EntityPlusFieldHandlerHelper::render($this, $values);
+ }
+
+ /**
+ * Render a value as a link to the entity if applicable.
+ *
+ * @param $entity
+ * The the ID of the entity.
+ * @param $values
+ * The values for the current row retrieved from the Views query, as an
+ * object.
+ */
+ public function render_entity_link($entity, $values) {
+ $type = $this->field_entity_type;
+ if (!is_object($entity) && isset($entity) && $entity !== FALSE) {
+ $entity = entity_load($type, $entity);
+ }
+ if (!$entity) {
+ return '';
+ }
+ $render = $this->render_single_value($entity, $values);
+ if (!$this->options['link_to_entity'] || $this->options['display'] == 'view') {
+ return $render;
+ }
+ if (is_object($entity) && ($url = entity_uri($type, $entity))) {
+ return l($render, $url['path'], array('html' => TRUE) + $url['options']);
+ }
+ return $render;
+ }
+
+ /**
+ * Render a single field value.
+ */
+ public function render_single_value($entity, $values) {
+ $type = $this->field_entity_type;
+ if (!is_object($entity) && isset($entity) && $entity !== FALSE) {
+ $entity = entity_load($type, $entity);
+ }
+ // Make sure the entity exists and access is either given or bypassed.
+ if (!$entity || !(!empty($this->options['bypass_access']) || entity_access('view', $type, $entity))) {
+ return '';
+ }
+
+ if ($this->options['display'] === 'view') {
+ $entity_view = entity_plus_view($type, array($entity), $this->options['view_mode']);
+ return render($entity_view);
+ }
+
+ if ($this->options['display'] == 'label') {
+ $value = entity_label($type, $entity);
+ }
+ // Either $options[display] == 'id', or we have no label.
+ if (empty($value)) {
+ $value = entity_plus_id($type, $entity);
+ }
+ $value = $this->sanitize_value($value);
+
+ return $value;
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_field.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_field.inc
new file mode 100644
index 000000000..36ab9cf8e
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_field.inc
@@ -0,0 +1,105 @@
+field_info, $this->definition['entity type']);
+ }
+
+ /**
+ * Overridden to add the field for the entity ID (if necessary).
+ */
+ public function query($use_groupby = FALSE) {
+ EntityPlusFieldHandlerHelper::query($this);
+ }
+
+ /**
+ * Adds a click-sort to the query.
+ */
+ public function click_sort($order) {
+ EntityPlusFieldHandlerHelper::click_sort($this, $order);
+ }
+
+ /**
+ * Override so it doesn't do any harm (or, anything at all).
+ */
+ public function post_execute(&$values) { }
+
+ /**
+ * Load the entities for all rows that are about to be displayed.
+ */
+ public function pre_render(&$values) {
+ parent::pre_render($values);
+ EntityPlusFieldHandlerHelper::pre_render($this, $values, TRUE);
+ }
+
+ /**
+ * Overridden to get the items our way.
+ */
+ public function get_items($values) {
+ $items = array();
+ // Set the entity type for the parent handler.
+ $values->_field_data[$this->field_alias]['entity_type'] = $this->entity_type;
+ // We need special handling for lists of entities as the base.
+ $entities = EntityPlusFieldHandlerHelper::get_value($this, $values, 'entity object');
+ if (!is_array($entities)) {
+ $entities = $entities ? array($entities) : array();
+ }
+ foreach ($entities as $entity) {
+ // Only try to render the field if it is even present on this bundle.
+ // Otherwise, field_view_field() will trigger a fatal.
+ list (, , $bundle) = entity_extract_ids($this->entity_type, $entity);
+ if (field_info_instance($this->entity_type, $this->definition['field_name'], $bundle)) {
+ // Set the currently rendered entity.
+ $values->_field_data[$this->field_alias]['entity'] = $entity;
+ $items = array_merge($items, $this->set_items($values, $this->view->row_index));
+ }
+ }
+ return $items;
+ }
+
+ /**
+ * Overridden to force displaying multiple values in a single row.
+ */
+ function multiple_options_form(&$form, &$form_state) {
+ parent::multiple_options_form($form, $form_state);
+ $form['group_rows']['#default_value'] = TRUE;
+ $form['group_rows']['#disabled'] = TRUE;
+ }
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_numeric.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_numeric.inc
new file mode 100644
index 000000000..b9593140b
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_numeric.inc
@@ -0,0 +1,99 @@
+ TRUE);
+ return $options;
+ }
+
+ /**
+ * Returns an option form for setting this handler's options.
+ */
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+ EntityPlusFieldHandlerHelper::options_form($this, $form, $form_state);
+
+ $form['format_name'] = array(
+ '#title' => t('Use human-readable name'),
+ '#type' => 'checkbox',
+ '#description' => t("If this is checked, the values' names will be displayed instead of their internal identifiers."),
+ '#default_value' => $this->options['format_name'],
+ '#weight' => -5,
+ );
+ }
+
+ public function render($values) {
+ return EntityPlusFieldHandlerHelper::render($this, $values);
+ }
+
+ /**
+ * Render a single field value.
+ */
+ public function render_single_value($value, $values) {
+ if (!isset($this->option_list)) {
+ $this->option_list = array();
+ $callback = $this->definition['options callback'];
+ if (is_callable($callback['function'])) {
+ // If a selector is used, get the name of the selected field.
+ $field_name = EntityPlusFieldHandlerHelper::get_selector_field_name($this->real_field);
+ $this->option_list = call_user_func($callback['function'], $field_name, $callback['info'], 'view');
+ }
+ }
+ if ($this->options['format_name'] && isset($this->option_list[$value])) {
+ $value = $this->option_list[$value];
+ }
+ // Sanitization is handled by the wrapper, see
+ // EntityPlusFieldHandlerHelper::get_value().
+ return $value;
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_text.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_text.inc
new file mode 100644
index 000000000..676227c3c
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_field_text.inc
@@ -0,0 +1,101 @@
+definition['multiple'])) {
+ $form['multiple_note'] = array(
+ '#markup' => t('Note: This is a multi-valued relationship, which is currently not supported. ' .
+ 'Only the first related entity will be shown.'),
+ '#weight' => -5,
+ );
+ }
+ }
+
+ /**
+ * Called to implement a relationship in a query.
+ *
+ * As we don't add any data to the query itself, we don't have to do anything
+ * here. Views just don't thinks we have been called unless we set our
+ * $alias property. Otherwise, this override is just here to keep PHP from
+ * blowing up by calling inexistent methods on the query plugin.
+ */
+ public function query() {
+ $this->alias = $this->options['id'];
+ }
+
+}
diff --git a/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_relationship_by_bundle.inc b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_relationship_by_bundle.inc
new file mode 100644
index 000000000..d25aee452
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/handlers/entity_plus_views_handler_relationship_by_bundle.inc
@@ -0,0 +1,117 @@
+ array());
+
+ return $options;
+ }
+
+ /**
+ * Add an entity type option.
+ */
+ function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+
+ // Get the entity type and info from the table data for the base on the
+ // right hand side of the relationship join.
+ $table_data = views_fetch_data($this->definition['base']);
+ $entity_type = $table_data['table']['entity type'];
+ $entity_info = entity_get_info($entity_type);
+
+ // Get the info of the bundle entity.
+ foreach (entity_get_info() as $type => $info) {
+ if (isset($info['bundle of']) && $info['bundle of'] == $entity_type) {
+ $entity_bundle_info = $info;
+ break;
+ }
+ }
+
+ $plural_label = isset($entity_bundle_info['plural label']) ? $entity_bundle_info['plural label'] : $entity_bundle_info['label'] . 's';
+ $bundle_options = array();
+ foreach ($entity_info['bundles'] as $name => $info) {
+ $bundle_options[$name] = $info['label'];
+ }
+
+ $form['bundle_types'] = array(
+ '#title' => $plural_label,
+ '#type' => 'checkboxes',
+ '#description' => t('Restrict this relationship to one or more @bundles.', array('@bundles' => strtolower($entity_bundle_info['plural label']))),
+ '#options' => $bundle_options,
+ '#default_value' => $this->options['bundle_types'],
+ );
+ }
+
+ /**
+ * Make sure only checked bundle types are left.
+ */
+ function options_submit(&$form, &$form_state) {
+ $form_state['values']['options']['bundle_types'] = array_filter($form_state['values']['options']['bundle_types']);
+ parent::options_submit($form, $form_state);
+ }
+
+ /**
+ * Called to implement a relationship in a query.
+ *
+ * Mostly the same as the parent method, except we add an extra clause to
+ * the join.
+ */
+ function query() {
+ $table_data = views_fetch_data($this->definition['base']);
+ $base_field = empty($this->definition['base field']) ? $table_data['table']['base']['field'] : $this->definition['base field'];
+ $this->ensure_my_table();
+
+ $def = $this->definition;
+ $def['table'] = $this->definition['base'];
+ $def['field'] = $base_field;
+ $def['left_table'] = $this->table_alias;
+ $def['left_field'] = $this->field;
+ if (!empty($this->options['required'])) {
+ $def['type'] = 'INNER';
+ }
+
+ // Add an extra clause to the join if there are bundle types selected.
+ if ($this->options['bundle_types']) {
+ $entity_info = entity_get_info($table_data['table']['entity type']);
+ $def['extra'] = array(
+ array(
+ // The table and the IN operator are implicit.
+ 'field' => $entity_info['entity keys']['bundle'],
+ 'value' => $this->options['bundle_types'],
+ ),
+ );
+ }
+
+ if (!empty($def['join_handler']) && class_exists($def['join_handler'])) {
+ $join = new $def['join_handler'];
+ }
+ else {
+ $join = new views_join();
+ }
+
+ $join->definition = $def;
+ $join->construct();
+ $join->adjusted = TRUE;
+
+ // Use a short alias for this.
+ $alias = $def['table'] . '_' . $this->table;
+ $this->alias = $this->query->add_relationship($alias, $join, $this->definition['base'], $this->relationship);
+ }
+}
diff --git a/www/modules/contrib/entity_plus/views/plugins/entity_plus_views_plugin_row_entity_view.inc b/www/modules/contrib/entity_plus/views/plugins/entity_plus_views_plugin_row_entity_view.inc
new file mode 100644
index 000000000..bfef24a3c
--- /dev/null
+++ b/www/modules/contrib/entity_plus/views/plugins/entity_plus_views_plugin_row_entity_view.inc
@@ -0,0 +1,98 @@
+view->base_table);
+ $this->entity_type = $table_data['table']['entity type'];
+ // Set base table and field information as used by views_plugin_row to
+ // select the entity id if used with default query class.
+ $info = entity_get_info($this->entity_type);
+ if (!empty($info['base table']) && $info['base table'] == $this->view->base_table) {
+ $this->base_table = $info['base table'];
+ $this->base_field = $info['entity keys']['id'];
+ }
+ }
+
+ public function option_definition() {
+ $options = parent::option_definition();
+ $options['view_mode'] = array('default' => 'full');
+ return $options;
+ }
+
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+
+ $entity_info = entity_get_info($this->entity_type);
+ $options = array();
+ if (!empty($entity_info['view modes'])) {
+ foreach ($entity_info['view modes'] as $mode => $settings) {
+ $options[$mode] = $settings['label'];
+ }
+ }
+
+ if (count($options) > 1) {
+ $form['view_mode'] = array(
+ '#type' => 'select',
+ '#options' => $options,
+ '#title' => t('View mode'),
+ '#default_value' => $this->options['view_mode'],
+ );
+ }
+ else {
+ $form['view_mode_info'] = array(
+ '#type' => 'item',
+ '#title' => t('View mode'),
+ '#description' => t('Only one view mode is available for this entity type.'),
+ '#markup' => $options ? current($options) : t('Default'),
+ );
+ $form['view_mode'] = array(
+ '#type' => 'value',
+ '#value' => $options ? key($options) : 'default',
+ );
+ }
+ return $form;
+ }
+
+ public function pre_render($values) {
+ if (!empty($values)) {
+ list($this->entity_type, $this->entities) = $this->view->query->get_result_entities($values, !empty($this->relationship) ? $this->relationship : NULL, isset($this->field_alias) ? $this->field_alias : NULL);
+ }
+ // Render the entities.
+ if ($this->entities) {
+ $render = entity_plus_view($this->entity_type, $this->entities, $this->options['view_mode']);
+ // Remove the first level of the render array.
+ $this->rendered_content = reset($render);
+ }
+ }
+
+ /**
+ * Overridden to return the entity object.
+ */
+ function get_value($values, $field = NULL) {
+ return isset($this->entities[$this->view->row_index]) ? $this->entities[$this->view->row_index] : FALSE;
+ }
+
+ public function render($values) {
+ if ($entity = $this->get_value($values)) {
+ // Add the view object as views_plugin_row_node_view::render() would.
+ // Otherwise the views theme suggestions won't work properly.
+ $entity->view = $this->view;
+ $render = $this->rendered_content[entity_plus_id($this->entity_type, $entity)];
+ return backdrop_render($render);
+ }
+ }
+}
diff --git a/www/modules/contrib/search_api/LICENSE.txt b/www/modules/contrib/search_api/LICENSE.txt
new file mode 100644
index 000000000..d159169d1
--- /dev/null
+++ b/www/modules/contrib/search_api/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/www/modules/contrib/search_api/README.md b/www/modules/contrib/search_api/README.md
new file mode 100644
index 000000000..2f08c2a91
--- /dev/null
+++ b/www/modules/contrib/search_api/README.md
@@ -0,0 +1,39 @@
+Search API
+==========
+
+This module provides a framework for easily creating searches on any entity
+known to Backdrop, 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, a
+high-performance search engine is available for use with the Search API.
+
+HOW TO INSTALL:
+---------------
+- Install this module using the [official Backdrop CMS instructions](https://backdropcms.org/guide/modules).
+
+DOCUMENTATION:
+-------------
+ - Additional [documentation is located in the Wiki](https://github.com/backdrop-contrib/search_api/wiki/Search-API-Documentation).
+
+ISSUES:
+------
+ - Bugs and Feature requests should be reported in the [Issue Queue](https://github.com/backdrop-contrib/search_api/issues).
+
+CURRENT MAINTAINERS:
+---------------
+- [earlyburg](https://github.com/earlyburg).
+- [Laryn Kragt Bakker](https://github.com/laryn).
+
+CREDITS:
+---------------
+- Ported to Backdrop by [docwilmot](https://github.com/docwilmot).
+- Maintainer on Drupal is [drunken monkey](https://www.drupal.org/u/drunken-monkey).
+- **Search API Multi** has been ported to Backdrop by
+ [Laryn Kragt Bakker](https://github.com/laryn) and merged into this module; it
+ was also maintained for Drupal 7 by [drunken monkey](https://www.drupal.org/u/drunken-monkey).
+
+LICENSE:
+---------------
+This project is GPL v2 software. See the LICENSE.txt file in this directory
+for complete text.
diff --git a/www/modules/contrib/search_api/config/search_api.settings.json b/www/modules/contrib/search_api/config/search_api.settings.json
new file mode 100644
index 000000000..4e2638fcb
--- /dev/null
+++ b/www/modules/contrib/search_api/config/search_api.settings.json
@@ -0,0 +1,4 @@
+{
+ "_config_name": "search_api.settings",
+ "search_api_index_worker_callback_runtime": "15"
+}
\ No newline at end of file
diff --git a/www/modules/contrib/search_api/contrib/search_api_facetapi/README.md b/www/modules/contrib/search_api/contrib/search_api_facetapi/README.md
new file mode 100644
index 000000000..9ce6b1109
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/README.md
@@ -0,0 +1,126 @@
+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.
+
+- Facet Search ID State
+
+The module uses a state 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 state:
+ `state_del("search_api_facets_search_ids");`
diff --git a/www/modules/contrib/search_api/contrib/search_api_facetapi/example_service.php b/www/modules/contrib/search_api/contrib/search_api_facetapi/example_service.php
new file mode 100644
index 000000000..4dcb61f1b
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/example_service.php
@@ -0,0 +1,208 @@
+ 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/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/adapter.inc b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
new file mode 100644
index 000000000..e74787dfa
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
@@ -0,0 +1,313 @@
+info['instance'];
+ return $base_path . '/index/' . $index_id . '/facets/' . $realm_name;
+ }
+
+ /**
+ * Overrides FacetapiAdapter::getSearchPath().
+ */
+ public function getSearchPath() {
+ return search_api_facetapi_current_search_path();
+ }
+
+ /**
+ * 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'];
+ // Only act on queries from the right index.
+ if ($index_id != $query->getIndex()->machine_name) {
+ return;
+ }
+ $facets = facetapi_get_enabled_facets($this->info['name']);
+ $this->fields = array();
+
+ // 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 = &backdrop_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();
+
+ // Remember this search ID, if necessary.
+ $this->rememberSearchId($index_id, $search_id);
+
+ if (array_search($search_id, $facet_search_ids) === FALSE) {
+ if (!$default_true) {
+ // We are only to show facets for explicitly named search ids.
+ continue;
+ }
+ }
+ elseif ($default_true) {
+ // The 'facet_search_ids' in the settings are to be excluded.
+ continue;
+ }
+ $facet_key = $facet['name'] . '@' . $this->getSearcher();
+ $active[$facet_key] = $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'],
+ );
+ }
+ }
+
+ /**
+ * Adds a search ID to the list of known searches for an index.
+ *
+ * @param string $index_id
+ * The machine name of the search index.
+ * @param string $search_id
+ * The identifier of the executed search.
+ */
+ protected function rememberSearchId($index_id, $search_id) {
+ $search_ids = state_get('search_api_facets_search_ids');
+ if (empty($search_ids[$index_id][$search_id])) {
+ $search_ids[$index_id][$search_id] = $search_id;
+ asort($search_ids[$index_id]);
+ state_set('search_api_facets_search_ids', $search_ids);
+ }
+ }
+
+ /**
+ * Add the given facet to the query.
+ */
+ public function addFacet(array $facet, SearchApiQueryInterface $query) {
+ if (isset($this->fields[$facet['name']])) {
+ $options = &$query->getOptions();
+ $facet_info = $this->fields[$facet['name']];
+ if (!empty($facet['query_options'])) {
+ // Let facet-specific query options override the set options.
+ $facet_info = $facet['query_options'] + $facet_info;
+ }
+ $options['search_api_facets'][$facet['name']] = $facet_info;
+ }
+ }
+
+ /**
+ * 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() {
+ // Even if this fails once, there might be a search query later in the page
+ // request. We therefore don't store anything in $this->current_search in
+ // case of failure, but just try again if the method is called again.
+ if (!isset($this->current_search)) {
+ $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) = $search;
+ if ($query->getIndex()->machine_name == $index_id) {
+ $this->current_search = $search;
+ }
+ }
+ }
+ return $this->current_search;
+ }
+
+ /**
+ * 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();
+
+ // If the search is empty then there's no reason to continue.
+ if (!$search) {
+ return NULL;
+ }
+
+ $keys = $search[0]->getOriginalKeys();
+ if (is_array($keys)) {
+ // This will happen nearly never when displaying the search keys to the
+ // 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') . ']';
+ }
+ backdrop_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'];
+ $facet_settings = $this->getFacet($facet)->getSettings();
+ $options = $facet_settings->settings;
+ $search_ids = state_get('search_api_facets_search_ids');
+ $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'),
+ '#prefix' => '',
+ '#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'),
+ '#suffix' => '
',
+ '#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(),
+ );
+ }
+
+ // Add a granularity option to date query types.
+ if (isset($facet['query type']) && $facet['query type'] == 'date') {
+ $granularity_options = array(
+ FACETAPI_DATE_YEAR => t('Years'),
+ FACETAPI_DATE_MONTH => t('Months'),
+ FACETAPI_DATE_DAY => t('Days'),
+ FACETAPI_DATE_HOUR => t('Hours'),
+ FACETAPI_DATE_MINUTE => t('Minutes'),
+ FACETAPI_DATE_SECOND => t('Seconds'),
+ );
+
+ $form['global']['date_granularity'] = array(
+ '#type' => 'select',
+ '#title' => t('Granularity'),
+ '#description' => t('Determine the maximum drill-down level'),
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#options' => $granularity_options,
+ '#default_value' => isset($options['date_granularity']) ? $options['date_granularity'] : FACETAPI_DATE_MINUTE,
+ );
+
+ $default_value = FACETAPI_DATE_YEAR;
+ if (isset($options['date_granularity_min'])) {
+ $default_value = $options['date_granularity_min'];
+ }
+ $form['global']['date_granularity_min'] = array(
+ '#type' => 'select',
+ '#title' => t('Minimum granularity'),
+ '#description' => t('Determine the minimum drill-down level to start at'),
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#options' => $granularity_options,
+ '#default_value' => $default_value,
+ );
+ }
+
+ // Add an "Exclude" option for terms.
+ if (!empty($facet['query types']) && in_array('term', $facet['query types'])) {
+ $form['global']['operator']['#weight'] = -2;
+ unset($form['global']['operator']['#suffix']);
+ $form['global']['exclude'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Exclude'),
+ '#description' => t('Make the search exclude selected facets, instead of restricting it to them.'),
+ '#suffix' => '',
+ '#weight' => -1,
+ '#default_value' => !empty($options['exclude']),
+ );
+ }
+ }
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
new file mode 100644
index 000000000..e80a3085a
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
@@ -0,0 +1,366 @@
+adapter->addFacet($this->facet, $query);
+
+ $settings = $this->adapter->getFacet($this->facet)->getSettings()->settings;
+
+ // First check if the facet is enabled for this search.
+ $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+ $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+ if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+ // Facet is not enabled for this search ID.
+ return;
+ }
+
+ // Change limit to "unlimited" (-1).
+ $options = &$query->getOptions();
+ if (!empty($options['search_api_facets'][$this->facet['name']])) {
+ $options['search_api_facets'][$this->facet['name']]['limit'] = -1;
+ }
+
+ if ($active_items = $this->adapter->getActiveItems($this->facet)) {
+ $field = $this->facet['field'];
+ $operator = 'OR';
+ if ($settings['operator'] !== FACETAPI_OPERATOR_OR) {
+ $operator = 'AND';
+ // If the operator is AND, we just need to apply the lowest-level
+ // filter(s) to make this work correctly. For single-valued fields, this
+ // will always just be the last value, so just use that to improve
+ // performance for that case.
+ $fields = $query->getIndex()->getFields();
+ if (isset($fields[$field]['type'])
+ && !search_api_is_list_type($fields[$field]['type'])) {
+ $active_items = array(end($active_items));
+ }
+ }
+ $date_query = $query->createFilter($operator, array("facet:$field"));
+ foreach ($active_items as $active_item) {
+ $filter = $this->createRangeFilter($active_item['value']);
+ if ($filter) {
+ $this->addFacetFilter($date_query, $field, $filter, $query);
+ }
+ }
+ $query->filter($date_query);
+ }
+ }
+
+ /**
+ * Rewrites the handler-specific date range syntax to the normal facet syntax.
+ *
+ * @param string $value
+ * The user-facing facet value.
+ *
+ * @return string|null
+ * A facet to add as a filter, in the format used internally in this module.
+ * Or NULL if the raw facet in $value is not valid.
+ */
+ protected function createRangeFilter($value) {
+ // Ignore any filters passed directly from the server (range or missing).
+ if (!$value || $value == '!' || (!ctype_digit($value[0]) && preg_match('/^[\[(][^ ]+ TO [^ ]+[\])]$/', $value))) {
+ return $value ? $value : NULL;
+ }
+
+ // Parse into date parts.
+ $parts = $this->parseRangeFilter($value);
+
+ // Return NULL if the date parts are invalid or none were found.
+ if (empty($parts)) {
+ return NULL;
+ }
+
+ $date = new DateTime();
+ switch (count($parts)) {
+ case 1:
+ $date->setDate($parts[0], 1, 1);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0] + 1, 1, 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 2:
+ // Luckily, $month = 13 is treated as January of next year. (The same
+ // goes for all other parameters.) We use the inverse trick for the
+ // seconds of the upper bound, since that's inclusive and we want to
+ // stop at a second before the next segment starts.
+ $date->setDate($parts[0], $parts[1], 1);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0], $parts[1] + 1, 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 3:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0], $parts[1], $parts[2] + 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 4:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], 0, 0);
+ $lower = $date->format('U');
+ $date->setTime($parts[3] + 1, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 5:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], $parts[4], 0);
+ $lower = $date->format('U');
+ $date->setTime($parts[3], $parts[4] + 1, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 6:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], $parts[4], $parts[5]);
+ return $date->format('U');
+
+ default:
+ return $value;
+ }
+
+ return "[$lower TO $upper]";
+ }
+
+ /**
+ * Parses the date range filter value into parts.
+ *
+ * @param string $value
+ * The user-facing facet value.
+ *
+ * @return int[]|null
+ * An array of date parts, or NULL if an invalid value was provided.
+ */
+ protected static function parseRangeFilter($value) {
+ $parts = explode('-', $value);
+
+ foreach ($parts as $i => $part) {
+ // Invalidate if part is not an integer.
+ if ($part === '' || !is_numeric($part) || intval($part) != $part) {
+ return NULL;
+ }
+ $parts[$i] = (int) $part;
+ // Depending on the position, negative numbers or 0 are invalid.
+ switch ($i) {
+ case 0:
+ // Years can contain anything – negative values are unlikely, but
+ // technically possible.
+ break;
+ case 1:
+ case 2:
+ // Days and months have to be positive.
+ if ($part <= 0) {
+ return NULL;
+ }
+ break;
+ default:
+ // All others can be 0, but not negative.
+ if ($part < 0) {
+ return NULL;
+ }
+ }
+ }
+
+ return $parts;
+ }
+
+ /**
+ * Replacement callback for replacing ISO dates with timestamps.
+ *
+ * Not used anymore, but kept for backwards compatibility with potential
+ * subclasses.
+ */
+ public function replaceDateString($matches) {
+ return strtotime($matches[0]);
+ }
+
+ /**
+ * Initializes the facet's build array.
+ *
+ * @return array
+ * The initialized render array.
+ */
+ public function build() {
+ $facet = $this->adapter->getFacet($this->facet);
+ $search_ids = backdrop_static('search_api_facetapi_active_facets', array());
+ $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+ if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
+ return array();
+ }
+ $search_id = $search_ids[$facet_key];
+ $build = array();
+ $search = search_api_current_search($search_id);
+ $results = $search[1];
+ // Gets total number of documents matched in search.
+ $total = $results['result count'];
+
+ // Executes query, iterates over results.
+ if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+ $values = $results['search_api_facets'][$this->facet['name']];
+ $mincount = $facet->getSettings()->settings['facet_mincount'];
+ foreach ($values as $value) {
+ if ($value['count'] >= $mincount) {
+ $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 {
+ $build[$filter]['#count'] = $value['count'];
+ }
+ }
+ }
+ }
+
+ $settings = $facet->getSettings()->settings;
+
+ // Get the finest level of detail we're allowed to drill down to.
+ $max_granularity = FACETAPI_DATE_MINUTE;
+ if (isset($settings['date_granularity'])) {
+ $max_granularity = $settings['date_granularity'];
+ }
+
+ // Get the coarsest level of detail we're allowed to start at.
+ $min_granularity = FACETAPI_DATE_YEAR;
+ if (isset($settings['date_granularity_min'])) {
+ $min_granularity = $settings['date_granularity_min'];
+ }
+
+ // Gets active facets, starts building hierarchy.
+ $parent = $granularity = NULL;
+ $active_items = $this->adapter->getActiveItems($this->facet);
+ foreach ($active_items as $value => $item) {
+ // If the item is active, the count is the result set count.
+ $build[$value] = array('#count' => $total);
+
+ // Gets next "gap" increment. Ignore any filters passed directly from the
+ // server (range or missing). We always create filters starting with a
+ // year.
+ $value = "$value";
+ if (!$value || !ctype_digit($value[0])) {
+ continue;
+ }
+
+ $granularity = search_api_facetapi_date_get_granularity($value);
+ if (!$granularity) {
+ continue;
+ }
+ $granularity = facetapi_get_next_date_gap($granularity, $max_granularity);
+
+ // If there is a previous item, there is a parent, uses a reference so the
+ // arrays are populated when they are updated.
+ if (NULL !== $parent) {
+ $build[$parent]['#item_children'][$value] = &$build[$value];
+ $build[$value]['#item_parents'][$parent] = $parent;
+ }
+ }
+
+ 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) {
+ $granularity = facetapi_get_timestamp_gap(min($timestamps), max($timestamps), $max_granularity);
+ // Array of numbers used to determine whether the next gap is smaller than
+ // the minimum gap allowed in the drilldown.
+ $gap_numbers = array(
+ FACETAPI_DATE_YEAR => 6,
+ FACETAPI_DATE_MONTH => 5,
+ FACETAPI_DATE_DAY => 4,
+ FACETAPI_DATE_HOUR => 3,
+ FACETAPI_DATE_MINUTE => 2,
+ FACETAPI_DATE_SECOND => 1,
+ );
+ // Gets gap numbers for both the gap, minimum and maximum gap, checks if
+ // the gap is within the limit set by the $granularity parameters.
+ if ($gap_numbers[$granularity] < $gap_numbers[$max_granularity]) {
+ $granularity = $max_granularity;
+ }
+ if ($gap_numbers[$granularity] > $gap_numbers[$min_granularity]) {
+ $granularity = $min_granularity;
+ }
+ }
+ else {
+ $granularity = $max_granularity;
+ }
+ }
+
+ // Groups dates by the range they belong to, builds the $build array with
+ // the facet counts and formatted range values.
+ $format = search_api_facetapi_date_get_granularity_format($granularity);
+ foreach ($raw_values as $value => $count) {
+ $new_value = date($format, $value);
+ if (!isset($build[$new_value])) {
+ $build[$new_value] = array('#count' => $count);
+ }
+ // Active items already have their value set because it's the current
+ // result count.
+ elseif (!isset($active_items[$new_value])) {
+ $build[$new_value]['#count'] += $count;
+ }
+
+ // 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/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
new file mode 100644
index 000000000..fbcded82b
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
@@ -0,0 +1,241 @@
+adapter->addFacet($this->facet, $query);
+
+ $settings = $this->getSettings()->settings;
+
+ // First check if the facet is enabled for this search.
+ $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+ $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+ if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+ // Facet is not enabled for this search ID.
+ return;
+ }
+
+ // Retrieve the active facet filters.
+ $active = $this->adapter->getActiveItems($this->facet);
+ if (empty($active)) {
+ return;
+ }
+
+ // Create the facet filter, and add a tag to it so that it can be easily
+ // identified down the line by services when they need to exclude facets.
+ $operator = $settings['operator'];
+ if ($operator == FACETAPI_OPERATOR_AND) {
+ $conjunction = 'AND';
+ }
+ elseif ($operator == FACETAPI_OPERATOR_OR) {
+ $conjunction = 'OR';
+ // When the operator is OR, remove parent terms from the active ones if
+ // children are active. If we don't do this, sending a term and its
+ // parent will produce the same results as just sending the parent.
+ if (is_callable($this->facet['hierarchy callback']) && !$settings['flatten']) {
+ // Check the filters in reverse order, to avoid checking parents that
+ // will afterwards be removed anyways.
+ $values = array_keys($active);
+ $parents = call_user_func($this->facet['hierarchy callback'], $values);
+ foreach (array_reverse($values) as $filter) {
+ // Skip this filter if it was already removed, or if it is the
+ // "missing value" filter ("!").
+ if (!isset($active[$filter]) || $filter == '!') {
+ continue;
+ }
+ // Go through the entire hierarchy of the value and remove all its
+ // ancestors.
+ while (!empty($parents[$filter])) {
+ $ancestor = array_shift($parents[$filter]);
+ if (isset($active[$ancestor])) {
+ unset($active[$ancestor]);
+ if (!empty($parents[$ancestor])) {
+ $parents[$filter] = array_merge($parents[$filter], $parents[$ancestor]);
+ }
+ }
+ }
+ }
+ }
+ }
+ else {
+ $vars = array(
+ '%operator' => $operator,
+ '%facet' => !empty($this->facet['label']) ? $this->facet['label'] : $this->facet['name'],
+ );
+ watchdog('search_api_facetapi', 'Unknown facet operator %operator used for facet %facet.', $vars, WATCHDOG_WARNING);
+ return;
+ }
+ $tags = array('facet:' . $this->facet['field']);
+ $facet_filter = $query->createFilter($conjunction, $tags);
+
+ foreach ($active as $filter => $filter_array) {
+ $field = $this->facet['field'];
+ $this->addFacetFilter($facet_filter, $field, $filter, $query);
+ }
+
+ // Now add the filter to the query.
+ $query->filter($facet_filter);
+ }
+
+ /**
+ * Helper method for setting a facet filter on a query or query filter object.
+ *
+ * @param SearchApiQueryInterface|SearchApiQueryFilterInterface $query_filter
+ * The query or filter to which apply the filter.
+ * @param string $field
+ * The field to apply the filter to.
+ * @param string $filter
+ * The filter, in the internal string representation used by this module.
+ * @param SearchApiQuery|null $query
+ * (optional) If available, the search query object should be passed as the
+ * fourth parameter.
+ */
+ protected function addFacetFilter($query_filter, $field, $filter) {
+ // Test if this filter should be negated.
+ $settings = $this->adapter->getFacet($this->facet)->getSettings();
+ $exclude = !empty($settings->settings['exclude']);
+ // Integer (or other non-string) filters might mess up some of the following
+ // comparison expressions.
+ $filter = (string) $filter;
+ if ($filter == '!') {
+ $query_filter->condition($field, NULL, $exclude ? '<>' : '=');
+ }
+ elseif ($filter && $filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
+ $lower = trim(substr($filter, 1, $pos));
+ $upper = trim(substr($filter, $pos + 4, -1));
+ $supports_between = FALSE;
+ if (func_num_args() > 3) {
+ $query = func_get_arg(3);
+ if ($query instanceof SearchApiQuery) {
+ try {
+ $supports_between = $query->getIndex()->server()
+ ->supportsFeature('search_api_between');
+ }
+ catch (SearchApiException $e) {
+ // Ignore, really not that important (and rather unlikely).
+ }
+ }
+ }
+ if ($lower == '*' && $upper == '*') {
+ $query_filter->condition($field, NULL, $exclude ? '=' : '<>');
+ }
+ elseif ($supports_between && $lower != '*' && $upper != '*') {
+ $operator = $exclude ? 'NOT BETWEEN' : 'BETWEEN';
+ $query_filter->condition($field, array($lower, $upper), $operator);
+ }
+ elseif (!$exclude) {
+ if ($lower != '*') {
+ // Iff we have a range with two finite boundaries, we set two
+ // conditions (larger than the lower bound and less than the upper
+ // 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 {
+ // Same as above, but with inverted logic.
+ if ($lower != '*') {
+ if ($upper != '*' && ($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
+ $original_query_filter = $query_filter;
+ $query_filter = new SearchApiQueryFilter('OR');
+ }
+ $query_filter->condition($field, $lower, '<');
+ }
+ if ($upper != '*') {
+ $query_filter->condition($field, $upper, '>');
+ }
+ }
+ }
+ else {
+ $query_filter->condition($field, $filter, $exclude ? '<>' : '=');
+ }
+ 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 = backdrop_static('search_api_facetapi_active_facets', array());
+ $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+ if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
+ return array();
+ }
+ $search_id = $search_ids[$facet_key];
+ list(, $results) = search_api_current_search($search_id);
+ $build = array();
+
+ // Always include the active facet items.
+ foreach ($this->adapter->getActiveItems($this->facet) as $filter) {
+ $build[$filter['value']]['#count'] = 0;
+ }
+
+ // Then, add the facets returned by the server.
+ if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+ $values = $results['search_api_facets'][$this->facet['name']];
+ foreach ($values as $value) {
+ $filter = $value['filter'];
+ // As Facet API isn't really suited for our native facet filter
+ // 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/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.api.php b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.api.php
new file mode 100644
index 000000000..eb5c890c6
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.api.php
@@ -0,0 +1,31 @@
+delete();
+ _search_api_facetapi_set_date_formats();
+}
+
+/**
+ * Helper to ensure the custom date formats exist.
+ */
+function _search_api_facetapi_set_date_formats($action = 'set') {
+ include_once BACKDROP_ROOT . '/core/includes/update.inc';
+ $save_needed = FALSE;
+ $date_config = config('system.date');
+ $defaults = array(
+ 'module' => 'search_api_facetapi',
+ );
+ $date_config_formats = $date_config->get('formats');
+ $date_formats = array(
+ FACETAPI_DATE_YEAR => array(
+ 'label' => t('Search facets - Years'),
+ 'pattern' => 'Y',
+ ) + $defaults,
+ FACETAPI_DATE_MONTH => array(
+ 'label' => t('Search facets - Months'),
+ 'pattern' => 'F Y',
+ ) + $defaults,
+ FACETAPI_DATE_DAY => array(
+ 'label' => t('Search facets - Days'),
+ 'pattern' => 'F j, Y',
+ ) + $defaults,
+ FACETAPI_DATE_HOUR => array(
+ 'label' => t('Search facets - Hours'),
+ 'pattern' => 'H:__',
+ ) + $defaults,
+ FACETAPI_DATE_MINUTE => array(
+ 'label' => t('Search facets - Minutes'),
+ 'pattern' => 'H:i',
+ ) + $defaults,
+ FACETAPI_DATE_SECOND => array(
+ 'label' => t('Search facets - Seconds'),
+ 'pattern' => 'H:i:s',
+ ) + $defaults,
+ );
+ foreach ($date_formats as $type => $format) {
+ $type = strtolower($type);
+ if ($action == 'set' && empty($date_config_formats['search_api_facetapi_' . $type])) {
+ $format['pattern'] = update_variable_get('search_api_facetapi_' . $type, $format['pattern']);
+ $date_config->set('formats.search_api_facetapi_' . $type, $format);
+ $save_needed = TRUE;
+ }
+ elseif ($action == 'unset') {
+ $date_config->clear('formats.search_api_facetapi_' . $type);
+ $save_needed = TRUE;
+ }
+ }
+ if ($save_needed) {
+ $date_config->save();
+ }
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.module b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.module
new file mode 100644
index 000000000..894cb8640
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.module
@@ -0,0 +1,677 @@
+ $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 (_search_api_facetapi_index_support_feature($index)) {
+ $searcher_name = 'search_api@' . $index->machine_name;
+ $info[$searcher_name] = array(
+ 'label' => t('Search service: @name', array('@name' => $index->name)),
+ '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,
+ );
+ if (($entity_type = $index->getEntityType()) && $entity_type !== $index->item_type) {
+ $info[$searcher_name]['types'][] = $entity_type;
+ }
+ }
+ }
+ 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 ($index->getEntityType() && ($entity_info = entity_get_info($index->getEntityType())) && !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' => 'search_api_facetapi_get_taxonomy_hierarchy',
+ ),
+ 'date' => array(
+ 'query type' => 'date',
+ 'map options' => array(
+ 'map callback' => 'search_api_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.
+ if (isset($field['entity_type'])) {
+ $type = $inner_type = $field['entity_type'];
+ }
+ else {
+ $type = $field['type'];
+ $inner_type = search_api_extract_inner_type($type);
+ }
+ $type_settings += array($inner_type => array());
+
+ $facet_info[$key] = $type_settings[$inner_type] + array(
+ 'label' => $field['name'],
+ 'description' => t('Filter by @type.', array('@type' => $field['name'])),
+ 'allowed operators' => array(
+ FACETAPI_OPERATOR_AND => TRUE,
+ FACETAPI_OPERATOR_OR => _search_api_facetapi_index_support_feature($index, '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 ($inner_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->getEntityType();
+ }
+ 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.
+ search_api_facetapi_current_search_path($query);
+ $searcher = 'search_api@' . $index->machine_name;
+ $adapter = facetapi_adapter_load($searcher);
+ if ($adapter) {
+ $adapter->addActiveFilters($query);
+ }
+ }
+}
+
+/**
+ * Helper function that statically stores the Search API's base path. Can
+ * either be used to store or retrieve the base path.
+ *
+ * This static store is used in SearchApiFacetapiAdapter::getSearchPath() in
+ * order to retrieve the current search base path. Just relying on
+ * search_api_current_search() to retrieve the current query is not enough, as
+ * the path might be requested before the query is finally executed and stored.
+ *
+ * @param SearchApiQueryInterface $query
+ * When storing the base base, the query that is executed. Else NULL.
+ *
+ * @return
+ * The Search API's base path.
+ */
+function search_api_facetapi_current_search_path(SearchApiQueryInterface $query = NULL) {
+ $path = &backdrop_static(__FUNCTION__, '');
+ if ($query && $query->getOption('search_api_base_path')) {
+ $path = $query->getOption('search_api_base_path');
+ }
+ // If the path has not been set (yet), return the current path as a fallback.
+ return $path ? $path : current_path();
+}
+
+/**
+ * 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 (!_search_api_facetapi_index_support_feature($index)) {
+ return array('#markup' => t('This index uses a server that does not support facet functionality.'));
+ }
+ $searcher_name = 'search_api@' . $index->machine_name;
+ module_load_include('inc', 'facetapi', 'facetapi.admin');
+ return backdrop_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name);
+}
+
+/**
+ * Checks whether a certain feature is supported for an index.
+ *
+ * @param SearchApiIndex $index
+ * The search index which should be checked.
+ * @param string $feature
+ * (optional) The feature to check for. Defaults to "search_api_facets".
+ *
+ * @return bool
+ * TRUE if the feature is supported by the index's server (and the index is
+ * currently enabled), FALSE otherwise.
+ */
+function _search_api_facetapi_index_support_feature(SearchApiIndex $index, $feature = 'search_api_facets') {
+ try {
+ $server = $index->server();
+ return $server && $server->supportsFeature($feature);
+ }
+ catch (SearchApiException $e) {
+ return FALSE;
+ }
+}
+
+/**
+ * Gets hierarchy information for taxonomy terms.
+ *
+ * Used as a hierarchy callback in search_api_facetapi_facetapi_facet_info().
+ *
+ * Internally just uses facetapi_get_taxonomy_hierarchy(), but makes sure that
+ * our special "!" value is not passed.
+ *
+ * @param array $values
+ * An array containing the term IDs.
+ *
+ * @return array
+ * An associative array mapping term IDs to parent IDs (where parents could be
+ * found).
+ */
+function search_api_facetapi_get_taxonomy_hierarchy(array $values) {
+ $values = array_filter($values, 'is_numeric');
+ return $values ? facetapi_get_taxonomy_hierarchy($values) : array();
+}
+
+/**
+ * Map callback for all search_api facet fields.
+ *
+ * @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.
+ *
+ * @param array $values
+ * The values for which labels should be returned.
+ * @param array $options
+ * An associative array containing the following information about the facet:
+ * - field: Field information, as stored in the index, but with an additional
+ * "key" property set to the field's internal name.
+ * - index id: The machine name of the index for this facet.
+ * - map callback: (optional) A callback that will be called at the beginning,
+ * which allows initial mapping of filters. Only values not mapped by that
+ * callback will be processed by this method.
+ * - value callback: A callback used to map single values and the limits of
+ * ranges. The signature is the same as for this function, but all values
+ * will be single values.
+ * - missing label: (optional) The label used for the "missing" facet.
+ *
+ * @return array
+ * An array mapping raw facet values to their labels.
+ */
+function _search_api_facetapi_facet_create_label(array $values, array $options) {
+ $field = $options['field'];
+ $map = array();
+ $n = count($values);
+
+ // For entities, we can simply use the entity labels.
+ if (isset($field['entity_type'])) {
+ $type = $field['entity_type'];
+ $entities = entity_load_multiple($type, $values);
+ foreach ($entities as $id => $entity) {
+ $label = entity_label($type, $entity);
+ if ($label !== FALSE) {
+ $map[$id] = $label;
+ }
+ }
+ if (count($map) == $n) {
+ return $map;
+ }
+ }
+
+ // Then, we check whether there is an options list for the field.
+ $index = search_api_index_load($options['index id']);
+ $wrapper = $index->entityWrapper();
+ $values = backdrop_map_assoc($values);
+ 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_list = $wrapper->optionsList('view'))) {
+ // We have no use for empty strings, as then the facet links would be
+ // invisible.
+ $map += array_intersect_key(array_filter($options_list, 'strlen'), $values);
+ if (count($map) == $n) {
+ return $map;
+ }
+ }
+
+ // As a "last resort" we try to create a label based on the field type, for
+ // all values that haven't got a mapping yet.
+ foreach (array_diff_key($values, $map) as $value) {
+ switch ($field['type']) {
+ case 'boolean':
+ $map[$value] = $value ? t('true') : t('false');
+ 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);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for views_exposed_form().
+ *
+ * Custom integration for facets. When a Views exposed filter is modified on a
+ * search results page, any facets which have been already selected will be
+ * removed. This (optionally) adds hidden fields for each facet so their values
+ * are retained.
+ */
+function search_api_facetapi_form_views_exposed_form_alter(array &$form, array &$form_state) {
+ if (empty($form_state['view'])) {
+ return;
+ }
+ $view = $form_state['view'];
+
+ // Check if this is a Search API-based view and if the "Preserve facets"
+ // option is enabled. ("search_api_multi" would be the exact base table name,
+ // not just a prefix, but since it's just 16 characters long, we can still use
+ // this check to make the condition less complex.)
+ $base_table_prefix = substr($view->base_table, 0, 17);
+ if (in_array($base_table_prefix, array('search_api_index_', 'search_api_multi'))
+ && _search_api_preserve_views_facets($view)) {
+ // Get query parameters.
+ $query_parameters = backdrop_get_query_parameters();
+
+ // Check if any facet query parameters are provided.
+ if (!empty($query_parameters['f'])) {
+ // Iterate through facet query parameters.
+ foreach ($query_parameters['f'] as $key => $value) {
+ // Add hidden form field for facet parameter.
+ $form['f[' . $key . ']'] = array(
+ '#type' => 'hidden',
+ '#value' => $value,
+ '#weight' => -1,
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Checks whether "Preserve facets" option is enabled on the given view.
+ *
+ * If the view display is overridden, use its configuration. Otherwise, use the
+ * default configuration.
+ *
+ * @param view $view
+ * The search view.
+ *
+* @return bool
+* TRUE if "Preserve facets" is enabled, FALSE otherwise.
+*/
+function _search_api_preserve_views_facets(view $view) {
+ $query_options = $view->display_handler->get_option('query');
+ return !empty($query_options['options']['preserve_facet_query_args']);
+}
+
+/**
+ * Computes the granularity of a date facet filter.
+ *
+ * @param $filter
+ * The filter value to examine.
+ *
+ * @return string|null
+ * Either one of the FACETAPI_DATE_* constants corresponding to the
+ * granularity of the filter, or NULL if it couldn't be computed.
+ */
+function search_api_facetapi_date_get_granularity($filter) {
+ // Granularity corresponds to number of dashes in filter value.
+ $units = array(
+ FACETAPI_DATE_YEAR,
+ FACETAPI_DATE_MONTH,
+ FACETAPI_DATE_DAY,
+ FACETAPI_DATE_HOUR,
+ FACETAPI_DATE_MINUTE,
+ FACETAPI_DATE_SECOND,
+ );
+ $count = substr_count($filter, '-');
+ return isset($units[$count]) ? $units[$count] : NULL;
+}
+
+/**
+ * Returns the date format used for a given granularity.
+ *
+ * @param $granularity
+ * One of the FACETAPI_DATE_* constants.
+ *
+ * @return string
+ * The date format used for the given granularity.
+ */
+function search_api_facetapi_date_get_granularity_format($granularity) {
+ $formats = array(
+ FACETAPI_DATE_YEAR => 'Y',
+ FACETAPI_DATE_MONTH => 'Y-m',
+ FACETAPI_DATE_DAY => 'Y-m-d',
+ FACETAPI_DATE_HOUR => 'Y-m-d-H',
+ FACETAPI_DATE_MINUTE => 'Y-m-d-H-i',
+ FACETAPI_DATE_SECOND => 'Y-m-d-H-i-s',
+ );
+ return $formats[$granularity];
+}
+
+/**
+ * Constructs labels for date facet filter values.
+ *
+ * @param array $values
+ * The date facet filter values, as used in URL parameters.
+ * @param array $options
+ * (optional) Options for creating the mapping. The following options are
+ * recognized:
+ * - format callback: A callback for creating a label for a timestamp. The
+ * function signature is like search_api_facetapi_format_timestamp(),
+ * receiving a timestamp and one of the FACETAPI_DATE_* constants as the
+ * parameters and returning a human-readable label.
+ *
+ * @return array
+ * An array of labels for the given facet filters.
+ */
+function search_api_facetapi_map_date(array $values, array $options = array()) {
+ $map = array();
+ foreach ($values as $value) {
+ // Ignore any filters passed directly from the server (range or missing). We
+ // always create filters starting with a year.
+ $value = "$value";
+ if (!$value || !ctype_digit($value[0])) {
+ continue;
+ }
+
+ // Get the granularity of the filter.
+ $granularity = search_api_facetapi_date_get_granularity($value);
+ if (!$granularity) {
+ continue;
+ }
+
+ // Otherwise, parse the timestamp from the known format and format it as a
+ // label.
+ $format = search_api_facetapi_date_get_granularity_format($granularity);
+ // Use the "!" modifier to make the date parsing independent of the current
+ // date/time. (See #2678856.)
+ $date = DateTime::createFromFormat('!' . $format, $value);
+ if (!$date) {
+ continue;
+ }
+ $format_callback = 'search_api_facetapi_format_timestamp';
+ if (!empty($options['format callback']) && is_callable($options['format callback'])) {
+ $format_callback = $options['format callback'];
+ }
+ $map[$value] = call_user_func($format_callback, $date->format('U'), $granularity);
+ }
+ return $map;
+}
+
+/**
+ * Format a date according to the default timezone and the given precision.
+ *
+ * @param int $timestamp
+ * An integer containing the Unix timestamp being formated.
+ * @param string $precision
+ * A string containing the formatting precision. See the FACETAPI_DATE_*
+ * constants for valid values.
+ *
+ * @return string
+ * A human-readable representation of the timestamp.
+ */
+function search_api_facetapi_format_timestamp($timestamp, $precision = FACETAPI_DATE_YEAR) {
+ $formats = array(
+ FACETAPI_DATE_YEAR,
+ FACETAPI_DATE_MONTH,
+ FACETAPI_DATE_DAY,
+ FACETAPI_DATE_HOUR,
+ FACETAPI_DATE_MINUTE,
+ FACETAPI_DATE_SECOND,
+ );
+
+ if (!in_array($precision, $formats)) {
+ $precision = FACETAPI_DATE_YEAR;
+ }
+ return format_date($timestamp, 'search_api_facetapi_' . strtolower($precision));
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function search_api_facetapi_autoload_info() {
+ return array(
+ 'SearchApiFacetapiExampleService' => 'example_service.php',
+ 'SearchApiFacetapiAdapter' => 'plugins/facetapi/adapter.inc',
+ 'SearchApiFacetapiDate' => 'plugins/facetapi/query_type_date.inc',
+ 'SearchApiFacetapiTerm' => 'plugins/facetapi/query_type_term.inc',
+ );
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.tests.info b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.tests.info
new file mode 100644
index 000000000..34d6985a6
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_facetapi/search_api_facetapi.tests.info
@@ -0,0 +1,5 @@
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/README.md b/www/modules/contrib/search_api/contrib/search_api_multi/README.md
new file mode 100644
index 000000000..8149206aa
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/README.md
@@ -0,0 +1,30 @@
+# Search API: Multi-index searches
+
+This module allows you to create search queries on multiple indexes that lie on
+the same server. The only thing you'll need is a search service class that
+supports the "search_api_multi" feature. Currently, only the "Solr search"
+supports this.
+
+Information for users
+---------------------
+
+Enable the Search views (search_api_views) module along with this one to make
+instant use of the multi-index searching facilities. You'll get a new base table
+in Views for each server supporting the "search_api_multi" feature.
+You can then add filters, arguments, fields and sorts (although the last one
+might work rather poorly, depending on the sorted field and the implementation)
+from all enabled indexes on this server.
+
+- Issues
+
+If you find any bugs or shortcomings while using this module, please file an
+issue in the project's issue queue
+
+Information for developers
+--------------------------
+
+If you are the developer of a SearchApiServiceInterface implementation and want
+to support searches on multiple indexes with your service class, too, you'll
+have to support the "search_api_multi" feature by implementing the
+SearchApiMultiServiceInterface interface documented in
+search_api_multi.service.inc.
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.query.inc b/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.query.inc
new file mode 100644
index 000000000..2c6befa1f
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.query.inc
@@ -0,0 +1,1214 @@
+", "<", "<=", ">=", ">". 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 SearchApiMultiQueryInterface
+ * 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.
+ *
+ * How sorts on index-specific fields are handled may differ between service
+ * backends.
+ *
+ * @param string $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 string $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 SearchApiMultiQueryInterface
+ * 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 int|null $offset
+ * The zero-based offset of the first result returned.
+ * @param int|null $limit
+ * The number of results to return.
+ *
+ * @return SearchApiMultiQueryInterface
+ * 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
+ * arbitrary unique keys, values are arrays containing the following keys:
+ * - id: The item's ID.
+ * - index_id: The machine name of the index this item was found on.
+ * - 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, keyed by their field identifiers (without index
+ * prefix). 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();
+
+ /**
+ * Retrieves the searched indexes.
+ *
+ * @return SearchApiIndex[]
+ * An array of SearchApiIndex objects representing all indexes that will be
+ * used for this search, keyed by machine names.
+ */
+ public function getIndexes();
+
+ /**
+ * Retrieves the search keys.
+ *
+ * @return array|string|null
+ * 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.
+ */
+ public function &getKeys();
+
+ /**
+ * Retrieves the original, unprocessed search keys.
+ *
+ * @return array|string|null
+ * The unprocessed search keys, exactly as passed to this object. Has the
+ * same format as getKeys().
+ */
+ public function getOriginalKeys();
+
+ /**
+ * Retrieves the searched fulltext fields.
+ *
+ * @return array|null
+ * An array containing the fields that should be searched for the search
+ * keys.
+ */
+ public function &getFields();
+
+ /**
+ * Retrieves the query's filter object.
+ *
+ * @return SearchApiQueryFilterInterface
+ * This object's associated filter object.
+ */
+ public function getFilter();
+
+ /**
+ * Retrieves the set sorts.
+ *
+ * @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();
+
+ /**
+ * Retrieves a single option.
+ *
+ * @param string $name
+ * The name of an option.
+ * @param mixed $default
+ * The default in case the option isn't set.
+ *
+ * @return mixed
+ * The value of the option with the specified name, if set; $default
+ * otherwise.
+ */
+ public function getOption($name, $default = NULL);
+
+ /**
+ * Sets an option.
+ *
+ * @param string $name
+ * The name of an option.
+ * @param mixed $value
+ * The new value of the option.
+ *
+ * @return mixed
+ * The option's previous value.
+ */
+ public function setOption($name, $value);
+
+ /**
+ * Retrieves all options for this query.
+ *
+ * @return array
+ * An associative array of query options.
+ */
+ public function &getOptions();
+
+}
+
+/**
+ * Standard implementation of SearchApiMultiQueryInterface.
+ *
+ * If the search involves only a single server which supports the
+ * "search_api_multi" feature, the methods for this feature are used. Otherwise,
+ * generic code allows the searching of multiple indexes.
+ */
+class SearchApiMultiQuery implements SearchApiMultiQueryInterface {
+
+ /**
+ * All indexes which are used in this search.
+ *
+ * This is first loaded with all indexes, and only restricted to the used ones
+ * during preExecute().
+ *
+ * @var array
+ */
+ protected $indexes = array();
+
+ /**
+ * The indexes which are currently used in this search.
+ *
+ * This collects the index IDs (in the keys) of indexes as they are used in
+ * the search, so the appropriate ones can be kept in $this->indexes during
+ * preExecute().
+ *
+ * @var array
+ */
+ protected $used_indexes = array();
+
+ /**
+ * All indexes which are used in this search, ordered by their servers.
+ *
+ * The array contains server machine names mapped to an array of all their
+ * searched indexes.
+ *
+ * @var array
+ */
+ protected $servers = array();
+
+ /**
+ * 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|null
+ */
+ protected $fields;
+
+ /**
+ * The fields that will be searched, grouped by index.
+ *
+ * @var array
+ */
+ protected $index_fields = array();
+
+ /**
+ * The search filter associated with this query.
+ *
+ * @var SearchApiQueryFilterInterface
+ */
+ protected $filter;
+
+ /**
+ * The sort associated with this query.
+ *
+ * @var array
+ */
+ protected $sort = array();
+
+ /**
+ * Search options configuring this query.
+ *
+ * @var array
+ */
+ protected $options;
+
+ /**
+ * Flag for whether preExecute() was already called for this query.
+ *
+ * @var bool
+ */
+ protected $pre_execute = FALSE;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(array $options = array()) {
+ 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->options = $options + array(
+ 'conjunction' => 'AND',
+ 'parse mode' => 'terms',
+ 'filter class' => 'SearchApiQueryFilter',
+ 'search id' => __CLASS__,
+ );
+ $this->filter = $this->createFilter('AND');
+ $this->indexes = search_api_index_load_multiple(FALSE, array('enabled' => TRUE));
+ foreach ($this->indexes as $index_id => $index) {
+ $this->servers[$index->server][$index_id] = $index;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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.'),
+ );
+ return $modes;
+ }
+
+ /**
+ * Parses the keys string according to a certain parse mode.
+ *
+ * @param string|array|null $keys
+ * The keys as passed to keys().
+ * @param string $mode
+ * The parse mode to use. Must be one of the keys from parseModes().
+ *
+ * @return string|array|null
+ * The parsed keys.
+ */
+ 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);
+ $ret['#conjunction'] = $this->options['conjunction'];
+ $quoted = FALSE;
+ $str = '';
+ foreach ($ret as $k => $v) {
+ if (!$v) {
+ continue;
+ }
+ if ($quoted) {
+ if ($v[backdrop_strlen($v) - 1] == '"') {
+ $v = substr($v, 0, -1);
+ $str .= ' ' . $v;
+ $ret[$k] = $str;
+ $quoted = FALSE;
+ }
+ else {
+ $str .= ' ' . $v;
+ unset($ret[$k]);
+ }
+ }
+ elseif ($v[0] == '"') {
+ $len = backdrop_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;
+ }
+ return array_filter($ret);
+
+ default:
+ throw new SearchApiException(t('Unrecognized parse mode %mode.', array('%mode' => $mode)));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createFilter($conjunction = 'AND', array $tags = array()) {
+ $filter_class = $this->options['filter class'];
+ return new $filter_class($conjunction, $tags);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fields($fields = NULL) {
+ $this->index_fields = array();
+ if ($fields) {
+ foreach ($fields as $spec) {
+ list($index_id, $field) = explode(':', $spec, 2);
+ $index = $this->indexes[$index_id];
+ if (empty($index->options['fields'][$field]) || !search_api_is_text_type($index->options['fields'][$field]['type'])) {
+ throw new SearchApiException(t('Trying to search on field @field which is no indexed fulltext field.', array('@field' => $field)));
+ }
+ $this->used_indexes[$index_id] = TRUE;
+ $this->index_fields[$index_id][] = $field;
+ }
+ }
+ $this->fields = $fields;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function filter(SearchApiQueryFilterInterface $filter) {
+ $this->filter->filter($filter);
+ $indexes = $this->checkFilterIndexes($filter);
+ $this->used_indexes += $indexes;
+ // Since the filter is added with AND to the query, the query will be
+ // restricted to the indexes encountered within it.
+ $this->indexes = array_intersect_key($this->indexes, $indexes);
+ return $this;
+ }
+
+ /**
+ * Checks a filter object for filters on the used indexes.
+ *
+ * @param SearchApiQueryFilterInterface $filter
+ * The filter whose indexes should be added.
+ *
+ * @return array
+ * An array mapping the machine names of all indexes used in the filter to
+ * TRUE.
+ */
+ protected function checkFilterIndexes(SearchApiQueryFilterInterface $filter) {
+ $indexes = array();
+ // Remember all the indexes of fields used in any filters, so we can later
+ // restrict the search to only those. Also, restrict the search correctly if
+ // the "search_api_multi_index" field is used.
+ foreach ($filter->getFilters() as $f) {
+ if (is_array($f)) {
+ if ($f[0] == 'search_api_multi_index') {
+ if ($f[2] == '=') {
+ $indexes[$f[1]] = TRUE;
+ }
+ else {
+ foreach ($this->indexes as $id => $index) {
+ if ($id != $f[1]) {
+ $indexes[$id] = TRUE;
+ }
+ }
+ }
+ }
+ elseif ($f[2] != '<>' && strpos($f[0], ':')) {
+ list($index_id) = explode(':', $f[0], 2);
+ $indexes[$index_id] = TRUE;
+ }
+ }
+ else {
+ $indexes += $this->checkFilterIndexes($f);
+ }
+ }
+ return $indexes;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function condition($field, $value, $operator = '=') {
+ if ($field == 'search_api_multi_index') {
+ if ($operator == '=') {
+ if (isset($this->indexes[$value])) {
+ $this->indexes = array($value => $this->indexes[$value]);
+ }
+ else {
+ throw new SearchApiException(t('Trying to filter multi-index query on two indexes simultaneously.'));
+ }
+ }
+ else {
+ unset($this->indexes[$value]);
+ }
+ }
+ else {
+ $this->filter->condition($field, $value, $operator);
+ if ($operator != '<>' && strpos($field, ':')) {
+ list($index_id) = explode(':', $field, 2);
+ $this->used_indexes[$index_id] = TRUE;
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function sort($field, $order = 'ASC') {
+ if ($field != 'search_api_relevance' && $field != 'search_api_id') {
+ list($index_id, $f) = explode(':', $field, 2);
+ $index = $this->indexes[$index_id];
+ $fields = $index->options['fields'];
+ if (empty($fields[$f])) {
+ throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $f)));
+ }
+ $type = $fields[$f]['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' => $f,
+ '@type' => $type,
+ )));
+ }
+ $this->used_indexes[$index_id] = TRUE;
+ }
+ $order = strtoupper(trim($order)) == 'DESC' ? 'DESC' : 'ASC';
+ $this->sort[$field] = $order;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function range($offset = NULL, $limit = NULL) {
+ $this->options['offset'] = $offset;
+ $this->options['limit'] = $limit;
+ return $this;
+ }
+
+ /**
+ * Implements SearchApiMultiQueryInterface::execute().
+ *
+ * Uses a server's searchMultiple() method, where possible.
+ */
+ public final function execute() {
+ $start = microtime(TRUE);
+
+ // Call pre-execute hook.
+ $this->preExecute();
+
+ // Let modules alter the query.
+ backdrop_alter('search_api_multi_query', $this);
+
+ $pre_search = microtime(TRUE);
+
+ // Execute query.
+ if (count($this->servers) == 1) {
+ $server = search_api_server_load(key($this->servers));
+ if ($server && $server->supportsFeature('search_api_multi')) {
+ $response = $server->searchMultiple($this);
+ }
+ }
+ if (!isset($response)) {
+ $response = $this->searchMultiple();
+ }
+
+ $post_search = microtime(TRUE);
+
+ // Call post-execute hook.
+ $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_multi_current_search(NULL, $this, $response);
+
+ return $response;
+ }
+
+ /**
+ * Helper method for adding a language filter.
+ *
+ * @param array $languages
+ * The languages which the query should include.
+ */
+ protected function addLanguages(array $languages) {
+ if (array_search(LANGUAGE_NONE, $languages) === FALSE) {
+ $languages[] = LANGUAGE_NONE;
+ }
+
+ $filter = $this->createFilter('OR');
+ foreach ($languages as $lang) {
+ foreach ($this->indexes as $index_id => $index) {
+ $filter->condition("$index_id:search_api_language", $lang);
+ }
+ }
+ $this->filter($filter);
+ }
+
+ /**
+ * Searches multiple indexes with this query.
+ *
+ * Workaround if there is no server's searchMultiple() method available.
+ *
+ * @return array
+ * Search results as specified by SearchApiMultiQueryInterface::execute().
+ */
+ protected function searchMultiple() {
+ // Prepare options/range.
+ $options = $this->options;
+ if (!empty($options['offset']) || isset($options['limit'])) {
+ $options['limit'] = isset($options['limit']) ? $options['offset'] + $options['limit'] : NULL;
+ $options['offset'] = 0;
+ }
+ // Prepare a normal Search API query for all contained indexes.
+ /** @var SearchApiQuery[] $queries */
+ $queries = array();
+ foreach ($this->getIndexes() as $index_id => $index) {
+ try {
+ $queries[$index_id] = search_api_query($index_id, $options);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api_multi', $e);
+ }
+ }
+
+ // Set the filters appropriately.
+ $this->addFilters($this->filter->getFilters(), $queries, $queries);
+
+ // Prepare and execute the search on every index available.
+ foreach ($queries as $index_id => $query) {
+ if (isset($this->orig_keys)) {
+ if (empty($this->index_fields[$index_id])) {
+ continue;
+ }
+ $query->keys($this->orig_keys);
+ $query->fields($this->index_fields[$index_id]);
+ }
+ foreach ($this->sort as $field => $order) {
+ if (strpos($field, ':') !== FALSE) {
+ list($field_index_id, $field) = explode(':', $field, 2);
+ if ($field_index_id != $index_id) {
+ continue;
+ }
+ }
+ $query->sort($field, $order);
+ }
+
+ $response = $query->execute();
+
+ if (!empty($response['results'])) {
+ // Adapt the results array to the multi-index format.
+ $results = array();
+ foreach ($response['results'] as $key => $result) {
+ $key = "$index_id:$key";
+ $results[$key] = $result;
+ $results[$key]['index_id'] = $index_id;
+ }
+ $response['results'] = $results;
+ }
+
+ if (!isset($return)) {
+ $return = array(
+ 'result count' => 0,
+ 'results' => array(),
+ 'performance' => array(),
+ );
+ }
+
+ // Add the new result count.
+ $return['result count'] += $response['result count'];
+
+ // Merge results.
+ if (!empty($response['results'])) {
+ $return['results'] = array_merge($return['results'], $response['results']);
+ }
+
+ // Merge performance.
+ if (!empty($response['performance'])) {
+ foreach ($response['performance'] as $measure => $time) {
+ $return['performance'] += array($measure => 0);
+ $return['performance'][$measure] += $time;
+ }
+ }
+
+ // Merge any additional keys. We can only guess what to do here, but we
+ // opt to merge array-valued keys together, and store all other kinds of
+ // data in a new array keyed by index ID.
+ unset($response['result count'], $response['results'], $response['performance']);
+ foreach ($response as $key => $value) {
+ if (is_array($value)) {
+ $return[$key] = isset($return[$key]) ? array_merge($value, $return[$key]) : $value;
+ }
+ else {
+ $return[$key][$index_id] = $value;
+ }
+ }
+ }
+
+ if (isset($return)) {
+ if (!empty($return['results'])) {
+ // Add default sorting by score, if it isn't included already.
+ if ($this->keys && !isset($this->sort['search_api_relevance'])) {
+ $this->sort['search_api_relevance'] = 'DESC';
+ }
+ // Sort the results.
+ if ($this->sort) {
+ $this->ensureSortFields($return['results']);
+ uasort($return['results'], array($this, 'compareResults'));
+ }
+ // Apply range.
+ $offset = $this->getOption('offset', 0);
+ $limit = $this->getOption('limit', NULL);
+ $return['results'] = array_slice($return['results'], $offset, $limit, TRUE);
+ }
+ return $return;
+ }
+
+ return array('result count' => 0);
+ }
+
+ /**
+ * Helper method for adding a filter to index-specific queries.
+ *
+ * @param SearchApiQueryFilterInterface[]|array[] $filters
+ * An array of filters to add, as returned by
+ * SearchApiQueryFilterInterface::getFilters().
+ * @param SearchApiQuery[] $parents
+ * The query or filter objects to which the filters should be applied, keyed
+ * by index ID.
+ * @param SearchApiQuery[] $queries
+ * The queries used, keyed by index ID.
+ */
+ protected function addFilters(array $filters, array $parents, array $queries) {
+ foreach ($filters as $filter) {
+ if (is_array($filter)) {
+ if ($filter[0] == 'search_api_multi_index') {
+ continue;
+ }
+ list($index_id, $field) = explode(':', $filter[0], 2);
+ if (!empty($parents[$index_id])) {
+ $parents[$index_id]->condition($field, $filter[1], $filter[2]);
+ }
+ }
+ else {
+ /** @var SearchApiQueryFilterInterface[] $nested */
+ $nested = array();
+ foreach ($parents as $index_id => $query) {
+ $nested[$index_id] = $queries[$index_id]->createFilter($filter->getConjunction());
+ }
+ $this->addFilters($filter->getFilters(), $nested, $queries);
+ foreach ($nested as $index_id => $nested_filter) {
+ if ($nested_filter->getFilters()) {
+ $parents[$index_id]->filter($nested_filter);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Ensure that all results have all fields needed for sorting.
+ *
+ * @param array $results
+ * The results array, as in the 'results' key of the return value of
+ * SearchApiMultiQueryInterface::execute().
+ */
+ protected function ensureSortFields(array &$results) {
+ $sort = array_keys($this->sort);
+ // Eliminate special fields which are always included.
+ foreach ($sort as $i => $key) {
+ if ($key == 'search_api_id' || $key == 'search_api_relevance') {
+ unset($sort[$i]);
+ }
+ }
+ if (!$sort) {
+ return;
+ }
+ // Determine what fields we need from items of each index.
+ $fields = array();
+ foreach ($sort as $key) {
+ list($index_id, $field) = explode(':', $key, 2);
+ if (!empty($this->indexes[$index_id])) {
+ $fields[$index_id][$field] = $this->indexes[$index_id]->options['fields'][$field];
+ }
+ }
+ if (!$fields) {
+ return;
+ }
+ // Determine for which items we need the entity.
+ $to_load = array();
+ foreach ($results as $i => $result) {
+ $results[$i] = $result += array('fields' => array(), 'entity' => NULL);
+ if (empty($fields[$result['index_id']]) || $result['entity']) {
+ continue;
+ }
+ foreach ($fields[$result['index_id']] as $field => $info) {
+ if (!array_key_exists($field, $result['fields'])) {
+ $to_load[$this->indexes[$result['index_id']]->item_type][$i] = $result['id'];
+ break;
+ }
+ }
+ }
+ // Load items, as necessary.
+ foreach ($to_load as $type => $ids) {
+ $type_items = search_api_get_datasource_controller($type)->loadItems($ids);
+ foreach ($ids as $i => $id) {
+ if (isset($type_items[$id])) {
+ $results[$i]['entity'] = $type_items[$id];
+ }
+ }
+ }
+ // Now extract the fields for each item.
+ foreach ($results as $i => $result) {
+ if (empty($fields[$result['index_id']])) {
+ continue;
+ }
+ $item_fields = $fields[$result['index_id']];
+ if (empty($result['entity'])) {
+ $results[$i]['fields'] += array_fill_keys(array_keys($item_fields), NULL);
+ continue;
+ }
+ $item_fields = array_diff_key($item_fields, $result['fields']);
+ if ($item_fields) {
+ $wrapper = $this->indexes[$result['index_id']]->entityWrapper($result['entity']);
+ $item_fields = search_api_extract_fields($wrapper, $item_fields);
+ foreach ($item_fields as $field => $info) {
+ $results[$i]['fields'][$field] = $info['value'];
+ }
+ }
+ }
+ }
+
+ /**
+ * Compare two result arrays.
+ *
+ * Callback for uasort() within searchMultiple().
+ *
+ * @param array $a
+ * One result.
+ * @param array $b
+ * The other result.
+ *
+ * @return int
+ * A negative number if $a should come before $b, 0 if both compare equal
+ * and a positive number otherwise.
+ */
+ protected function compareResults(array $a, array $b) {
+ foreach ($this->sort as $key => $order) {
+ // Get the sorting for this specific field.
+ if ($key == 'search_api_relevance') {
+ $comp = $a['score'] - $b['score'];
+ }
+ elseif ($key == 'search_api_id') {
+ if (is_numeric($a['id']) && is_numeric($b['id'])) {
+ $comp = $a['id'] - $b['id'];
+ }
+ else {
+ $comp = strnatcasecmp($a['id'], $b['id']);
+ }
+ }
+ else {
+ list($index_id, $field) = explode(':', $key, 2);
+ $a_applies = ($a['index_id'] == $index_id);
+ $b_applies = ($b['index_id'] == $index_id);
+ if ($a_applies == $b_applies) {
+ if (!$a_applies) {
+ continue;
+ }
+ $value_a = $a['fields'][$field];
+ $value_b = $b['fields'][$field];
+ if (is_numeric($value_a) && is_numeric($value_b)) {
+ $comp = $value_a - $value_b;
+ }
+ else {
+ $comp = strnatcasecmp($value_a, $value_b);
+ }
+ }
+ else {
+ $comp = $a_applies ? -1 : 1;
+ // When the sort only applies to one of the two results, we always
+ // want it in front of the other, regardless of $order.
+ $order = 'ASC';
+ }
+ }
+
+ // Now apply the specified order and either return or continue.
+ if (!$comp) {
+ continue;
+ }
+ return (int) ($order == 'ASC' ? $comp : -$comp);
+ }
+ return 0;
+ }
+
+ /**
+ * Pre-execute hook for modifying search behaviour.
+ */
+ public function preExecute() {
+ // Make sure to only execute this once per query.
+ if (!$this->pre_execute) {
+ $this->pre_execute = TRUE;
+
+ // Add filter for languages.
+ if (isset($this->options['languages'])) {
+ $this->addLanguages($this->options['languages']);
+ }
+
+ // Filter indexes to those used. If no index was explicitly used, include
+ // all of them.
+ if ($this->used_indexes) {
+ $this->indexes = array_intersect_key($this->indexes, $this->used_indexes);
+ }
+
+ // Add fulltext fields, unless set.
+ if ($this->fields === NULL) {
+ $this->fields = $this->index_fields = array();
+ foreach ($this->indexes as $index_id => $index) {
+ foreach ($index->getFulltextFields() as $f) {
+ $this->fields[] = "$index_id:$f";
+ $this->index_fields[$index_id][] = $f;
+ }
+ }
+ }
+ // If both keys and fields are given, indexes with no fields searched
+ // should not be included.
+ elseif ($this->keys) {
+ $this->indexes = array_intersect_key($this->indexes, $this->index_fields);
+ }
+
+ // Filter the $servers property according to the used indexes.
+ foreach ($this->servers as $server_id => $indexes) {
+ foreach ($indexes as $index_id => $index) {
+ if (!isset($this->indexes[$index_id])) {
+ unset($this->servers[$server_id][$index_id]);
+ }
+ }
+ }
+ $this->servers = array_filter($this->servers);
+ }
+ }
+
+ /**
+ * Post-execute hook for modifying search behaviour.
+ *
+ * @param array $results
+ * The results returned by the server, which may be altered.
+ */
+ public function postExecute(array &$results) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getIndexes() {
+ return $this->indexes;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getKeys() {
+ return $this->keys;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOriginalKeys() {
+ return $this->orig_keys;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getFields() {
+ return $this->fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFilter() {
+ return $this->filter;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getSort() {
+ return $this->sort;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOption($name, $default = NULL) {
+ return isset($this->options[$name]) ? $this->options[$name] : $default;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOption($name, $value) {
+ $old = $this->getOption($name);
+ $this->options[$name] = $value;
+ return $old;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getOptions() {
+ return $this->options;
+ }
+
+ /**
+ * Implements the magic __toString() method to simplify debugging.
+ */
+ public function __toString() {
+ $ret = '';
+ if ($this->indexes) {
+ $indexes = array();
+ foreach ($this->indexes as $index) {
+ $indexes[] = $index->machine_name;
+ }
+ $ret .= 'Indexes: ' . implode(', ', $indexes) . "\n";
+ }
+ $ret .= 'Keys: ' . str_replace("\n", "\n ", var_export($this->orig_keys, TRUE)) . "\n";
+ if (isset($this->keys)) {
+ $ret .= 'Parsed keys: ' . str_replace("\n", "\n ", var_export($this->keys, TRUE)) . "\n";
+ $ret .= 'Searched fields: ' . (isset($this->fields) ? implode(', ', $this->fields) : '[ALL]') . "\n";
+ }
+ if ($filter = (string) $this->filter) {
+ $filter = str_replace("\n", "\n ", $filter);
+ $ret .= "Filters:\n $filter\n";
+ }
+ if ($this->sort) {
+ $sort = array();
+ foreach ($this->sort as $field => $order) {
+ $sort[] = "$field $order";
+ }
+ $ret .= 'Sorting: ' . implode(', ', $sort) . "\n";
+ }
+ $options = $this->sanitizeOptions($this->options);
+ $options = str_replace("\n", "\n ", var_export($options, TRUE));
+ $ret .= 'Options: ' . $options . "\n";
+ return $ret;
+ }
+
+ /**
+ * Sanitizes an array of options in a way that plays nice with var_export().
+ *
+ * @param array $options
+ * An array of options.
+ *
+ * @return array
+ * The sanitized options.
+ */
+ protected function sanitizeOptions(array $options) {
+ foreach ($options as $key => $value) {
+ if (is_object($value)) {
+ $options[$key] = 'object (' . get_class($value) . ')';
+ }
+ elseif (is_array($value)) {
+ $options[$key] = $this->sanitizeOptions($value);
+ }
+ }
+ return $options;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.service.inc b/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.service.inc
new file mode 100644
index 000000000..6e1c133e2
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/includes/search_api_multi.service.inc
@@ -0,0 +1,26 @@
+getIndexes();
+ if (isset($indexes['default_node_index'])) {
+ $query->condition('default_node_index:author', 0, '!=');
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.info b/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.info
new file mode 100644
index 000000000..6f26c822b
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.info
@@ -0,0 +1,21 @@
+type = module
+name = Multi-index searches
+description = Small extension for the Search API that allows searches across several indexes on the same server.
+backdrop = 1.x
+package = Search
+
+dependencies[] = entity_plus
+dependencies[] = search_api
+
+files[] = search_api_multi.query.inc
+files[] = search_api_multi.service.inc
+files[] = views/handler_argument_fulltext.inc
+files[] = views/handler_filter_fulltext.inc
+files[] = views/handler_filter_options.inc
+files[] = views/query.inc
+files[] = views/row_entity_view.inc
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.install b/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.install
new file mode 100644
index 000000000..cd7b448a4
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/search_api_multi.install
@@ -0,0 +1,12 @@
+ '3.0',
+ 'path' => backdrop_get_path('module', 'search_api_multi') . '/views',
+ );
+ }
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function search_api_multi_autoload_info() {
+ return array(
+ 'SearchApiMultiQueryInterface' => 'includes/search_api_multi.query.inc',
+ 'SearchApiMultiQuery' => 'includes/search_api_multi.query.inc',
+ 'SearchApiMultiServiceInterface' => 'includes/search_api_multi.service.inc',
+ 'SearchApiMultiHandlerArgumentFulltext' => 'views/handler_argument_fulltext.inc',
+ 'SearchApiMultiHandlerFilterFulltext' => 'views/handler_filter_fulltext.inc',
+ 'SearchApiMultiViewsHandlerFilterOptions' => 'views/handler_filter_options.inc',
+ 'SearchApiMultiViewsQuery' => 'views/query.inc',
+ 'SearchApiMultiRowEntityView' => 'views/row_entity_view.inc',
+ );
+}
+
+/**
+ * Creates a multi-index search query.
+ *
+ * For backwards-compatibility reasons, the options can be passed as either the
+ * first or the second parameter, the other one is ignored.
+ *
+ * @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 SearchApiMultiQuery::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.
+ * - 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 static cache.
+ * All options are optional.
+ * @param array $options2
+ * Deprecated. Exactly the same as $options. Will be used if $options is no
+ * array.
+ *
+ * @return SearchApiMultiQueryInterface
+ * A query object for searching multiple indexes.
+ */
+function search_api_multi_query(?array $options = array(), array $options2 = array()) {
+ $options = is_array($options) ? $options : $options2;
+ return new SearchApiMultiQuery($options);
+}
+
+/**
+ * Static store for the multi-index searches executed on the current page.
+ *
+ * Can either be used to store an executed search, or to retrieve a previously
+ * stored search.
+ *
+ * @param ?string $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 ?SearchApiMultiQuery $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
+ * SearchApiMultiQueryInterface::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_multi_current_search(?string $search_id = NULL, ?SearchApiMultiQuery $query = NULL, array $results = array()) {
+ $searches = &backdrop_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($searches[$search_id])) {
+ return $searches[$search_id];
+ }
+ return $searches;
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_argument_fulltext.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_argument_fulltext.inc
new file mode 100644
index 000000000..5ce969532
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_argument_fulltext.inc
@@ -0,0 +1,29 @@
+ TRUE));
+ foreach ($indexes as $index) {
+ if ($index->getFields()) {
+ $prefix = $index->machine_name . ':';
+ $prefix_name = $index->name . ' » ';
+ $f = $index->getFields();
+ foreach ($index->getFulltextFields() as $name) {
+ $fields[$prefix . $name] = $prefix_name . $f[$name]['name'];
+ }
+ }
+ }
+ return $fields;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_fulltext.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_fulltext.inc
new file mode 100644
index 000000000..c6f4ebe2f
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_fulltext.inc
@@ -0,0 +1,103 @@
+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'];
+
+ // 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 && $fields && (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;
+ }
+
+ // If the operator was set to OR, set it as the conjunction. (AND is set by
+ // default.)
+ if ($this->operator === 'OR') {
+ $this->query->setOption('conjunction', $this->operator);
+ }
+
+ if ($fields) {
+ $this->query->fields($fields);
+ }
+ $old = $this->query->getOriginalKeys();
+ $this->query->keys($this->value);
+ if ($this->operator == 'NOT') {
+ $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();
+ $indexes = search_api_index_load_multiple(FALSE, array('enabled' => TRUE));
+ foreach ($indexes as $index) {
+ if ($index->getFields()) {
+ $prefix = $index->machine_name . ':';
+ $prefix_name = $index->name . ' » ';
+ $f = $index->getFields();
+ foreach ($index->getFulltextFields() as $name) {
+ $fields[$prefix . $name] = $prefix_name . $f[$name]['name'];
+ }
+ }
+ }
+ return $fields;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_options.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_options.inc
new file mode 100644
index 000000000..f53ea7021
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/handler_filter_options.inc
@@ -0,0 +1,56 @@
+real_field, 2);
+ $index = search_api_index_load($index_id);
+ if (!$index) {
+ return NULL;
+ }
+ $wrapper = $index->entityWrapper(NULL, TRUE);
+ $parts = explode(':', $property);
+ foreach ($parts as $i => $part) {
+ if (!isset($wrapper->$part)) {
+ return NULL;
+ }
+ $wrapper = $wrapper->$part;
+ $info = $wrapper->info();
+ if ($i < count($parts) - 1) {
+ // Unwrap lists.
+ $level = search_api_list_nesting_level($info['type']);
+ for ($j = 0; $j < $level; ++$j) {
+ $wrapper = $wrapper[0];
+ }
+ }
+ }
+
+ return $wrapper;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function get_value_options() {
+ if ($this->real_field == 'search_api_multi_index') {
+ $this->value_options = array();
+ foreach (search_api_index_load_multiple(FALSE, array('enabled' => 1)) as $index) {
+ $this->value_options[$index->machine_name] = check_plain($index->name);
+ }
+ }
+ else {
+ parent::get_value_options();
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/query.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/query.inc
new file mode 100644
index 000000000..59916e568
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/query.inc
@@ -0,0 +1,219 @@
+query = search_api_multi_query(array('parse mode' => $this->options['parse_mode']));
+ }
+ catch (Exception $e) {
+ $this->errors[] = $e->getMessage();
+ }
+ }
+
+ /**
+ * Adds settings for the UI.
+ *
+ * Adds an option for bypassing access checks.
+ */
+ public function 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'],
+ );
+
+ $form['entity_access'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Additional access checks on result entities'),
+ '#description' => t("Execute an access check for all result entities. This prevents users from seeing inappropriate content when the index contains stale data, or doesn't provide access checks. However, result counts, paging and other things won't work correctly if results are eliminated in this way, so only use this as a last ressort (and in addition to other checks, if possible)."),
+ '#default_value' => $this->options['entity_access'],
+ );
+
+ $form['parse_mode'] = array(
+ '#type' => 'select',
+ '#title' => t('Parse mode'),
+ '#description' => t('Choose how the search keys will be parsed.'),
+ '#options' => array(),
+ '#default_value' => $this->options['parse_mode'],
+ );
+ foreach ($this->query->parseModes() as $key => $mode) {
+ $form['parse_mode']['#options'][$key] = $mode['name'];
+ if (!empty($mode['description'])) {
+ $states['visible'][':input[name="query[options][parse_mode]"]']['value'] = $key;
+ $form["parse_mode_{$key}_description"] = array(
+ '#type' => 'item',
+ '#title' => $mode['name'],
+ '#description' => $mode['description'],
+ '#states' => $states,
+ );
+ }
+ }
+ }
+
+ /**
+ * Helper function for adding results to a view in the format expected by the
+ * view.
+ */
+ protected function addResults(array $results, $view) {
+ $indexes = $this->getIndexes();
+ foreach ($results as $result) {
+ $row = array();
+
+ if (!empty($this->options['entity_access']) && isset($indexes[$result['index_id']]) && $indexes[$result['index_id']]->getEntityType()) {
+ $entity = $indexes[$result['index_id']]->loadItems(array($result['id']));
+ if (!$entity || !entity_access('view', $indexes[$result['index_id']]->item_type, reset($entity))) {
+ continue;
+ }
+ }
+ // 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'] = $result['id'];
+ }
+
+ // Gather any fields from the search results.
+ if (!empty($result['fields'])) {
+ foreach (search_api_get_sanitized_field_values($result['fields']) as $field_id => $value) {
+ $field_id = $result['index_id'] . ':' . $field_id;
+ $row['_entity_properties'][$field_id] = $value;
+ }
+ }
+
+ $row['_entity_properties']['search_api_multi_id'] = $result['id'];
+ $row['_entity_properties']['search_api_multi_index'] = $result['index_id'];
+ $row['_entity_properties']['search_api_relevance'] = $result['score'];
+ $row['_entity_properties']['search_api_excerpt'] = empty($result['excerpt']) ? '' : $result['excerpt'];
+
+ // Add the row to the Views result set.
+ $view->result[] = (object) $row;
+ }
+ }
+
+ /**
+ * 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) {
+ $wrappers = array();
+ $load_items = array();
+ $entity_types = entity_get_info();
+ $indexes = $this->getIndexes();
+
+ // Entity property info for the results.
+ $info = array();
+ foreach ($indexes as $index_id => $index) {
+ $entity_type = $index->getEntityType();
+ $info['property info'][$index_id] = array(
+ 'label' => t('@index results', array('@index' => $index->name)),
+ 'type' => $entity_type ? $entity_type : $index->item_type,
+ 'description' => t('Results from the @index index.', array('@index' => $index->name)),
+ );
+ if (!$entity_type) {
+ $info['property info'][$index_id] += $index->entityWrapper()->info();
+ }
+ }
+
+ // Pick out all results that need to be loaded.
+ foreach ($results as $row_index => $row) {
+ $index_id = $row->_entity_properties['search_api_multi_index'];
+ if (isset($row->entity) && !empty($indexes[$index_id])) {
+ $index = $indexes[$index_id];
+ // If this item isn't loaded, register it for pre-loading.
+ if (is_scalar($row->entity)) {
+ $load_items[$index->item_type][$row->entity] = $row->entity;
+ }
+ }
+ }
+
+ // Load the results in bulk, by item type, and create the wrappers.
+ if (!empty($load_items)) {
+ foreach ($load_items as $type => $ids) {
+ try {
+ $load_items[$type] = search_api_get_datasource_controller($type)->loadItems($ids);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api_multi', $e);
+ }
+ }
+ }
+
+ // Create wrappers for all results.
+ foreach ($results as $row_index => $row) {
+ $index_id = $row->_entity_properties['search_api_multi_index'];
+ if ($indexes[$index_id]) {
+ if (is_scalar($row->entity)) {
+ $index = $indexes[$index_id];
+ if (empty($load_items[$index->item_type][$row->entity])) {
+ continue;
+ }
+ $row->entity = $load_items[$index->item_type][$row->entity];
+ }
+ $item = $row->entity;
+ $data = new stdClass();
+ $data->{$index_id} = $item;
+ $wrappers[$row_index] = entity_metadata_wrapper('search_api_multi', $data, $info);
+ }
+ }
+
+ // Apply the relationship, if necessary.
+ $type = 'search_api_multi';
+ $selector_suffix = '';
+ if ($field && ($pos = strrpos($field, ':'))) {
+ $selector_suffix = substr($field, 0, $pos);
+ }
+ if ($selector_suffix || ($relationship && !empty($this->view->relationship[$relationship]))) {
+ // Use EntityPlusFieldHandlerHelper to compute the correct data selector
+ // for the relationship.
+ $handler = (object) array(
+ 'view' => $this->view,
+ 'relationship' => $relationship,
+ 'real_field' => '',
+ );
+ $selector = EntityPlusFieldHandlerHelper::construct_property_selector($handler);
+ $selector .= ($selector ? ':' : '') . $selector_suffix;
+ list($type, $wrappers) = EntityPlusFieldHandlerHelper::extract_property_multiple($wrappers, $selector);
+ }
+
+ return array($type, $wrappers);
+ }
+
+ //
+ // Query interface methods (proxy to $this->query)
+ //
+
+ /**
+ * Retrieves the searched indexes.
+ *
+ * @return SearchApiIndex[]
+ * An array of SearchApiIndex objects representing all indexes that will be
+ * used for this search, keyed by machine names.
+ */
+ public function getIndexes() {
+ if (!$this->errors) {
+ return $this->query->getIndexes();
+ }
+ return array();
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/row_entity_view.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/row_entity_view.inc
new file mode 100644
index 000000000..c0143ca46
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/row_entity_view.inc
@@ -0,0 +1,177 @@
+view->query)) {
+ $this->view->init_query();
+ }
+
+ $entity_types = $incompatibles = array();
+ foreach ($this->view->query->getIndexes() as $id => $index) {
+ $type = $index->getEntityType();
+ $label = $index->label();
+
+ if ($type) {
+ $entity_types[$type][] = $label;
+ }
+ else {
+ $incompatibles[$id] = $label;
+ }
+ }
+
+ $this->indexes = array(
+ 'entity_types' => $entity_types,
+ 'incompatibles' => $incompatibles,
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function option_definition() {
+ $options = parent::option_definition();
+ $options['view_mode'] = array('default' => array());
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+
+ $entity_info = entity_get_info();
+
+ $options = array();
+ foreach ($this->indexes['entity_types'] as $type => $indexes) {
+ foreach ($entity_info[$type]['view modes'] as $view_mode => $view_mode_info) {
+ $options[$type][$view_mode] = $view_mode_info['label'];
+ }
+ }
+
+ $form['view_mode'] = array(
+ '#tree' => TRUE,
+ );
+
+ foreach ($options as $entity_type => $modes) {
+ if (count($modes) > 1) {
+ $form['view_mode'][$entity_type] = array(
+ '#type' => 'select',
+ '#options' => $modes,
+ '#title' => t('View mode for @entity_name entities', array(
+ '@entity_name' => $entity_info[$entity_type]['label'],
+ )),
+ '#description' => format_plural(
+ count($this->indexes['entity_types'][$entity_type]),
+ 'This view mode will be used to render entities from the following index: %indexes',
+ 'This view mode will be used to render entities from the following indexes: %indexes',
+ array(
+ '%indexes' => implode(', ', $this->indexes['entity_types'][$entity_type]),
+ )
+ ),
+ );
+ if (!empty($this->options['view_mode'][$entity_type])) {
+ $form['view_mode'][$entity_type]['#default_value'] = $this->options['view_mode'][$entity_type];
+ }
+ }
+ else {
+ // For entity types that only have one view mode, there's no meaningful
+ // choice to make, so just expose it to let the user know about it.
+ $form['view_mode']["{$entity_type}__description"] = array(
+ '#type' => 'item',
+ '#title' => t('View mode for @entity_name entities', array(
+ '@entity_name' => $entity_info[$entity_type]['label'],
+ )),
+ '#description' => reset($modes),
+ );
+ $form['view_mode'][$entity_type] = array(
+ '#type' => 'value',
+ '#value' => key($modes),
+ );
+ }
+ }
+
+ if (count($this->indexes['incompatibles'])) {
+ $form['incompatibles'] = array(
+ '#type' => 'item',
+ '#title' => 'Incompatible indexes',
+ '#description' => t("The following indexes are not based on entities, and can't be used with this row style: %indexes. Items from those indexes will be skipped during rendering.", array(
+ '%indexes' => implode(', ', $this->indexes['incompatibles']),
+ )),
+ );
+ }
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function pre_render($values) {
+ if (!empty($values)) {
+ list(, $this->wrappers) = $this->view->query->get_result_wrappers($values);
+ }
+ }
+
+ /**
+ * Returns a metadata wrapper for a returned row.
+ *
+ * @param object $values
+ * The values of the returned row.
+ *
+ * @return EntityMetadataWrapper|null
+ * A wrapper for that row, or NULL if the row doesn't represent an entity.
+ */
+ public function get_wrapper($values) {
+ $index = $values->_entity_properties['search_api_multi_index'];
+ if (isset($this->indexes['incompatibles'][$index])) {
+ return NULL;
+ }
+ if (empty($this->wrappers[$this->view->row_index]->$index)) {
+ return NULL;
+ }
+ return $this->wrappers[$this->view->row_index]->$index;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render($values) {
+ if ($wrapper = $this->get_wrapper($values)) {
+ $render = $wrapper->view($this->options['view_mode'][$wrapper->type()]);
+ return backdrop_render($render);
+ }
+ }
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_multi/views/search_api_multi.views.inc b/www/modules/contrib/search_api/contrib/search_api_multi/views/search_api_multi.views.inc
new file mode 100644
index 000000000..eddaa0162
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_multi/views/search_api_multi.views.inc
@@ -0,0 +1,171 @@
+ 'search_api_multi_index',
+ 'title' => t('Multi-index search'),
+ 'help' => t('Search several search indexes at once.'),
+ 'query class' => 'search_api_multi_query',
+ );
+ $index_options = array();
+ foreach (search_api_index_load_multiple(FALSE, array('enabled' => TRUE)) as $index_id => $index) {
+ try {
+ $index_options[$index_id] = check_plain($index->name);
+ $wrapper = $index->entityWrapper(NULL, TRUE);
+
+ // Add a relationship to the index's entity type.
+ $info = array(
+ 'label' => t('@index results', array('@index' => $index->name)),
+ 'type' => $index->item_type,
+ 'description' => t('Results from the @index index.', array('@index' => $index->name)),
+ );
+ if (empty($entity_types[$index->item_type])) {
+ $info += $index->entityWrapper()->info();
+ }
+ entity_plus_views_field_definition($index_id, $info, $table);
+
+ // Add handlers for all indexed fields.
+ foreach ($index->getFields() as $key => $field) {
+ $tmp = $wrapper;
+ $group = '';
+ $name = $index->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 = $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];
+ }
+ }
+ }
+ $key = "$index_id:$key";
+ $id = _entity_plus_views_field_identifier($key, $table);
+ if ($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'];
+ $table[$id]['real field'] = $key;
+ _search_api_views_add_handlers($key, $field, $tmp, $table);
+ // The newest (Search API 1.10+) SearchApiViewsHandlerFilterOptions
+ // class is incompatible with multi-index searches. Substitute our own
+ // handler.
+ if (isset($table[$id]['filter']['handler']) && $table[$id]['filter']['handler'] == 'SearchApiViewsHandlerFilterOptions' && method_exists('SearchApiViewsHandlerFilterOptions', 'get_wrapper')) {
+ $table[$id]['filter']['handler'] = 'SearchApiMultiViewsHandlerFilterOptions';
+ }
+ }
+ }
+ catch (Exception $e) {
+ watchdog_exception('search_api_multi', $e);
+ }
+ }
+
+ // Special handlers.
+ $table['search_api_multi_id']['title'] = t('Entity ID');
+ $table['search_api_multi_id']['help'] = t("The entity's ID.");
+ $table['search_api_multi_id']['field']['type'] = 'text';
+ $table['search_api_multi_id']['field']['handler'] = 'entity_plus_views_handler_field_text';
+ $table['search_api_multi_id']['sort']['handler'] = 'SearchApiViewsHandlerSort';
+ $table['search_api_multi_id']['sort']['real field'] = 'search_api_id';
+
+ $table['search_api_multi_index']['title'] = t('Index');
+ $table['search_api_multi_index']['help'] = t('The search indexes that will be searched.');
+ $table['search_api_multi_index']['type'] = 'options';
+ $table['search_api_multi_index']['field']['handler'] = 'entity_views_handler_field_entity';
+ $table['search_api_multi_index']['field']['type'] = 'search_api_index';
+ $table['search_api_multi_index']['relationship']['handler'] = 'entity_views_handler_relationship';
+ $table['search_api_multi_index']['relationship']['base'] = 'entity_search_api_index';
+ $table['search_api_multi_index']['relationship']['base field'] = 'machine_name';
+ $table['search_api_multi_index']['relationship']['relationship field'] = 'search_api_multi_index';
+ $table['search_api_multi_index']['relationship']['label'] = t('Search index');
+ $table['search_api_multi_index']['relationship']['multiple'] = FALSE;
+ $table['search_api_multi_index']['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+ // See above.
+ if (method_exists('SearchApiViewsHandlerFilterOptions', 'get_wrapper')) {
+ $table['search_api_multi_index']['filter']['handler'] = 'SearchApiMultiViewsHandlerFilterOptions';
+ }
+ else {
+ $table['search_api_multi_index']['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
+ $table['search_api_multi_index']['filter']['options'] = $index_options;
+ }
+
+ $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']['title'] = t('Excerpt');
+ $table['search_api_excerpt']['help'] = t('Excerpts from the search results highlighting occurrences of the keywords.');
+ $table['search_api_excerpt']['field']['type'] = 'text';
+ $table['search_api_excerpt']['field']['handler'] = 'entity_plus_views_handler_field_text';
+
+ $table['search_api_multi_fulltext']['group'] = t('Search');
+ $table['search_api_multi_fulltext']['title'] = t('Fulltext search');
+ $table['search_api_multi_fulltext']['help'] = t('Search several or all fulltext fields at once.');
+ $table['search_api_multi_fulltext']['type'] = 'text';
+ $table['search_api_multi_fulltext']['filter']['handler'] = 'SearchApiMultiHandlerFilterFulltext';
+ $table['search_api_multi_fulltext']['argument']['handler'] = 'SearchApiMultiHandlerArgumentFulltext';
+ }
+ catch (Exception $e) {
+ watchdog_exception('search_api_multi', $e);
+ }
+ return $data;
+}
+
+/**
+ * Implements hook_views_plugins().
+ */
+function search_api_multi_views_plugins() {
+ return array(
+ 'module' => 'search_api_multi',
+ 'query' => array(
+ 'search_api_multi_query' => array(
+ 'title' => t('Search API Query'),
+ 'help' => t('Query will be generated and run using the Search API.'),
+ 'handler' => 'SearchApiMultiViewsQuery',
+ ),
+ ),
+ 'row' => array(
+ 'search_api_multi' => array(
+ 'title' => t('Rendered entity'),
+ 'handler' => 'SearchApiMultiRowEntityView',
+ 'path' => backdrop_get_path('module', 'search_api_multi') . '/views',
+ 'uses options' => TRUE,
+ 'help' => t('Renders a single entity in a specific view mode (e.g. teaser).'),
+ 'base' => array('search_api_multi'),
+ 'uses fields' => FALSE,
+ 'type' => 'normal',
+ ),
+ ),
+ );
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/README.md b/www/modules/contrib/search_api/contrib/search_api_views/README.md
new file mode 100644
index 000000000..61c9cc1ce
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/README.md
@@ -0,0 +1,151 @@
+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.
+
+"Random sort" feature
+---------------------
+This module defines the "Random sort" feature (feature key:
+"search_api_random_sort") that allows to randomly sort the results returned by a
+search. With a server supporting this, you can use the "Global: Random" sort to
+sort the view's results randomly. Every time the query is run a different
+sorting will be provided.
+
+For developers:
+A service class that wants to support this feature has to check for a
+"search_api_random" field in the search query's sorts and insert a random sort
+in that position. If the query is sorted in this way, then the
+"search_api_random_sort" query option can contain additional options for the
+random sort, as an associative array with any of the following keys:
+- seed: A numeric seed value to use for the random sort.
+
+"BETWEEN operator" feature
+--------------------------
+This module defines the "BETWEEN operator" feature (feature key:
+"search_api_between") that adds the "BETWEEN" and "NOT BETWEEN" filter
+operators to search queries. If your search server supports this feature, you
+can use the "Is between" and "Is not between" operators when adding Views
+filters for numeric, string or date types.
+
+For developers:
+A service class that wants to support this feature has to accept "BETWEEN" and
+"NOT BETWEEN" as additional $operator values in query conditions. The value in
+both cases is an array with the keys 0 and 1, with the value under key 0 being
+the lower and the value under key 1 being the upper bound for the range in which
+the field's value should ("BETWEEN") or should not ("NOT BETWEEN") be.
+
+"Facets block" display
+----------------------
+Most features should be clear to users of Views. However, the module also
+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.
+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.
+
+Access features
+---------------
+Search views created with this module contain two query settings (located in
+the "Advanced" fieldset) which let you control the access checks executed for
+search results displayed in the view.
+
+- Bypass access checks
+This option allows you to deactivate access filters that would otherwise be
+added to the search, if the index supports this. This is, for instance, the case
+for indexes on the "Node" item type, when the "Node access" data alteration is
+activated.
+Use this either to slightly speed up searches where additional checks are
+unnecessary (e.g., because you already filter on "Node: Published") and there is
+no other node access mechanism on your site) or to show certain data that users
+normally wouldn't have access to (e.g., a list of all matching node titles,
+published or not).
+
+- Additional access checks on result entities
+When this option is activated, all result entities will be passed to an
+additional access check, even if search-time access checks are available for
+this index. The advantage is that access rules are guaranteed to be enforced –
+stale data in the index, which might make other access checks incorrect, won't
+influence this access check. You can also use it for item types for which no
+other access mechanisms are available.
+However, note that results filtered out this way will mess up paging, result
+counts and possibly other things too (like facet counts), as the result row is
+only hidden from display after the search has been executed. Where possible,
+you should therefore only use this in combination with appropriate filter
+settings ensuring that only when the index isn't up-to-date items will be
+filtered out this way.
+This option is only available for indexes on entity types.
+
+Other features
+--------------
+- Change parse mode
+You can determine how search keys entered by the user will be parsed by going to
+"Advanced" > "Query settings" within your View's settings. "Direct" can be
+useful, e.g., when you want to give users the full power of Solr. In other
+cases, "Multiple terms" is usually what you want / what users expect.
+Caution: For letting users use fulltext searches, always use the "Search:
+Fulltext search" filter or contextual filter – using a normal filter on a
+fulltext field won't parse the search keys, which means multiple words will only
+be found when they appear as that exact phrase.
+
+- Preserve facets while using filters
+This is another option under "Advanced" > "Query settings", only available when
+the Search Facets module is installed. When enabled, facet filters are persisted
+when submitting an exposed filters form. When disabled (the default), exposed
+filters will override and reset the selected facet filters.
+
+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/www/modules/contrib/search_api/contrib/search_api_views/includes/display_facet_block.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/display_facet_block.inc
new file mode 100644
index 000000000..7323d502f
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/display_facet_block.inc
@@ -0,0 +1,334 @@
+ '');
+ $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 query() {
+ parent::query();
+
+ $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'];
+ }
+
+ $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_api_base_path'] = $base_path;
+ $this->view->query->range(0, 0);
+ }
+
+ /**
+ *
+ */
+ public function render() {
+ if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
+ form_set_error('', t('The "Facets block" display can only be used with base tables based on Search API indexes.'));
+ return NULL;
+ }
+ $facet_field = $this->get_option('facet_field');
+ if (!$facet_field) {
+ return NULL;
+ }
+
+ $this->view->execute();
+
+ 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' => $this->view->query->getOption('search_api_base_path'),
+ 'count' => $term['count'],
+ 'options' => array(
+ 'attributes' => array('class' => 'facetapi-inactive'),
+ 'html' => FALSE,
+ 'query' => $query,
+ ),
+ );
+
+ // Override the $variables['#path'] if facetapi_pretty_paths is enabled.
+ if (module_exists('facetapi_pretty_paths')) {
+ // Get the appropriate facet adapter.
+ $adapter = facetapi_adapter_load('search_api@' . $index->machine_name);
+
+ // Get the URL processor and check if it uses pretty paths.
+ $urlProcessor = $adapter->getUrlProcessor();
+ if ($urlProcessor instanceof FacetapiUrlProcessorPrettyPaths) {
+ // Retrieve the pretty path alias from the URL processor.
+ $facet = facetapi_facet_load($facet_field, 'search_api@' . $index->machine_name);
+ $values = array(trim($term['filter'], '"'));
+
+ // Get the pretty path for the facet and remove the current search's
+ // base path from it.
+ $base_path_current = $urlProcessor->getBasePath();
+ $pretty_path = $urlProcessor->getFacetPath($facet, $values, FALSE);
+ $pretty_path = str_replace($base_path_current, '', $pretty_path);
+
+ // Set the new, pretty path for the facet and remove the "f" query
+ // parameter.
+ $variables['path'] = $variables['path'] . $pretty_path;
+ unset($variables['options']['query']['f']);
+ }
+ }
+
+ // Themes the link, adds row to facets.
+ $facets[] = array(
+ 'class' => array('leaf'),
+ 'data' => theme('facetapi_link_inactive', $variables),
+ );
+ }
+
+ if (!$facets) {
+ return NULL;
+ }
+
+ return array(
+ 'facets' => array(
+ '#theme' => 'item_list',
+ '#items' => $facets,
+ ),
+ );
+ }
+
+ /**
+ *
+ */
+ public function execute() {
+ $info['content'] = $this->render();
+ $info['content']['more'] = $this->render_more_link();
+ $info['subject'] = filter_xss_admin($this->view->get_title());
+ return $info;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument.inc
new file mode 100644
index 000000000..38daaa1d8
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument.inc
@@ -0,0 +1,155 @@
+ 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,
+ 'bool' => TRUE,
+ );
+ $options['not'] = array(
+ 'default' => FALSE,
+ 'bool' => TRUE,
+ );
+
+ 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',
+ );
+
+ $form['not'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Exclude'),
+ '#description' => t('If selected, the numbers entered for the filter will be excluded rather than limiting the view.'),
+ '#default_value' => !empty($this->options['not']),
+ '#fieldset' => 'more',
+ );
+ }
+
+ /**
+ * Set up the query for this argument.
+ *
+ * The argument sent may be found at $this->argument.
+ */
+ public function query($group_by = FALSE) {
+ if (empty($this->value)) {
+ if (!empty($this->options['break_phrase'])) {
+ views_break_phrase($this->argument, $this);
+ }
+ else {
+ $this->value = array($this->argument);
+ }
+ }
+
+ $operator = empty($this->options['not']) ? '=' : '<>';
+
+ if (count($this->value) > 1) {
+ $filter = $this->query->createFilter(backdrop_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, $operator);
+ }
+ $this->query->filter($filter);
+ }
+ }
+ else {
+ $this->query->condition($this->real_field, reset($this->value), $operator);
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_date.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_date.inc
new file mode 100644
index 000000000..a92c896e6
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_date.inc
@@ -0,0 +1,161 @@
+value)) {
+ $this->fillValue();
+ if ($this->value === FALSE) {
+ $this->abort();
+ return;
+ }
+ }
+
+ $outer_conjunction = strtoupper($this->operator);
+
+ if (empty($this->options['not'])) {
+ $operator = '=';
+ $inner_conjunction = 'OR';
+ }
+ else {
+ $operator = '<>';
+ $inner_conjunction = 'AND';
+ }
+
+ if (!empty($this->value)) {
+ if (!empty($this->value)) {
+ $outer_filter = $this->query->createFilter($outer_conjunction);
+ foreach ($this->value as $value) {
+ $value_filter = $this->query->createFilter($inner_conjunction);
+ $values = explode(';', $value);
+ $values = array_map(array($this, 'getTimestamp'), $values);
+ if (in_array(FALSE, $values, TRUE)) {
+ $this->abort();
+ return;
+ }
+ $is_range = (count($values) > 1);
+
+ $inner_filter = ($is_range ? $this->query->createFilter('AND') : $value_filter);
+ $range_op = (empty($this->options['not']) ? '>=' : '<');
+ $inner_filter->condition($this->real_field, $values[0], $is_range ? $range_op : $operator);
+ if ($is_range) {
+ $range_op = (empty($this->options['not']) ? '<=' : '>');
+ $inner_filter->condition($this->real_field, $values[1], $range_op);
+ $value_filter->filter($inner_filter);
+ }
+ $outer_filter->filter($value_filter);
+ }
+
+ $this->query->filter($outer_filter);
+ }
+ }
+ }
+
+ /**
+ * Converts a value to a timestamp, if it isn't one already.
+ *
+ * @param string|int $value
+ * The value to convert. Either a timestamp, or a date/time string as
+ * recognized by strtotime().
+ *
+ * @return int|false
+ * The parsed timestamp, or FALSE if an illegal string was passed.
+ */
+ public function getTimestamp($value) {
+ if (is_numeric($value)) {
+ return $value;
+ }
+
+ return strtotime($value);
+ }
+
+ /**
+ * Fills $this->value with data from the argument.
+ */
+ protected function fillValue() {
+ if (!empty($this->options['break_phrase'])) {
+ // Set up defaults:
+ if (!isset($this->value)) {
+ $this->value = array();
+ }
+
+ if (!isset($this->operator)) {
+ $this->operator = 'OR';
+ }
+
+ if (empty($this->argument)) {
+ return;
+ }
+
+ if (preg_match('/^([-\d;:\s]+\+)*[-\d;:\s]+$/', $this->argument)) {
+ // The '+' character in a query string may be parsed as ' '.
+ $this->value = explode('+', $this->argument);
+ }
+ elseif (preg_match('/^([-\d;:\s]+,)*[-\d;:\s]+$/', $this->argument)) {
+ $this->operator = 'AND';
+ $this->value = explode(',', $this->argument);
+ }
+
+ // Keep an 'error' value if invalid strings were given.
+ if (!empty($this->argument) && (empty($this->value) || !is_array($this->value))) {
+ $this->value = FALSE;
+ }
+ }
+ else {
+ $this->value = array($this->argument);
+ }
+ }
+
+ /**
+ * Aborts the associated query due to an illegal argument.
+ */
+ protected function abort() {
+ $variables['!field'] = $this->definition['group'] . ': ' . $this->definition['title'];
+ $this->query->abort(t('Illegal argument passed to !field contextual filter.', $variables));
+ }
+
+ /**
+ * Computes the title this argument will assign the view, given the argument.
+ *
+ * @return string
+ * A title fitting for the passed argument.
+ */
+ public function title() {
+ if (!empty($this->argument)) {
+ if (empty($this->value)) {
+ $this->fillValue();
+ }
+ $dates = array();
+ foreach ($this->value as $date) {
+ $date_parts = explode(';', $date);
+
+ $ts = $this->getTimestamp($date_parts[0]);
+ $datestr = format_date($ts, 'short');
+ if (count($date_parts) > 1) {
+ $ts = $this->getTimestamp($date_parts[1]);
+ $datestr .= ' - ' . format_date($ts, 'short');
+ }
+
+ if ($datestr) {
+ $dates[] = $datestr;
+ }
+ }
+
+ return $dates ? implode(', ', $dates) : check_plain($this->argument);
+ }
+
+ return check_plain($this->argument);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_fulltext.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_fulltext.inc
new file mode 100644
index 000000000..a4db8e253
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_fulltext.inc
@@ -0,0 +1,112 @@
+ array());
+ $options['conjunction'] = array('default' => 'AND');
+ return $options;
+ }
+
+ /**
+ * Extend the options form a bit.
+ */
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+
+ $form['help']['#markup'] = t('Note: You can change how search keys are parsed under "Advanced" > "Query settings".');
+
+ $fields = $this->getFulltextFields();
+ if (!empty($fields)) {
+ $form['fields'] = array(
+ '#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'],
+ );
+ $form['conjunction'] = array(
+ '#title' => t('Operator'),
+ '#description' => t('Determines how multiple keywords entered for the search will be combined.'),
+ '#type' => 'radios',
+ '#options' => array(
+ 'AND' => t('Contains all of these words'),
+ 'OR' => t('Contains any of these words'),
+ ),
+ '#default_value' => $this->options['conjunction'],
+ );
+
+ }
+ else {
+ $form['fields'] = array(
+ '#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']) {
+ try {
+ $this->query->fields($this->options['fields']);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ return;
+ }
+ }
+ if ($this->options['conjunction'] != 'AND') {
+ $this->query->setOption('conjunction', $this->options['conjunction']);
+ }
+
+ $old = $this->query->getOriginalKeys();
+ $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/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_more_like_this.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_more_like_this.inc
new file mode 100644
index 000000000..044f86495
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_more_like_this.inc
@@ -0,0 +1,111 @@
+ FALSE);
+ $options['fields'] = array('default' => array());
+ return $options;
+ }
+
+ /**
+ * Extend the options form a bit.
+ */
+ public function options_form(&$form, &$form_state) {
+ parent::options_form($form, $form_state);
+ unset($form['break_phrase']);
+ unset($form['not']);
+
+ $index = search_api_index_load(substr($this->table, 17));
+
+ if ($index->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+ $types = array_intersect_key(search_api_entity_type_options_list(), array_flip($index->options['datasource']['types']));
+ $form['entity_type'] = array(
+ '#type' => 'select',
+ '#title' => t('Entity type'),
+ '#description' => t('Select the entity type of the argument.'),
+ '#options' => $types,
+ '#default_value' => $this->options['entity_type'],
+ '#required' => TRUE,
+ );
+ }
+
+ if (!empty($index->options['fields'])) {
+ $fields = array();
+ foreach ($index->getFields() as $key => $field) {
+ $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) {
+ try {
+ $server = $this->query->getIndex()->server();
+ if (!$server->supportsFeature('search_api_mlt')) {
+ $class = search_api_get_service_info($server->class);
+ watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.',
+ array('@class' => $class['name']), WATCHDOG_ERROR);
+ $this->query->abort();
+ return;
+ }
+ $index_fields = array_keys($this->query->getIndex()->options['fields']);
+ if (empty($this->options['fields'])) {
+ $fields = $index_fields;
+ }
+ else {
+ $fields = array_intersect($this->options['fields'], $index_fields);
+ }
+ if ($this->query->getIndex()->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+ $id = $this->options['entity_type'] . '/' . $this->argument;
+ }
+ else {
+ $id = $this->argument;
+ }
+
+ $mlt = array(
+ 'id' => $id,
+ 'fields' => $fields,
+ );
+ $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_string.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_string.inc
new file mode 100644
index 000000000..f932ff0e1
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_string.inc
@@ -0,0 +1,31 @@
+argument.
+ */
+ public function query($group_by = FALSE) {
+ if (empty($this->value)) {
+ if (!empty($this->options['break_phrase'])) {
+ views_break_phrase_string($this->argument, $this);
+ }
+ else {
+ $this->value = array($this->argument);
+ }
+ }
+
+ parent::query($group_by);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_taxonomy_term.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_taxonomy_term.inc
new file mode 100644
index 000000000..5ab97e15e
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_argument_taxonomy_term.inc
@@ -0,0 +1,104 @@
+argument.
+ */
+ public function query($group_by = FALSE) {
+ if (empty($this->value)) {
+ $this->fillValue();
+ }
+
+ $outer_conjunction = strtoupper($this->operator);
+
+ if (empty($this->options['not'])) {
+ $operator = '=';
+ $inner_conjunction = 'OR';
+ }
+ else {
+ $operator = '<>';
+ $inner_conjunction = 'AND';
+ }
+
+ if (!empty($this->value)) {
+ $terms = entity_load_multiple('taxonomy_term', $this->value);
+
+ if (!empty($terms)) {
+ $filter = $this->query->createFilter($outer_conjunction);
+ $vocabulary_fields = $this->definition['vocabulary_fields'];
+ $vocabulary_fields += array('' => array());
+ foreach ($terms as $term) {
+ $inner_filter = $filter;
+ if ($outer_conjunction != $inner_conjunction) {
+ $inner_filter = $this->query->createFilter($inner_conjunction);
+ }
+ // Set filters for all term reference fields which don't specify a
+ // vocabulary, as well as for all fields specifying the term's
+ // vocabulary.
+ if (!empty($this->definition['vocabulary_fields'][$term->vocabulary_machine_name])) {
+ foreach ($this->definition['vocabulary_fields'][$term->vocabulary_machine_name] as $field) {
+ $inner_filter->condition($field, $term->tid, $operator);
+ }
+ }
+ foreach ($vocabulary_fields[''] as $field) {
+ $inner_filter->condition($field, $term->tid, $operator);
+ }
+ if ($outer_conjunction != $inner_conjunction) {
+ $filter->filter($inner_filter);
+ }
+ }
+
+ $this->query->filter($filter);
+ }
+ }
+ }
+
+ /**
+ * Get the title this argument will assign the view, given the argument.
+ */
+ public function title() {
+ if (!empty($this->argument)) {
+ if (empty($this->value)) {
+ $this->fillValue();
+ }
+ $terms = array();
+ foreach ($this->value as $tid) {
+ $taxonomy_term = taxonomy_term_load($tid);
+ if ($taxonomy_term) {
+ $terms[] = check_plain($taxonomy_term->name);
+ }
+ }
+
+ return $terms ? implode(', ', $terms) : check_plain($this->argument);
+ }
+ else {
+ return check_plain($this->argument);
+ }
+ }
+
+ /**
+ * Fill $this->value with data from the argument.
+ *
+ * Uses views_break_phrase(), if appropriate.
+ */
+ protected function fillValue() {
+ if (!empty($this->options['break_phrase'])) {
+ views_break_phrase($this->argument, $this);
+ }
+ else {
+ $this->value = array($this->argument);
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter.inc
new file mode 100644
index 000000000..770526a82
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter.inc
@@ -0,0 +1,119 @@
+ t('Is less than'),
+ '<=' => t('Is less than or equal to'),
+ '=' => t('Is equal to'),
+ '<>' => t('Is not equal to'),
+ '>=' => t('Is greater than or equal to'),
+ '>' => 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) && count($this->value) < 2) {
+ $this->value = $this->value ? reset($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.
+ if (empty($form_state['exposed'])) {
+ $form['value']['#states']['visible'] = array(
+ ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ elseif (!empty($this->options['expose']['use_operator'])) {
+ $name = $this->options['expose']['operator_id'];
+ $form['value']['#states']['visible'] = array(
+ ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ }
+
+ /**
+ * 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/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_boolean.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_boolean.inc
new file mode 100644
index 000000000..c39416cf3
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_boolean.inc
@@ -0,0 +1,38 @@
+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/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_date.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_date.inc
new file mode 100644
index 000000000..164833c06
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_date.inc
@@ -0,0 +1,167 @@
+ array('default' => 'default'),
+ 'date_popup_format' => array('default' => 'm/d/Y'),
+ 'year_range' => array('default' => '-3:+3'),
+ );
+ }
+
+ /**
+ * 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,
+ );
+ $form['date_popup_format'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Date format'),
+ '#default_value' => $this->options['date_popup_format'],
+ '#description' => t('A date in any format understood by PHP. For example, "Y-m-d" or "m/d/Y".', array(
+ '@doc-link' => 'http://php.net/manual/en/function.date.php',
+ )),
+ '#states' => array(
+ 'visible' => array(
+ ':input[name="options[widget_type]"]' => array('value' => 'date_popup'),
+ ),
+ ),
+ );
+ }
+
+ if (module_exists('date_api')) {
+ $form['year_range'] = array(
+ '#type' => 'date_year_range',
+ '#default_value' => $this->options['year_range'],
+ );
+ }
+ }
+
+ /**
+ * Validate extra options.
+ */
+ public function extra_options_validate($form, &$form_state) {
+ if (isset($form_state['values']['options']['year_range'])) {
+ if (!preg_match('/^(?:\-[0-9]{1,4}|[0-9]{4}):(?:[\+|\-][0-9]{1,4}|[0-9]{4})$/', $form_state['values']['options']['year_range'])) {
+ form_error($form['year_range'], t('Date year range must be in the format -9:+9, 2005:2010, -9:2010, or 2005:+9'));
+ }
+ }
+ }
+
+ /**
+ * Provide a form for setting the filter value.
+ */
+ public function value_form(&$form, &$form_state) {
+ parent::value_form($form, $form_state);
+
+ $is_date_popup = ($this->options['widget_type'] == 'date_popup' && module_exists('date_popup'));
+
+ // If the operator is between.
+ if ($this->operator == 'between') {
+ if ($is_date_popup) {
+ $form['value']['min']['#type'] = 'date_popup';
+ $form['value']['min']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['min']['#date_year_range'] = $this->options['year_range'];
+ $form['value']['max']['#type'] = 'date_popup';
+ $form['value']['max']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['max']['#date_year_range'] = $this->options['year_range'];
+ }
+ }
+ // If we are using the date popup widget, overwrite the settings of the form
+ // according to what date_popup expects.
+ elseif ($is_date_popup) {
+ // Add an "id" for the "value" field, since it is expected in
+ // date_views_form_views_exposed_form_alter().
+ // @see date_views_filter_handler_simple::value_form()
+ $form['value']['#id'] = 'date_views_exposed_filter-' . bin2hex(backdrop_random_bytes(16));
+ $form['value']['#type'] = 'date_popup';
+ $form['value']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['#date_year_range'] = $this->options['year_range'];
+ unset($form['value']['#description']);
+ }
+ elseif (empty($form_state['exposed'])) {
+ $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() {
+ $this->normalizeValue();
+
+ if ($this->operator === 'empty') {
+ $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+ }
+ elseif ($this->operator === 'not empty') {
+ $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+ }
+ elseif (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ $min = $this->value['min'];
+ if ($min !== '') {
+ $min = is_numeric($min) ? $min : strtotime($min, REQUEST_TIME);
+ }
+ $max = $this->value['max'];
+ if ($max !== '') {
+ $max = is_numeric($max) ? $max : strtotime($max, REQUEST_TIME);
+ }
+
+ if (is_numeric($min) && is_numeric($max)) {
+ $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+ }
+ elseif (is_numeric($min)) {
+ $operator = $this->operator === 'between' ? '>=' : '<';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ elseif (is_numeric($max)) {
+ $operator = $this->operator === 'between' ? '<=' : '>';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ }
+ else {
+ $v = is_numeric($this->value['value']) ? $this->value['value'] : strtotime($this->value['value'], REQUEST_TIME);
+ if ($v !== FALSE) {
+ $this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_entity.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_entity.inc
new file mode 100644
index 000000000..82d239e12
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_entity.inc
@@ -0,0 +1,207 @@
+ $this->isMultiValued() ? t('Is one of') : t('Is'),
+ 'all of' => t('Is all of'),
+ '<>' => $this->isMultiValued() ? t('Is not one of') : t('Is not'),
+ 'empty' => t('Is empty'),
+ 'not empty' => t('Is not empty'),
+ );
+ if (!$this->isMultiValued()) {
+ unset($operators['all of']);
+ }
+ return $operators;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_form(&$form, &$form_state) {
+ parent::value_form($form, $form_state);
+
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
+
+ // Set the correct default value in case the admin-set value is used (and a
+ // value is present). The value is used if the form is either not exposed,
+ // or the exposed form wasn't submitted yet. (There doesn't seem to be an
+ // easier way to check for that.)
+ if ($this->value && (empty($form_state['input']) || !empty($form_state['input']['live_preview']))) {
+ $form['value']['#default_value'] = $this->ids_to_strings($this->value);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_validate($form, &$form_state) {
+ if (!empty($form['value'])) {
+ $value = &$form_state['values']['options']['value'];
+ if (strlen($value)) {
+ $values = $this->isMultiValued($form_state['values']['options']) ? backdrop_explode_tags($value) : array($value);
+ $ids = $this->validate_entity_strings($form['value'], $values);
+
+ if ($ids) {
+ $value = $ids;
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function accept_exposed_input($input) {
+ $rc = parent::accept_exposed_input($input);
+
+ if ($rc) {
+ // If we have previously validated input, override.
+ if ($this->validated_exposed_input) {
+ $this->value = $this->validated_exposed_input;
+ }
+ }
+
+ return $rc;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ $this->validated_exposed_input = FALSE;
+ $identifier = $this->options['expose']['identifier'];
+ $input = $form_state['values'][$identifier];
+
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+ $input = $this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ if (!strlen($input)) {
+ return;
+ }
+ $values = $this->isMultiValued() ? backdrop_explode_tags($input) : array($input);
+
+ if (!$this->options['is_grouped'] || ($this->options['is_grouped'] && ($input != 'All'))) {
+ $this->validated_exposed_input = $this->validate_entity_strings($form[$identifier], $values);
+ }
+ }
+
+ /**
+ * Determines whether multiple user names can be entered into this filter.
+ *
+ * This is either the case if the form isn't exposed, or if the " Allow
+ * multiple selections" option is enabled.
+ *
+ * @param array $options
+ * (optional) The options array to use. If not supplied, the options set on
+ * this filter will be used.
+ *
+ * @return bool
+ * TRUE if multiple values can be entered for this filter, FALSE otherwise.
+ */
+ protected function isMultiValued(array $options = array()) {
+ $options = $options ? $options : $this->options;
+ return empty($options['exposed']) || !empty($options['expose']['multiple']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function admin_summary() {
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
+ $value = $this->value;
+ $this->value = empty($value) ? '' : $this->ids_to_strings($value);
+ $ret = parent::admin_summary();
+ $this->value = $value;
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ if ($this->operator === 'empty') {
+ $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+ }
+ elseif ($this->operator === 'not empty') {
+ $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+ }
+ elseif (is_array($this->value)) {
+ $all_of = $this->operator === 'all of';
+ $operator = $all_of ? '=' : $this->operator;
+ if (count($this->value) == 1) {
+ $this->query->condition($this->real_field, reset($this->value), $operator, $this->options['group']);
+ }
+ else {
+ $filter = $this->query->createFilter($operator === '<>' || $all_of ? 'AND' : 'OR');
+ foreach ($this->value as $value) {
+ $filter->condition($this->real_field, $value, $operator);
+ }
+ $this->query->filter($filter, $this->options['group']);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_fulltext.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_fulltext.inc
new file mode 100644
index 000000000..19987958b
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_fulltext.inc
@@ -0,0 +1,273 @@
+operator_form($form, $form_state);
+ $form['operator']['#description'] = t('This operator is only useful when using \'Search keys\'.');
+ }
+
+ /**
+ * Provide a list of options for the operator form.
+ */
+ public function operator_options() {
+ return array(
+ 'AND' => t('Contains all of these words'),
+ 'OR' => t('Contains any of these words'),
+ 'NOT' => t('Contains none of these words'),
+ );
+ }
+
+ /**
+ * Specify the options this filter uses.
+ */
+ public function option_definition() {
+ $options = parent::option_definition();
+
+ $options['operator']['default'] = 'AND';
+
+ $options['mode'] = array('default' => 'keys');
+ $options['min_length'] = array('default' => '');
+ $options['fields'] = array('default' => array());
+
+ return $options;
+ }
+
+ /**
+ * 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. You can change how search keys are parsed under "Advanced" > "Query settings".'),
+ 'filter' => t("Search filter – use as a single phrase that restricts the result set but doesn't influence relevance."),
+ ),
+ '#default_value' => $this->options['mode'],
+ );
+
+ $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;
+ }
+
+ $form['min_length'] = array(
+ '#title' => t('Minimum keyword length'),
+ '#description' => t('Minimum length of each word in the search keys. Leave empty to allow all words.'),
+ '#type' => 'number',
+ '#min' => 1,
+ '#step' => 1,
+ '#size' => 4,
+ '#maxlength' => 4,
+ '#default_value' => $this->options['min_length'],
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ // Only validate exposed input.
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ // Don't validate on form reset.
+ if (!empty($form_state['triggering_element']['#value'])
+ && !empty($form['reset']['#value'])
+ && $form_state['triggering_element']['#value'] === $form['reset']['#value']) {
+ return;
+ }
+
+ // We only need to validate if there is a minimum word length set.
+ if ($this->options['min_length'] < 2) {
+ return;
+ }
+
+ $identifier = $this->options['expose']['identifier'];
+ $input = &$form_state['values'][$identifier];
+
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+ $input = &$this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ // If there is no input, we're fine.
+ if (!trim($input)) {
+ return;
+ }
+
+ $words = preg_split('/\s+/', $input);
+ $quoted = FALSE;
+ foreach ($words as $i => $word) {
+ $word_length = backdrop_strlen($word);
+ if (!$word_length) {
+ unset($words[$i]);
+ continue;
+ }
+ // Protect quoted strings.
+ if ($quoted && $word[strlen($word) - 1] === '"') {
+ $quoted = FALSE;
+ continue;
+ }
+ if ($quoted || $word[0] === '"') {
+ $quoted = TRUE;
+ continue;
+ }
+ if ($word_length < $this->options['min_length']) {
+ unset($words[$i]);
+ }
+ }
+ if (!$words) {
+ $vars['@count'] = $this->options['min_length'];
+ $msg = t('You must include at least one positive keyword with @count characters or more.', $vars);
+ form_error($form[$identifier], $msg);
+ }
+ $input = implode(' ', $words);
+ }
+
+ /**
+ * 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'];
+ $available_fields = array_keys($this->getFulltextFields());
+ $fields = $fields ? array_intersect($fields, $available_fields) : $available_fields;
+
+ // If something already specifically set different fields, we silently fall
+ // back to mere filtering.
+ $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');
+ $op = $this->operator === 'NOT' ? '<>' : '=';
+ foreach ($fields as $field) {
+ $filter->condition($field, $this->value, $op);
+ }
+ $this->query->filter($filter);
+ return;
+ }
+
+ // If the operator was set to OR or NOT, set OR as the conjunction. (It is
+ // also set for NOT since otherwise it would be "not all of these words".)
+ if ($this->operator != 'AND') {
+ $this->query->setOption('conjunction', 'OR');
+ }
+
+ try {
+ $this->query->fields($fields);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ return;
+ }
+ $old = $this->query->getKeys();
+ $old_original = $this->query->getOriginalKeys();
+ $this->query->keys($this->value);
+ if ($this->operator == 'NOT') {
+ $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 there were fulltext keys set, we take care to combine them in a
+ // meaningful way (especially with negated keys).
+ if ($old) {
+ $keys = &$this->query->getKeys();
+ // Array-valued keys are combined.
+ if (is_array($keys)) {
+ // If the old keys weren't parsed into an array, we instead have to
+ // combine the original keys.
+ if (is_scalar($old)) {
+ $keys = "($old) ({$this->value})";
+ }
+ else {
+ // If the conjunction or negation settings aren't the same, we have to
+ // nest both old and new keys array.
+ if (!empty($keys['#negation']) != !empty($old['#negation']) || $keys['#conjunction'] != $old['#conjunction']) {
+ $keys = array(
+ '#conjunction' => 'AND', $old, $keys,
+ );
+ }
+ // Otherwise, just add all individual words from the old keys to the
+ // new ones.
+ else {
+ foreach (element_children($old) as $i) {
+ $keys[] = $old[$i];
+ }
+ }
+ }
+ }
+ // If the parse mode was "direct" for both old and new keys, we
+ // concatenate them and set them both via method and reference (to also
+ // update the originalKeys property.
+ elseif (is_scalar($old_original)) {
+ $combined_keys = "($old_original) ($keys)";
+ $this->query->keys($combined_keys);
+ $keys = $combined_keys;
+ }
+ }
+ }
+
+ /**
+ * 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/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_language.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_language.inc
new file mode 100644
index 000000000..13433f78f
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_language.inc
@@ -0,0 +1,55 @@
+value_options = $options + $this->value_options;
+ }
+
+ /**
+ * Add this filter to the query.
+ */
+ public function query() {
+ global $language_content;
+
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
+ foreach ($this->value as $i => $v) {
+ if ($v == 'current') {
+ $this->value[$i] = $language_content->language;
+ }
+ elseif ($v == 'default') {
+ $this->value[$i] = language_default('language');
+ }
+ elseif ($v == 'fallback' && module_exists('language_hierarchy')) {
+ $fallbacks = array($language_content->language => $language_content->language);
+ $fallbacks += array_keys(language_hierarchy_get_ancestors($language_content->language));
+ $this->value[$i] = backdrop_map_assoc($fallbacks);
+ }
+ }
+ parent::query();
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_numeric.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_numeric.inc
new file mode 100644
index 000000000..c04f2a297
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_numeric.inc
@@ -0,0 +1,248 @@
+normalizeValue();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function option_definition() {
+ $options = parent::option_definition();
+ $options['value'] = array(
+ 'contains' => array(
+ 'value' => array('default' => ''),
+ 'min' => array('default' => ''),
+ 'max' => array('default' => ''),
+ ),
+ );
+
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function operator_options() {
+ $operators = parent::operator_options();
+
+ $index = search_api_index_load(substr($this->table, 17));
+ $server = NULL;
+ try {
+ if ($index) {
+ $server = $index->server();
+ }
+ }
+ catch (SearchApiException $e) {
+ // Ignore.
+ }
+ if ($server && $server->supportsFeature('search_api_between')) {
+ $operators += array(
+ 'between' => t('Is between'),
+ 'not between' => t('Is not between'),
+ );
+ }
+
+ return $operators;
+ }
+
+ /**
+ * Provides a form for setting the filter value.
+ *
+ * Heavily borrowed from views_handler_filter_numeric.
+ *
+ * @see views_handler_filter_numeric::value_form()
+ */
+ public function value_form(&$form, &$form_state) {
+ $form['value']['#tree'] = TRUE;
+
+ $single_field_operators = $this->operator_options();
+ unset(
+ $single_field_operators['empty'],
+ $single_field_operators['not empty'],
+ $single_field_operators['between'],
+ $single_field_operators['not between']
+ );
+ $between_operators = array('between', 'not between');
+
+ // We have to make some choices when creating this as an exposed
+ // filter form. For example, if the operator is locked and thus
+ // not rendered, we can't render dependencies; instead we only
+ // render the form items we need.
+ $which = 'all';
+ $source = NULL;
+ if (!empty($form['operator'])) {
+ $source = ($form['operator']['#type'] == 'radios') ? 'radio:options[operator]' : 'edit-options-operator';
+ }
+
+ $identifier = NULL;
+ if (!empty($form_state['exposed'])) {
+ $identifier = $this->options['expose']['identifier'];
+ if (empty($this->options['expose']['use_operator']) || empty($this->options['expose']['operator_id'])) {
+ // Exposed and locked.
+ $which = in_array($this->operator, $between_operators) ? 'minmax' : 'value';
+ }
+ else {
+ $source = 'edit-' . backdrop_html_id($this->options['expose']['operator_id']);
+ }
+ }
+
+ // Hide the value box if the operator is 'empty' or 'not empty'.
+ // Radios share the same selector so we have to add some dummy selector.
+ if ($which == 'all') {
+ $form['value']['value'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Value') : '',
+ '#size' => 30,
+ '#default_value' => $this->value['value'],
+ '#dependency' => array($source => array_keys($single_field_operators)),
+ );
+ if ($identifier && !isset($form_state['input'][$identifier]['value'])) {
+ $form_state['input'][$identifier]['value'] = $this->value['value'];
+ }
+ }
+ elseif ($which == 'value') {
+ // When exposed we drop the value-value and just do value if
+ // the operator is locked.
+ $form['value'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Value') : '',
+ '#size' => 30,
+ '#default_value' => isset($this->value['value']) ? $this->value['value'] : '',
+ );
+ if ($identifier && !isset($form_state['input'][$identifier])) {
+ $form_state['input'][$identifier] = isset($this->value['value']) ? $this->value['value'] : '';
+ }
+ }
+
+ if ($which == 'all' || $which == 'minmax') {
+ $form['value']['min'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Min') : '',
+ '#size' => 30,
+ '#default_value' => $this->value['min'],
+ );
+ $form['value']['max'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('And max') : t('And'),
+ '#size' => 30,
+ '#default_value' => $this->value['max'],
+ );
+
+ if ($which == 'all') {
+ $form['value']['min']['#dependency'] = array($source => $between_operators);
+ $form['value']['max']['#dependency'] = array($source => $between_operators);
+ }
+
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['min'])) {
+ $form_state['input'][$identifier]['min'] = $this->value['min'];
+ }
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['max'])) {
+ $form_state['input'][$identifier]['max'] = $this->value['max'];
+ }
+
+ if (!isset($form['value']['value'])) {
+ // Ensure there is something in the 'value'.
+ $form['value']['value'] = array(
+ '#type' => 'value',
+ '#value' => NULL,
+ );
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function admin_summary() {
+ if (!empty($this->options['exposed'])) {
+ return t('exposed');
+ }
+
+ if ($this->operator === 'empty') {
+ return t('is empty');
+ }
+ if ($this->operator === 'not empty') {
+ return t('is not empty');
+ }
+
+ if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ // This is of course wrong for translation purposes, but copied from
+ // views_handler_filter_numeric::admin_summary() so probably still better
+ // to re-use this than to do it correctly.
+ $operator = $this->operator === 'between' ? t('between') : t('not between');
+ $vars = array(
+ '@min' => (string) $this->value['min'],
+ '@max' => (string) $this->value['max'],
+ );
+ return $operator . ' ' . t('@min and @max', $vars);
+ }
+
+ return check_plain((string) $this->operator) . ' ' . check_plain((string) $this->value['value']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ $this->normalizeValue();
+
+ if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ $min = $this->value['min'];
+ $max = $this->value['max'];
+ if ($min !== '' && $max !== '') {
+ $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+ }
+ elseif ($min !== '') {
+ $operator = $this->operator === 'between' ? '>=' : '<';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ elseif ($max !== '') {
+ $operator = $this->operator === 'between' ? '<=' : '>';
+ $this->query->condition($this->real_field, $max, $operator, $this->options['group']);
+ }
+ }
+ else {
+ // The parent handler doesn't expect our value structure, just pass the
+ // scalar value instead.
+ $this->value = $this->value['value'];
+ parent::query();
+ }
+ }
+
+ /**
+ * Sets $this->value to an array of options as defined by the filter.
+ *
+ * @see SearchApiViewsHandlerFilterNumeric::option_definition()
+ */
+ protected function normalizeValue() {
+ $value = $this->value;
+ if (is_array($value) && isset($value[0])) {
+ $value = $value[0];
+ }
+ if (!is_array($value)) {
+ $value = array('value' => $value);
+ }
+ $this->value = array(
+ 'value' => isset($value['value']) ? $value['value'] : '',
+ 'min' => isset($value['min']) ? $value['min'] : '',
+ 'max' => isset($value['max']) ? $value['max'] : '',
+ );
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_options.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_options.inc
new file mode 100644
index 000000000..88f61df4b
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_options.inc
@@ -0,0 +1,340 @@
+query) {
+ $index = $this->query->getIndex();
+ }
+ elseif (substr($this->view->base_table, 0, 17) == 'search_api_index_') {
+ $index = search_api_index_load(substr($this->view->base_table, 17));
+ }
+ else {
+ return NULL;
+ }
+ $wrapper = $index->entityWrapper(NULL, TRUE);
+ $parts = explode(':', $this->real_field);
+ foreach ($parts as $i => $part) {
+ if (!isset($wrapper->$part)) {
+ return NULL;
+ }
+ $wrapper = $wrapper->$part;
+ $info = $wrapper->info();
+ if ($i < count($parts) - 1) {
+ // Unwrap lists.
+ $level = search_api_list_nesting_level($info['type']);
+ for ($j = 0; $j < $level; ++$j) {
+ $wrapper = $wrapper[0];
+ }
+ }
+ }
+
+ return $wrapper;
+ }
+
+ /**
+ * Fills the value_options property with all possible options.
+ */
+ protected function get_value_options() {
+ if (isset($this->value_options)) {
+ return;
+ }
+
+ $wrapper = $this->get_wrapper();
+ if ($wrapper) {
+ $this->value_options = $wrapper->optionsList('view');
+ }
+ else {
+ $this->value_options = array();
+ }
+ }
+
+ /**
+ * Provide a list of options for the operator form.
+ */
+ public function operator_options() {
+ $options = array(
+ '=' => t('Is one of'),
+ 'all of' => t('Is all of'),
+ '<>' => t('Is none of'),
+ 'empty' => t('Is empty'),
+ 'not empty' => t('Is not empty'),
+ );
+ // "Is all of" doesn't make sense for single-valued fields.
+ if (empty($this->definition['multi-valued'])) {
+ unset($options['all of']);
+ }
+ return $options;
+ }
+
+ /**
+ * 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['value'] = array('default' => '');
+ $options['expose']['contains']['reduce'] = array('default' => FALSE);
+ return $options;
+ }
+
+ /**
+ * Reduce the options according to the selection.
+ */
+ protected function reduce_value_options() {
+ foreach ($this->value_options as $id => $option) {
+ if (!isset($this->options['value'][$id])) {
+ unset($this->value_options[$id]);
+ }
+ }
+ return $this->value_options;
+ }
+
+ /**
+ * Save set checkboxes.
+ */
+ public function value_submit($form, &$form_state) {
+ // Backdrop'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) {
+ $this->get_value_options();
+ if (!empty($this->options['expose']['reduce']) && !empty($form_state['exposed'])) {
+ $options = $this->reduce_value_options();
+ }
+ else {
+ $options = $this->value_options;
+ }
+
+ $form['value'] = array(
+ '#type' => $this->value_form_type,
+ '#title' => empty($form_state['exposed']) ? t('Value') : '',
+ '#options' => $options,
+ '#multiple' => TRUE,
+ '#size' => min(4, count($options)),
+ '#default_value' => is_array($this->value) ? $this->value : array(),
+ );
+
+ // Hide the value box if the operator is 'empty' or 'not empty'.
+ // Radios share the same selector so we have to add some dummy selector.
+ if (empty($form_state['exposed'])) {
+ $form['value']['#states']['visible'] = array(
+ ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ elseif (!empty($this->options['expose']['use_operator'])) {
+ $name = $this->options['expose']['operator_id'];
+ $form['value']['#states']['visible'] = array(
+ ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ }
+
+ /**
+ * 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.
+ $this->get_value_options();
+ foreach ($this->value as $i => $value) {
+ if (!isset($this->value_options[$value])) {
+ unset($this->value[$i]);
+ }
+ }
+ // Choose different kind of ouput for 0, a single and multiple values.
+ if (count($this->value) == 0) {
+ return $this->operator != '<>' ? t('none') : t('any');
+ }
+ elseif (count($this->value) == 1) {
+ switch ($this->operator) {
+ case '=':
+ case 'all of':
+ $operator = '=';
+ break;
+
+ case '<>':
+ $operator = '<>';
+ break;
+ }
+ // If there is only a single value, use just the plain operator, = or <>.
+ $operator = check_plain($operator);
+ $values = check_plain($this->value_options[reset($this->value)]);
+ }
+ else {
+ foreach ($this->value as $value) {
+ if ($values !== '') {
+ $values .= ', ';
+ }
+ if (backdrop_strlen($values) > 20) {
+ $values .= '…';
+ break;
+ }
+ $values .= check_plain($this->value_options[$value]);
+ }
+ }
+
+ return $operator . (($values !== '') ? ' ' . $values : '');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function accept_exposed_input($input) {
+ $accepted = parent::accept_exposed_input($input);
+
+ // Grouped filters will have the raw form values structure from the
+ // checkboxes as the value here. Convert that into the correct array of
+ // values instead.
+ if ($accepted && is_array($this->value) && $this->is_a_group()) {
+ // For some reason, Views thinks it's a good idea to nest the form values
+ // into a second array in some cases. That one will be numerically indexed
+ // with just a single entry, though, so it should be relatively easy to
+ // spot.
+ if (count($this->value) && isset($this->value[0])) {
+ $this->value = reset($this->value);
+ }
+ $this->value = array_keys(array_filter($this->value));
+ if (!$this->value) {
+ return FALSE;
+ }
+ }
+
+ return $accepted;
+ }
+
+ /**
+ * Add this filter to the query.
+ */
+ public function query() {
+ if ($this->operator === 'empty') {
+ $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+ return;
+ }
+ if ($this->operator === 'not empty') {
+ $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+ return;
+ }
+
+ // Extract the value.
+ while (is_array($this->value) && count($this->value) == 1) {
+ $this->value = reset($this->value);
+ }
+
+ // Determine operator and conjunction. The defaults are already right for
+ // "all of".
+ $operator = '=';
+ $conjunction = 'AND';
+ switch ($this->operator) {
+ case '=':
+ $conjunction = 'OR';
+ break;
+
+ case '<>':
+ $operator = '<>';
+ break;
+ }
+
+ // If the value is an empty array, we either want no filter at all (for
+ // "is none of"), or want to find only items with no value for the field.
+ if ($this->value === array()) {
+ if ($operator != '<>') {
+ $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+ }
+ return;
+ }
+
+ if (is_scalar($this->value) && $this->value !== '') {
+ $this->query->condition($this->real_field, $this->value, $operator, $this->options['group']);
+ }
+ elseif ($this->value) {
+ $filter = $this->query->createFilter($conjunction);
+ // $filter will be NULL if there were errors in the query.
+ if ($filter) {
+ foreach ($this->value as $v) {
+ $filter->condition($this->real_field, $v, $operator);
+ }
+ $this->query->filter($filter, $this->options['group']);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc
new file mode 100644
index 000000000..e9a1f1f5d
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc
@@ -0,0 +1,338 @@
+definition['vocabulary']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function option_definition() {
+ $options = parent::option_definition();
+
+ $options['type'] = array('default' => !empty($this->definition['vocabulary']) ? 'textfield' : 'select');
+ $options['hierarchy'] = array('default' => 0);
+ $options['expose']['contains']['reduce'] = array('default' => FALSE);
+ $options['error_message'] = array(
+ 'default' => TRUE,
+ 'bool' => TRUE,
+ );
+
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function extra_options_form(&$form, &$form_state) {
+ $form['type'] = array(
+ '#type' => 'radios',
+ '#title' => t('Selection type'),
+ '#options' => array(
+ 'select' => t('Dropdown'),
+ 'textfield' => t('Autocomplete'),
+ ),
+ '#default_value' => $this->options['type'],
+ );
+
+ $form['hierarchy'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Show hierarchy in dropdown'),
+ '#default_value' => !empty($this->options['hierarchy']),
+ );
+ $form['hierarchy']['#states']['visible'][':input[name="options[type]"]']['value'] = 'select';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_form(&$form, &$form_state) {
+ parent::value_form($form, $form_state);
+
+ if (!empty($this->definition['vocabulary'])) {
+ $vocabulary = taxonomy_vocabulary_load($this->definition['vocabulary']);
+ $title = t('Select terms from vocabulary @voc', array('@voc' => $vocabulary->name));
+ }
+ else {
+ $vocabulary = FALSE;
+ $title = t('Select terms');
+ }
+ $form['value']['#title'] = $title;
+
+ if ($vocabulary && $this->options['type'] == 'textfield') {
+ $form['value']['#autocomplete_path'] = 'admin/views/ajax/autocomplete/taxonomy/' . $vocabulary->machine_name;
+ }
+ else {
+ if ($vocabulary && !empty($this->options['hierarchy'])) {
+ $tree = taxonomy_get_tree($vocabulary->machine_name, 0, NULL, TRUE);
+ $options = array();
+
+ if ($tree) {
+ foreach ($tree as $term) {
+ $choice = new stdClass();
+ $choice->option = array($term->tid => str_repeat('-', $term->depth) . check_plain(entity_label('taxonomy_term', $term)));
+ $options[] = $choice;
+ }
+ }
+ }
+ else {
+ $options = array();
+ $query = db_select('taxonomy_term_data', 'td');
+ $query->fields('td');
+ $query->orderby('td.vocabulary');
+ $query->orderby('td.weight');
+ $query->orderby('td.name');
+ $query->addTag('taxonomy_term_access');
+ if ($vocabulary) {
+ $query->condition('td.vocabulary', $vocabulary->machine_name);
+ }
+ $result = $query->execute();
+ $tids = array();
+
+ foreach ($result as $term) {
+ $tids[] = $term->tid;
+ }
+ $terms = taxonomy_term_load_multiple($tids);
+
+ foreach ($terms as $term) {
+ $options[$term->tid] = check_plain(entity_label('taxonomy_term', $term));
+ }
+ }
+
+ $default_value = (array) $this->value;
+
+ if (!empty($form_state['exposed'])) {
+ $identifier = $this->options['expose']['identifier'];
+
+ if (!empty($this->options['expose']['reduce'])) {
+ $options = $this->reduce_value_options($options);
+
+ if (!empty($this->options['expose']['multiple']) && empty($this->options['expose']['required'])) {
+ $default_value = array();
+ }
+ }
+
+ if (empty($this->options['expose']['multiple'])) {
+ if (empty($this->options['expose']['required']) && (empty($default_value) || !empty($this->options['expose']['reduce']))) {
+ $default_value = 'All';
+ }
+ elseif (empty($default_value)) {
+ $keys = array_keys($options);
+ $default_value = array_shift($keys);
+ }
+ // Due to #1464174 there is a chance that array('') was saved in the
+ // admin ui. Let's choose a safe default value.
+ elseif ($default_value == array('')) {
+ $default_value = 'All';
+ }
+ else {
+ $copy = $default_value;
+ $default_value = array_shift($copy);
+ }
+ }
+ }
+ $form['value']['#type'] = 'select';
+ $form['value']['#multiple'] = TRUE;
+ $form['value']['#options'] = $options;
+ $form['value']['#size'] = min(9, count($options));
+ $form['value']['#default_value'] = $default_value;
+
+ if (!empty($form_state['exposed']) && isset($identifier) && !isset($form_state['input'][$identifier])) {
+ $form_state['input'][$identifier] = $default_value;
+ }
+ }
+ }
+
+ /**
+ * Reduces the available exposed options according to the selection.
+ */
+ protected function reduce_value_options(array $options) {
+ foreach ($options as $id => $option) {
+ if (empty($this->options['value'][$id])) {
+ unset($options[$id]);
+ }
+ }
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_validate($form, &$form_state) {
+ // We only validate if they've chosen the text field style.
+ if ($this->options['type'] != 'textfield') {
+ return;
+ }
+
+ parent::value_validate($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function accept_exposed_input($input) {
+ if (empty($this->options['exposed'])) {
+ return TRUE;
+ }
+
+ // We need to know the operator, which is normally set in
+ // views_handler_filter::accept_exposed_input(), before we actually call
+ // the parent version of ourselves.
+ if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+ $this->operator = $input[$this->options['expose']['operator_id']];
+ }
+
+ // If view is an attachment and is inheriting exposed filters, then assume
+ // exposed input has already been validated.
+ if (!empty($this->view->is_attachment) && $this->view->display_handler->uses_exposed()) {
+ $this->validated_exposed_input = (array) $this->view->exposed_raw_input[$this->options['expose']['identifier']];
+ }
+
+ // If we're checking for EMPTY or NOT, we don't need any input, and we can
+ // say that our input conditions are met by just having the right operator.
+ if ($this->operator == 'empty' || $this->operator == 'not empty') {
+ return TRUE;
+ }
+
+ // If it's non-required and there's no value don't bother filtering.
+ if (!$this->options['expose']['required'] && empty($this->validated_exposed_input)) {
+ return FALSE;
+ }
+
+ return parent::accept_exposed_input($input);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ // We only validate if they've chosen the text field style.
+ if ($this->options['type'] != 'textfield') {
+ $input = $form_state['values'][$this->options['expose']['identifier']];
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $input = $this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ if ($input != 'All') {
+ $this->validated_exposed_input = (array) $input;
+ }
+ return;
+ }
+
+ parent::exposed_validate($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function expose_options() {
+ parent::expose_options();
+ $this->options['expose']['reduce'] = FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validate_entity_strings(array &$form, array $values) {
+ if (empty($values)) {
+ return array();
+ }
+
+ $tids = array();
+ $names = array();
+ $missing = array();
+ foreach ($values as $value) {
+ $missing[strtolower($value)] = TRUE;
+ $names[] = $value;
+ }
+
+ if (!$names) {
+ return FALSE;
+ }
+
+ $query = db_select('taxonomy_term_data', 'td');
+ $query->fields('td');
+ $query->condition('td.name', $names);
+ if (!empty($this->definition['vocabulary'])) {
+ $query->condition('td.machine_name', $this->definition['vocabulary']);
+ }
+ $query->addTag('taxonomy_term_access');
+ $result = $query->execute();
+ foreach ($result as $term) {
+ unset($missing[strtolower($term->name)]);
+ $tids[] = $term->tid;
+ }
+
+ if ($missing) {
+ if (!empty($this->options['error_message'])) {
+ form_error($form, format_plural(count($missing), 'Unable to find term: @terms', 'Unable to find terms: @terms', array('@terms' => implode(', ', array_keys($missing)))));
+ }
+ else {
+ // Add a bogus TID which will show an empty result for a positive filter
+ // and be ignored for an excluding one.
+ $tids[] = 0;
+ }
+ }
+
+ return $tids;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function expose_form(&$form, &$form_state) {
+ parent::expose_form($form, $form_state);
+
+ if ($this->options['type'] == 'select') {
+ $form['expose']['reduce'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Limit list to selected items'),
+ '#description' => t('If checked, the only items presented to the user will be the ones selected here.'),
+ '#default_value' => $this->options['expose']['reduce'],
+ );
+ }
+ else {
+ $form['error_message'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display error message'),
+ '#description' => t('Display an error message if one of the entered terms could not be found.'),
+ '#default_value' => $this->options['error_message'],
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ids_to_strings(array $ids) {
+ $ids = array_filter($ids);
+ if (!$ids) {
+ return '';
+ }
+ return implode(', ', db_select('taxonomy_term_data', 'td')
+ ->fields('td', array('name'))
+ ->condition('td.tid', $ids)
+ ->execute()
+ ->fetchCol());
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_text.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_text.inc
new file mode 100644
index 000000000..83152a1d4
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_text.inc
@@ -0,0 +1,68 @@
+ t('contains'), '<>' => t("doesn't contain"));
+ }
+
+ /**
+ * Determines whether input from the exposed filters affects this filter.
+ *
+ * Overridden to not treat "All" differently.
+ *
+ * @param array $input
+ * The user input from the exposed filters.
+ *
+ * @return bool
+ * TRUE if the input should change the behavior of this filter.
+ */
+ public function accept_exposed_input($input) {
+ if (empty($this->options['exposed'])) {
+ return TRUE;
+ }
+
+ if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+ $this->operator = $input[$this->options['expose']['operator_id']];
+ }
+
+ if (!empty($this->options['expose']['identifier'])) {
+ $value = $input[$this->options['expose']['identifier']];
+
+ // Various ways to check for the absence of non-required input.
+ if (empty($this->options['expose']['required'])) {
+ if (($this->operator == 'empty' || $this->operator == 'not empty') && $value === '') {
+ $value = ' ';
+ }
+
+ if (!empty($this->always_multiple) && $value === '') {
+ return FALSE;
+ }
+ }
+
+ if (isset($value)) {
+ $this->value = $value;
+ if (empty($this->always_multiple) && empty($this->options['expose']['multiple'])) {
+ $this->value = array($value);
+ }
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_user.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_user.inc
new file mode 100644
index 000000000..29f42d1e1
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_filter_user.inc
@@ -0,0 +1,79 @@
+isMultiValued() ? 'admin/views/ajax/autocomplete/user' : 'user/autocomplete';
+ $form['value']['#autocomplete_path'] = $path;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ids_to_strings(array $ids) {
+ $names = array();
+ $args[':uids'] = array_filter($ids);
+ if ($args[':uids']) {
+ $result = db_query('SELECT uid, name FROM {users} u WHERE uid IN (:uids)', $args);
+ $result = $result->fetchAllKeyed();
+ }
+ foreach ($ids as $uid) {
+ if (!$uid) {
+ $names[] = config_get('system.performance', 'anonymous');
+ }
+ elseif (isset($result[$uid])) {
+ $names[] = $result[$uid];
+ }
+ }
+ return implode(', ', $names);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validate_entity_strings(array &$form, array $values) {
+ $uids = array();
+ $missing = array();
+ foreach ($values as $value) {
+ if (backdrop_strtolower($value) === backdrop_strtolower(config_get('system.performance', 'anonymous'))) {
+ $uids[] = 0;
+ }
+ else {
+ $missing[strtolower($value)] = $value;
+ }
+ }
+
+ if (!$missing) {
+ return $uids;
+ }
+
+ $result = db_query("SELECT * FROM {users} WHERE name IN (:names)", array(':names' => array_values($missing)));
+ foreach ($result as $account) {
+ unset($missing[strtolower($account->name)]);
+ $uids[] = $account->uid;
+ }
+
+ if ($missing) {
+ form_error($form, format_plural(count($missing), 'Unable to find user: @users', 'Unable to find users: @users', array('@users' => implode(', ', $missing))));
+ }
+
+ return $uids;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_sort.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_sort.inc
new file mode 100644
index 000000000..d7ca1e47d
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/handler_sort.inc
@@ -0,0 +1,50 @@
+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();
+ unset($sort);
+ }
+
+ // If two of the same fields are used for sort, ignore the latter in order
+ // for the prior to take precedence. (Temporary workaround until
+ // https://www.drupal.org/node/2145547 is fixed in Views.)
+ $alreadySorted = $this->query->getSort();
+ if (is_array($alreadySorted) && isset($alreadySorted[$this->real_field])) {
+ return;
+ }
+
+ try {
+ $this->query->sort($this->real_field, $this->options['order']);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_cache.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_cache.inc
new file mode 100644
index 000000000..0c5984e36
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_cache.inc
@@ -0,0 +1,146 @@
+get_results_key();
+ $results = NULL;
+ $query_plugin = $this->view->query;
+ if ($query_plugin instanceof SearchApiViewsQuery) {
+ $results = $query_plugin->getSearchApiResults();
+ }
+ $data = array(
+ 'result' => $this->view->result,
+ 'total_rows' => isset($this->view->total_rows) ? $this->view->total_rows : 0,
+ 'current_page' => $this->view->get_current_page(),
+ 'search_api results' => $results,
+ );
+ cache_set($cid, $data, $this->bin, $this->cache_set_expire($type));
+ }
+
+ /**
+ * Overrides views_plugin_cache::cache_get().
+ *
+ * Additionally stores successfully retrieved results with
+ * search_api_current_search().
+ */
+ public function cache_get($type) {
+ if ($type != 'results') {
+ return parent::cache_get($type);
+ }
+
+ // Values to set: $view->result, $view->total_rows, $view->execute_time,
+ // $view->current_page.
+ if ($cache = cache_get($this->get_results_key(), $this->bin)) {
+ $cutoff = $this->cache_expire($type);
+ if (!$cutoff || $cache->created > $cutoff) {
+ $this->view->result = $cache->data['result'];
+ $this->view->total_rows = $cache->data['total_rows'];
+ $this->view->set_current_page($cache->data['current_page']);
+ $this->view->execute_time = 0;
+
+ // Trick Search API into believing a search happened, to make facetting
+ // et al. work.
+ $query = $this->getSearchApiQuery();
+ search_api_current_search($query->getOption('search id'), $query, $cache->data['search_api results']);
+
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Overrides views_plugin_cache::get_cache_key().
+ *
+ * Use the Search API query as the main source for the key. Note that in
+ * Views < 3.8, this method does not exist.
+ */
+ public function get_cache_key($key_data = array()) {
+ global $user;
+
+ if (!isset($this->_results_key)) {
+ $query = $this->getSearchApiQuery();
+ $query->preExecute();
+ $key_data += array(
+ 'query' => $query,
+ 'roles' => array_keys($user->roles),
+ 'super-user' => $user->uid == 1, // Special caching for super user.
+ 'language' => $GLOBALS['language']->langcode,
+ 'base_url' => $GLOBALS['base_url'],
+ 'offset' => $this->view->get_current_page() . '*' . $this->view->get_items_per_page() . '+' . $this->view->get_offset(),
+ );
+ // Not sure what gets passed in exposed_info, so better include it. All
+ // other parameters used in the parent method are already reflected in the
+ // Search API query object we use.
+ if (isset($_GET['exposed_info'])) {
+ $key_data['exposed_info'] = $_GET['exposed_info'];
+ }
+ }
+ $key = backdrop_hash_base64(serialize($key_data));
+ return $key;
+ }
+
+ /**
+ * Overrides views_plugin_cache::get_results_key().
+ *
+ * This is unnecessary for Views >= 3.8.
+ */
+ public function get_results_key() {
+ if (!isset($this->_results_key)) {
+ $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . $this->get_cache_key();
+ }
+
+ return $this->_results_key;
+ }
+
+ /**
+ * Retrieves the Search API query object associated with the current view.
+ *
+ * @return SearchApiQueryInterface|null
+ * The Search API query object associated with the current view; or NULL if
+ * there is none.
+ */
+ protected function getSearchApiQuery() {
+ if (!isset($this->search_api_query)) {
+ $this->search_api_query = FALSE;
+ if (isset($this->view->query) && $this->view->query instanceof SearchApiViewsQuery) {
+ $this->search_api_query = $this->view->query->getSearchApiQuery();
+ }
+ }
+
+ return $this->search_api_query ? $this->search_api_query : NULL;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_content_cache.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_content_cache.inc
new file mode 100644
index 000000000..f7da404be
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/plugin_content_cache.inc
@@ -0,0 +1,146 @@
+get_results_key();
+ $results = NULL;
+ $query_plugin = $this->view->query;
+ if ($query_plugin instanceof SearchApiViewsQuery) {
+ $results = $query_plugin->getSearchApiResults();
+ }
+ $data = array(
+ 'result' => $this->view->result,
+ 'total_rows' => isset($this->view->total_rows) ? $this->view->total_rows : 0,
+ 'current_page' => $this->view->get_current_page(),
+ 'search_api results' => $results,
+ );
+ cache_set($cid, $data, $this->table, $this->cache_set_expire($type));
+ }
+
+ /**
+ * Overrides views_plugin_cache::cache_get().
+ *
+ * Additionally stores successfully retrieved results with
+ * search_api_current_search().
+ */
+ public function cache_get($type) {
+ if ($type != 'results') {
+ return parent::cache_get($type);
+ }
+
+ // Values to set: $view->result, $view->total_rows, $view->execute_time,
+ // $view->current_page.
+ if ($cache = cache_get($this->get_results_key(), $this->table)) {
+ $cutoff = $this->cache_expire($type);
+ if (!$cutoff || $cache->created > $cutoff) {
+ $this->view->result = $cache->data['result'];
+ $this->view->total_rows = $cache->data['total_rows'];
+ $this->view->set_current_page($cache->data['current_page']);
+ $this->view->execute_time = 0;
+
+ // Trick Search API into believing a search happened, to make facetting
+ // et al. work.
+ $query = $this->getSearchApiQuery();
+ search_api_current_search($query->getOption('search id'), $query, $cache->data['search_api results']);
+
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Overrides views_plugin_cache::get_cache_key().
+ *
+ * Use the Search API query as the main source for the key. Note that in
+ * Views < 3.8, this method does not exist.
+ */
+ public function get_cache_key($key_data = array()) {
+ global $user;
+
+ if (!isset($this->_results_key)) {
+ $query = $this->getSearchApiQuery();
+ $query->preExecute();
+ $key_data += array(
+ 'query' => $query,
+ 'roles' => array_keys($user->roles),
+ 'super-user' => $user->uid == 1, // Special caching for super user.
+ 'language' => $GLOBALS['language']->language,
+ 'base_url' => $GLOBALS['base_url'],
+ 'offset' => $this->view->get_current_page() . '*' . $this->view->get_items_per_page() . '+' . $this->view->get_offset(),
+ );
+ // Not sure what gets passed in exposed_info, so better include it. All
+ // other parameters used in the parent method are already reflected in the
+ // Search API query object we use.
+ if (isset($_GET['exposed_info'])) {
+ $key_data['exposed_info'] = $_GET['exposed_info'];
+ }
+ }
+ $key = backdrop_hash_base64(serialize($key_data));
+ return $key;
+ }
+
+ /**
+ * Overrides views_plugin_cache::get_results_key().
+ *
+ * This is unnecessary for Views >= 3.8.
+ */
+ public function get_results_key() {
+ if (!isset($this->_results_key)) {
+ $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . $this->get_cache_key();
+ }
+
+ return $this->_results_key;
+ }
+
+ /**
+ * Retrieves the Search API query object associated with the current view.
+ *
+ * @return SearchApiQueryInterface|null
+ * The Search API query object associated with the current view; or NULL if
+ * there is none.
+ */
+ protected function getSearchApiQuery() {
+ if (!isset($this->search_api_query)) {
+ $this->search_api_query = FALSE;
+ if (isset($this->view->query) && $this->view->query instanceof SearchApiViewsQuery) {
+ $this->search_api_query = $this->view->query->getSearchApiQuery();
+ }
+ }
+
+ return $this->search_api_query ? $this->search_api_query : NULL;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/includes/query.inc b/www/modules/contrib/search_api/contrib/search_api_views/includes/query.inc
new file mode 100644
index 000000000..b85af71c7
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/includes/query.inc
@@ -0,0 +1,794 @@
+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' => $this->options['parse_mode'],
+ ));
+ }
+ }
+ 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;
+ }
+
+ /**
+ * Adds a sort to the query.
+ *
+ * @param string $selector
+ * The field to sort on. All indexed fields of the index are valid values.
+ * In addition, these special fields may be used:
+ * - search_api_relevance: sort by relevance;
+ * - search_api_id: sort by item id;
+ * - search_api_random: random sort (available only if the server supports
+ * the "search_api_random_sort" feature).
+ * @param string $order
+ * The order to sort items in - either 'ASC' or 'DESC'. Defaults to 'ASC'.
+ */
+ public function add_selector_orderby($selector, $order = 'ASC') {
+ if (!$this->errors) {
+ $this->query->sort($selector, $order);
+ }
+ }
+
+ /**
+ * Provides a sorting method as present in the Views default query plugin.
+ *
+ * This is provided so that the "Global: Random" sort included in Views will
+ * work properly with Search API Views. Random sorting is only supported if
+ * the active search server supports the "search_api_random_sort" feature,
+ * though, otherwise the call will be ignored.
+ *
+ * This method can only be used to sort randomly, as would be done with the
+ * default query plugin. All other calls are ignored.
+ *
+ * @param string|null $table
+ * Only "rand" is recognized here, all other calls are ignored.
+ * @param string|null $field
+ * Is ignored and only present for compatibility reasons.
+ * @param string $order
+ * Either "ASC" or "DESC".
+ * @param string|null $alias
+ * Is ignored and only present for compatibility reasons.
+ * @param array $params
+ * The following optional parameters are recognized:
+ * - seed: a predefined seed for the random generator.
+ *
+ * @see views_plugin_query_default::add_orderby()
+ */
+ public function add_orderby($table, $field = NULL, $order = 'ASC', $alias = '', $params = array()) {
+ $server = $this->getIndex()->server();
+ if ($table == 'rand') {
+ if ($server->supportsFeature('search_api_random_sort')) {
+ $this->add_selector_orderby('search_api_random', $order);
+ if ($params) {
+ $this->setOption('search_api_random_sort', $params);
+ }
+ }
+ else {
+ $variables['%server'] = $server->label();
+ watchdog('search_api_views', 'Tried to sort results randomly on server %server which does not support random sorting.', $variables, WATCHDOG_WARNING);
+ }
+ }
+ }
+
+ /**
+ * Defines the options used by this query plugin.
+ *
+ * Adds some access options.
+ */
+ public function option_definition() {
+ return parent::option_definition() + array(
+ 'search_api_bypass_access' => array(
+ 'default' => FALSE,
+ ),
+ 'entity_access' => array(
+ 'default' => FALSE,
+ ),
+ 'parse_mode' => array(
+ 'default' => 'terms',
+ ),
+ 'preserve_facet_query_args' => array(
+ 'default' => FALSE,
+ 'bool' => TRUE,
+ ),
+ );
+ }
+
+ /**
+ * 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'],
+ );
+
+ if ($this->index && $this->index->getEntityType()) {
+ $form['entity_access'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Additional access checks on result entities'),
+ '#description' => t("Execute an access check for all result entities. This prevents users from seeing inappropriate content when the index contains stale data, or doesn't provide access checks. However, result counts, paging and other things won't work correctly if results are eliminated in this way, so only use this as a last ressort (and in addition to other checks, if possible)."),
+ '#default_value' => $this->options['entity_access'],
+ );
+ }
+
+ $form['parse_mode'] = array(
+ '#type' => 'select',
+ '#title' => t('Parse mode'),
+ '#description' => t('Choose how the search keys will be parsed.'),
+ '#options' => array(),
+ '#default_value' => $this->options['parse_mode'],
+ );
+ foreach ($this->query->parseModes() as $key => $mode) {
+ $form['parse_mode']['#options'][$key] = $mode['name'];
+ if (!empty($mode['description'])) {
+ $states['visible'][':input[name="query[options][parse_mode]"]']['value'] = $key;
+ $form["parse_mode_{$key}_description"] = array(
+ '#type' => 'item',
+ '#title' => $mode['name'],
+ '#description' => $mode['description'],
+ '#states' => $states,
+ );
+ }
+ }
+ if (module_exists('facetapi')) {
+ $form['preserve_facet_query_args'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Preserve facets while using filters'),
+ '#description' => t('By default, changing an exposed filter would reset all selected facets. This option allows you to prevent this behavior.'),
+ '#default_value' => $this->options['preserve_facet_query_args'],
+ );
+ }
+ }
+
+ /**
+ * Builds the necessary info to execute the query.
+ */
+ public function build(&$view) {
+ if (!empty($this->errors)) {
+ return;
+ }
+
+ $this->view = $view;
+
+ // Setup the nested filter structure for this query.
+ 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'])) {
+ $group += array('type' => 'AND');
+ // For filters without a group, we want to always add them directly to
+ // the query.
+ $filter = ($group_id === '') ? $this->query : $this->query->createFilter($group['type']);
+ 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();
+
+ // Set the search ID, if it was not already set.
+ if ($this->query->getOption('search id') == get_class($this->query)) {
+ $this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
+ }
+
+ // Add the "search_api_bypass_access" option to the query, if desired.
+ if (!empty($this->options['search_api_bypass_access'])) {
+ $this->query->setOption('search_api_bypass_access', TRUE);
+ }
+
+ // Store the Views base path, if we have one.
+ $path = $this->view->get_url();
+ if ($path) {
+ $this->query->setOption('search_api_base_path', $path);
+ }
+
+ // Save query information for Views UI.
+ $view->build_info['query'] = (string) $this->query;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alter(&$view) {
+ parent::alter($view);
+ backdrop_alter('search_api_views_query', $view, $this);
+ }
+
+ /**
+ * 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 || $this->abort) {
+ if (error_displayable()) {
+ foreach ($this->errors as $msg) {
+ backdrop_set_message(check_plain($msg), 'error');
+ }
+ }
+ $view->result = array();
+ $view->total_rows = 0;
+ $view->execute_time = 0;
+ return;
+ }
+
+ // Calculate the "skip result count" option, if it wasn't already set to
+ // FALSE.
+ $skip_result_count = $this->query->getOption('skip result count', TRUE);
+ if ($skip_result_count) {
+ $skip_result_count = !$this->pager || (!$this->pager->use_count_query() && empty($view->get_total_rows));
+ $this->query->setOption('skip result count', $skip_result_count);
+ }
+
+ try {
+ // Trigger pager pre_execute().
+ if ($this->pager) {
+ $this->pager->pre_execute($this->query);
+ }
+
+ // Views passes sometimes NULL and sometimes the integer 0 for "All" in a
+ // pager. If set to 0 items, a string "0" is passed. Therefore, we unset
+ // the limit if an empty value OTHER than a string "0" was passed.
+ if (!$this->limit && $this->limit !== '0') {
+ $this->limit = NULL;
+ }
+ // Set the range. (We always set this, as there might even be an offset if
+ // all items are shown.)
+ $this->query->range($this->offset, $this->limit);
+
+ $start = microtime(TRUE);
+
+ // Execute the search.
+ $results = $this->query->execute();
+ $this->search_api_results = $results;
+
+ // Store the results.
+ if (!$skip_result_count) {
+ $this->pager->total_items = $view->total_rows = $results['result count'];
+ if (!empty($this->pager->options['offset'])) {
+ $this->pager->total_items -= $this->pager->options['offset'];
+ }
+ $this->pager->update_page_info();
+ }
+ $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;
+
+ // Trigger pager post_execute().
+ if ($this->pager) {
+ $this->pager->post_execute($view->result);
+ }
+ }
+ catch (Exception $e) {
+ $this->errors[] = $e->getMessage();
+ // Recursion to get the same error behaviour as above.
+ $this->execute($view);
+ }
+ }
+
+ /**
+ * Aborts this search query.
+ *
+ * Used by handlers to flag a fatal error which shouldn't be displayed but
+ * still lead to the view returning empty and the search not being executed.
+ *
+ * @param string|null $msg
+ * Optionally, a translated, unescaped error message to display.
+ */
+ public function abort($msg = NULL) {
+ if ($msg) {
+ $this->errors[] = $msg;
+ }
+ $this->abort = TRUE;
+ }
+
+ /**
+ * 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) {
+ if (!empty($this->options['entity_access']) && ($entity_type = $this->index->getEntityType())) {
+ $entity = $this->index->loadItems(array($id));
+ if (!$entity || !entity_access('view', $entity_type, reset($entity))) {
+ continue;
+ }
+ }
+ $row = array();
+
+ // Include the loaded item for this result row, if present, or the item
+ // 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'] += search_api_get_sanitized_field_values($result['fields']);
+ }
+
+ // Check whether we need to extract any properties from the result item.
+ $missing_fields = array_diff_key($this->fields, $row['_entity_properties']);
+ 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 all loaded items from which fields still need to be
+ // extracted.
+ foreach ($items as $id => $item) {
+ // Extract item properties.
+ $wrapper = $this->index->entityWrapper($item, FALSE);
+ $rows[$id]->_entity_properties += $this->extractFields($wrapper, $missing[$id]);
+ $rows[$id]->entity = $item;
+ }
+
+ // Finally, add all rows to the Views result set.
+ $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 $i => $wrapper) {
+ try {
+ // Get the entity ID beforehand for possible watchdog messages.
+ $id = $wrapper->value(array('identifier' => TRUE));
+
+ // Only add results that exist.
+ if ($entity = $wrapper->value()) {
+ $return[$i] = $entity;
+ }
+ else {
+ watchdog('search_api_views', 'The search index returned a reference to an entity with ID @id, which does not exist in the database. Your index may be out of sync and should be rebuilt.', array('@id' => $id), WATCHDOG_ERROR);
+ }
+ }
+ catch (EntityMetadataWrapperException $e) {
+ watchdog_exception('search_api_views', $e, "%type while trying to load search result entity with ID @id: !message in %function (line %line of %file).", array('@id' => $id), WATCHDOG_ERROR);
+ }
+ }
+ return array($type, $return);
+ }
+
+ /**
+ * 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) {
+ $type = $this->index->getEntityType() ? $this->index->getEntityType() : $this->index->item_type;
+ $wrappers = array();
+ $load_items = array();
+ foreach ($results as $row_index => $row) {
+ if (isset($row->entity)) {
+ // If this entity isn't load, register it for pre-loading.
+ if (!is_object($row->entity)) {
+ $load_items[$row->entity] = $row_index;
+ }
+ else {
+ $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_items)) {
+ $items = $this->index->loadItems(array_keys($load_items));
+ foreach ($items as $id => $item) {
+ $wrappers[$load_items[$id]] = $this->index->entityWrapper($item);
+ }
+ }
+
+ // Apply the relationship, if necessary.
+ $selector_suffix = '';
+ if ($field && ($pos = strrpos($field, ':'))) {
+ $selector_suffix = substr($field, 0, $pos);
+ }
+ if ($selector_suffix || ($relationship && !empty($this->view->relationship[$relationship]))) {
+ // Use EntityPlusFieldHandlerHelper to compute the correct data selector for
+ // the relationship.
+ $handler = (object) array(
+ 'view' => $this->view,
+ 'relationship' => $relationship,
+ 'real_field' => '',
+ );
+ $selector = EntityPlusFieldHandlerHelper::construct_property_selector($handler);
+ $selector .= ($selector ? ':' : '') . $selector_suffix;
+ list($type, $wrappers) = EntityPlusFieldHandlerHelper::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', $tags = array()) {
+ if (!$this->errors) {
+ return $this->query->createFilter($conjunction, $tags);
+ }
+ }
+
+ /**
+ *
+ */
+ 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, $default = NULL) {
+ if (!$this->errors) {
+ return $this->query->getOption($name, $default);
+ }
+ return $default;
+ }
+
+ /**
+ *
+ */
+ public function setOption($name, $value) {
+ if (!$this->errors) {
+ return $this->query->setOption($name, $value);
+ }
+ return NULL;
+ }
+
+ /**
+ *
+ */
+ public function &getOptions() {
+ if (!$this->errors) {
+ return $this->query->getOptions();
+ }
+ $ret = NULL;
+ return $ret;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.api.php b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.api.php
new file mode 100644
index 000000000..95a92ca2d
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.api.php
@@ -0,0 +1,34 @@
+name == 'my_view' && is_numeric($view->exposed_raw_input['title']) && $view->exposed_raw_input['title'] > 0) {
+ // Traverse through the 'where' part of the query.
+ foreach ($query->where as &$condition_group) {
+ foreach ($condition_group['conditions'] as &$condition) {
+ // If this is the part of the query filtering on title, chang the
+ // condition to filter on node ID.
+ if (reset($condition) == 'node.title') {
+ $condition = array('node.nid', $view->exposed_raw_input['title'],'=');
+ }
+ }
+ }
+ }
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.info b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.info
new file mode 100644
index 000000000..3b8a6db51
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.info
@@ -0,0 +1,12 @@
+name = Search Views
+description = Integrates the Search API with Views, enabling users to create views with searches as filters or arguments.
+dependencies[] = search_api:search_api
+dependencies[] = entity_plus (>=1.x-1.0.10)
+backdrop = 1.x
+type = module
+package = Search
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.install b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.install
new file mode 100644
index 000000000..ac0431459
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.install
@@ -0,0 +1,13 @@
+ '3.0',
+ 'path' => backdrop_get_path('module', 'search_api_views') . '/views',
+ );
+}
+
+/**
+ * Implements hook_search_api_index_insert().
+ */
+function search_api_views_search_api_index_insert() {
+ // 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) {
+ // Check whether index was disabled.
+ $is_enabled = $index->enabled;
+ $was_enabled = $index->original->enabled;
+ if (!$is_enabled && $was_enabled) {
+ _search_api_views_index_unavailable($index);
+ return;
+ }
+
+ // Check whether the indexed fields changed.
+ $old_fields = $index->original->options + array('fields' => array());
+ $old_fields = $old_fields['fields'];
+ $new_fields = $index->options + array('fields' => array());
+ $new_fields = $new_fields['fields'];
+
+ // If the index was enabled or its fields changed, invalidate the Views cache.
+ if ($is_enabled != $was_enabled || $old_fields != $new_fields) {
+ views_invalidate_cache();
+ }
+}
+
+/**
+ * Implements hook_search_api_index_delete().
+ */
+function search_api_views_search_api_index_delete(SearchApiIndex $index) {
+ // Only do this if this is a "real" deletion, no revert.
+ if (!$index->hasStatus(ENTITY_PLUS_IN_CODE)) {
+ _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();
+ backdrop_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');
+ }
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function search_api_views_autoload_info() {
+ return array(
+ 'SearchApiViewsFacetsBlockDisplay' => 'includes/display_facet_block.inc',
+ 'SearchApiViewsHandlerArgument' => 'includes/handler_argument.inc',
+ 'SearchApiViewsHandlerArgumentDate' => 'includes/handler_argument_date.inc',
+ 'SearchApiViewsHandlerArgumentFulltext' => 'includes/handler_argument_fulltext.inc',
+ 'SearchApiViewsHandlerArgumentMoreLikeThis' => 'includes/handler_argument_more_like_this.inc',
+ 'SearchApiViewsHandlerArgumentString' => 'includes/handler_argument_string.inc',
+ 'SearchApiViewsHandlerArgumentTaxonomyTerm' => 'includes/handler_argument_taxonomy_term.inc',
+ 'SearchApiViewsHandlerFilter' => 'includes/handler_filter.inc',
+ 'SearchApiViewsHandlerFilterBoolean' => 'includes/handler_filter_boolean.inc',
+ 'SearchApiViewsHandlerFilterDate' => 'includes/handler_filter_date.inc',
+ 'SearchApiViewsHandlerFilterEntity' => 'includes/handler_filter_entity.inc',
+ 'SearchApiViewsHandlerFilterFulltext' => 'includes/handler_filter_fulltext.inc',
+ 'SearchApiViewsHandlerFilterLanguage' => 'includes/handler_filter_language.inc',
+ 'SearchApiViewsHandlerFilterNumeric' => 'includes/handler_filter_numeric.inc',
+ 'SearchApiViewsHandlerFilterOptions' => 'includes/handler_filter_options.inc',
+ 'SearchApiViewsHandlerFilterTaxonomyTerm' => 'includes/handler_filter_taxonomy_term.inc',
+ 'SearchApiViewsHandlerFilterText' => 'includes/handler_filter_text.inc',
+ 'SearchApiViewsHandlerFilterUser' => 'includes/handler_filter_user.inc',
+ 'SearchApiViewsHandlerSort' => 'includes/handler_sort.inc',
+ 'SearchApiViewsCache' => 'includes/plugin_cache.inc',
+ 'SearchApiViewsContentCache' => 'includes/plugin_content_cache.inc',
+ 'SearchApiViewsQuery' => 'includes/query.inc',
+ );
+}
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.tests.info b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.tests.info
new file mode 100644
index 000000000..34d6985a6
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/search_api_views.tests.info
@@ -0,0 +1,5 @@
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/contrib/search_api_views/views/search_api_views.views.inc b/www/modules/contrib/search_api/contrib/search_api_views/views/search_api_views.views.inc
new file mode 100644
index 000000000..1da1c0cbc
--- /dev/null
+++ b/www/modules/contrib/search_api/contrib/search_api_views/views/search_api_views.views.inc
@@ -0,0 +1,331 @@
+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',
+ );
+ $table['table']['entity type'] = $index->getEntityType();
+ $table['table']['skip entity load'] = TRUE;
+
+ try {
+ $wrapper = $index->entityWrapper(NULL, FALSE);
+ }
+ catch (EntityMetadataWrapperException $e) {
+ watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+ continue;
+ }
+
+ // Add field handlers and relationships provided by the Entity API.
+ foreach ($wrapper as $key => $property) {
+ $info = $property->info();
+ if ($info) {
+ entity_plus_views_field_definition($key, $info, $table);
+ }
+ }
+
+ try {
+ $wrapper = $index->entityWrapper(NULL);
+ }
+ catch (EntityMetadataWrapperException $e) {
+ watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+ continue;
+ }
+
+ // Add handlers for all indexed fields.
+ foreach ($index->getFields() as $key => $field) {
+ $tmp = $wrapper;
+ $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_plus_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']['float'] = TRUE;
+ $table['search_api_relevance']['field']['handler'] = 'entity_plus_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_plus_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';
+
+ // If there are taxonomy term references indexed in the index, include the
+ // "Indexed taxonomy term fields" contextual filter. We also save for all
+ // fields whether they contain only terms of a certain vocabulary, keying
+ // that information by vocabulary for later ease of use.
+ $vocabulary_fields = array();
+ foreach ($index->getFields() as $key => $field) {
+ if (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+ $field_id = ($pos = strrpos($key, ':')) ? substr($key, $pos + 1) : $key;
+ $field_info = field_info_field($field_id);
+ if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+ $vocabulary_fields[$vocabulary][] = $key;
+ }
+ else {
+ $vocabulary_fields[''][] = $key;
+ }
+ }
+ }
+ if ($vocabulary_fields) {
+ $table['search_api_views_taxonomy_term']['group'] = t('Search');
+ $table['search_api_views_taxonomy_term']['title'] = t('Indexed taxonomy term fields');
+ $table['search_api_views_taxonomy_term']['help'] = t('Search in all indexed taxonomy term fields.');
+ $table['search_api_views_taxonomy_term']['argument']['handler'] = 'SearchApiViewsHandlerArgumentTaxonomyTerm';
+ $table['search_api_views_taxonomy_term']['argument']['vocabulary_fields'] = $vocabulary_fields;
+ }
+ }
+ return $data;
+ }
+ catch (Exception $e) {
+ watchdog_exception('search_api_views', $e);
+ }
+}
+
+/**
+ * Adds handler definitions for a field to a Views data table definition.
+ *
+ * Helper method for search_api_views_views_data().
+ *
+ * @param $id
+ * The internal identifier of the field.
+ * @param array $field
+ * Information about the field.
+ * @param EntityMetadataWrapper $wrapper
+ * A wrapper providing further metadata about the field.
+ * @param array $table
+ * The existing Views data table definition, as a reference.
+ */
+function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper $wrapper, array &$table) {
+ $type = $field['type'];
+ $inner_type = search_api_extract_inner_type($type);
+
+ if (strpos($id, ':')) {
+ entity_plus_views_field_definition($id, $wrapper->info(), $table);
+ }
+ $id = _entity_plus_views_field_identifier($id, $table);
+ $table += array($id => array());
+
+ if ($inner_type == 'text') {
+ $table[$id] += array(
+ 'argument' => array(
+ 'handler' => 'SearchApiViewsHandlerArgumentString',
+ ),
+ 'filter' => array(
+ 'handler' => 'SearchApiViewsHandlerFilterText',
+ ),
+ );
+ return;
+ }
+
+ $info = $wrapper->info();
+ if (isset($info['options list']) && is_callable($info['options list'])) {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
+ $table[$id]['filter']['multi-valued'] = search_api_is_list_type($type);
+ }
+ elseif ($inner_type == 'boolean') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterBoolean';
+ }
+ elseif ($inner_type == 'date') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterDate';
+ }
+ elseif (isset($field['entity_type']) && $field['entity_type'] === 'user') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterUser';
+ }
+ elseif (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterTaxonomyTerm';
+ $field_info = field_info_field($info['name']);
+ // For the "Parent terms" and "All parent terms" properties, we can
+ // extrapolate the vocabulary from the parent in the selector. (E.g.,
+ // for "field_tags:parent" we can use the information of "field_tags".)
+ // Otherwise, we can't include any vocabulary information.
+ if (!$field_info && ($info['name'] == 'parent' || $info['name'] == 'parents_all')) {
+ if (!empty($table[$id]['real field'])) {
+ $parts = explode(':', $table[$id]['real field']);
+ $field_info = field_info_field($parts[count($parts) - 2]);
+ }
+ }
+ if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+ $table[$id]['filter']['vocabulary'] = $vocabulary;
+ }
+ }
+ elseif (in_array($inner_type, array('integer', 'decimal', 'duration', 'string', 'uri'))) {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterNumeric';
+ }
+ else {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter';
+ }
+
+ if ($inner_type == 'string' || $inner_type == 'uri') {
+ $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentString';
+ }
+ elseif ($inner_type == 'date') {
+ $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentDate';
+ }
+ else {
+ $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+ }
+
+ // We can only sort according to single-valued fields.
+ if ($type == $inner_type) {
+ $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() {
+ // Collect all base tables provided by this module.
+ $bases = array();
+ foreach (search_api_index_load_multiple(FALSE) as $index) {
+ $bases[] = 'search_api_index_' . $index->machine_name;
+ }
+
+ $ret = array(
+ 'query' => array(
+ 'search_api_views_query' => array(
+ 'title' => t('Search API Query'),
+ 'help' => t('Query will be generated and run using the Search API.'),
+ 'handler' => 'SearchApiViewsQuery',
+ ),
+ ),
+ 'cache' => array(
+ 'search_api_views_cache' => array(
+ 'title' => t('Search-specific'),
+ 'help' => t("Cache Search API views. (Other methods probably won't work with search views.)"),
+ 'base' => $bases,
+ 'handler' => 'SearchApiViewsCache',
+ 'uses options' => TRUE,
+ ),
+ ),
+ );
+
+ 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'),
+ );
+ }
+
+ if (module_exists('views_content_cache')) {
+ $ret['cache']['search_api_views_content_cache'] = array(
+ 'title' => t('Search-specific content-based'),
+ 'help' => t("Cache Search API views based on content updates. (Requires Views Content Cache)"),
+ 'base' => $bases,
+ 'handler' => 'SearchApiViewsContentCache',
+ 'uses options' => TRUE,
+ );
+ }
+
+ return $ret;
+}
+
+/**
+ * Returns the vocabulary machine name of a term field.
+ *
+ * @param array|null $field_info
+ * The field's field info array, or NULL if the field is not provided by the
+ * Field API. See the return value of field_info_field().
+ *
+ * @return string|null
+ * If the field contains taxonomy terms of a single vocabulary (which could be
+ * determined), that vocabulary's machine name; NULL otherwise.
+ */
+function _search_api_views_get_field_vocabulary($field_info) {
+ // Test for "Term reference" fields.
+ if (isset($field_info['settings']['allowed_values'][0]['vocabulary'])) {
+ return $field_info['settings']['allowed_values'][0]['vocabulary'];
+ }
+ // Test for "Entity reference" fields.
+ elseif (isset($field_info['settings']['handler']) && $field_info['settings']['handler'] === 'base') {
+ if (!empty($field_info['settings']['handler_settings']['target_bundles'])) {
+ $bundles = $field_info['settings']['handler_settings']['target_bundles'];
+ if (count($bundles) == 1) {
+ return key($bundles);
+ }
+ }
+ }
+ return NULL;
+}
diff --git a/www/modules/contrib/search_api/css/search_api.admin.css b/www/modules/contrib/search_api/css/search_api.admin.css
new file mode 100644
index 000000000..1b7aaef04
--- /dev/null
+++ b/www/modules/contrib/search_api/css/search_api.admin.css
@@ -0,0 +1,51 @@
+/**
+ * @file
+ * Styles for Search API admin pages.
+ */
+
+/*
+ * OVERVIEW
+ */
+
+.search-api-overview td.search-api-status {
+ text-align: center;
+}
+
+.search-api-overview td {
+ vertical-align: top;
+}
+
+/*
+ * VIEW SERVER
+ */
+
+.search-api-server-summary ul.inline {
+ margin: 0;
+}
+
+.search-api-server-summary ul.inline li {
+ padding-left: 0;
+}
+
+/*
+ * VIEW INDEX
+ */
+.search-api-limit,
+.search-api-batch-size {
+ text-align: center;
+}
+
+.search-api-index-status .progress .bar .filled::after {
+ background: #0074BD none;
+
+}
+
+/*
+ * MISC
+ */
+
+.search-api-alter-add-aggregation-fields,
+.search-api-checkboxes-list {
+ max-height: 12em;
+ overflow: auto;
+}
diff --git a/www/modules/contrib/search_api/disabled.png b/www/modules/contrib/search_api/disabled.png
new file mode 100644
index 000000000..224776502
Binary files /dev/null and b/www/modules/contrib/search_api/disabled.png differ
diff --git a/www/modules/contrib/search_api/enabled.png b/www/modules/contrib/search_api/enabled.png
new file mode 100644
index 000000000..95f8730e6
Binary files /dev/null and b/www/modules/contrib/search_api/enabled.png differ
diff --git a/www/modules/contrib/search_api/includes/callback.inc b/www/modules/contrib/search_api/includes/callback.inc
new file mode 100644
index 000000000..c41d2c447
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback.inc
@@ -0,0 +1,200 @@
+index = $index;
+ $this->options = $options;
+ }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::supportsIndex().
+ *
+ * The default implementation always returns TRUE.
+ */
+ public function supportsIndex(SearchApiIndex $index) {
+ return TRUE;
+ }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::configurationForm().
+ */
+ public function configurationForm() {
+ return array();
+ }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::configurationFormValidate().
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) { }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::configurationFormSubmit().
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ $this->options = $values;
+ return $values;
+ }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::propertyInfo().
+ */
+ public function propertyInfo() {
+ return array();
+ }
+
+ /**
+ * Determines whether the given index contains multiple types of entities.
+ *
+ * @param SearchApiIndex|null $index
+ * (optional) The index to examine. Defaults to the index set for this
+ * plugin.
+ *
+ * @return bool
+ * TRUE if the index is a multi-entity index, FALSE otherwise.
+ */
+ protected function isMultiEntityIndex(?SearchApiIndex $index = NULL) {
+ $index = $index ? $index : $this->index;
+ return $index->datasource() instanceof SearchApiCombinedEntityDataSourceController;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_add_aggregation.inc b/www/modules/contrib/search_api/includes/callback_add_aggregation.inc
new file mode 100644
index 000000000..643e23b1b
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_add_aggregation.inc
@@ -0,0 +1,405 @@
+index->getFields(FALSE);
+ $field_options = array();
+ $field_properties = array();
+ foreach ($fields as $name => $field) {
+ $field_options[$name] = check_plain($field['name']);
+ $field_properties[$name] = array(
+ '#attributes' => array('title' => $name),
+ '#description' => check_plain($field['description']),
+ );
+ }
+ $additional = empty($this->options['fields']) ? array() : $this->options['fields'];
+
+ $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;
+ $type_selector = ':input[name="callbacks[search_api_alter_add_aggregation][settings][fields][' . $name . '][type]"]';
+ foreach (array_keys($types) as $type) {
+ $form['fields'][$name]['type_descriptions'][$type]['#states']['visible'][$type_selector]['value'] = $type;
+ }
+ $form['fields'][$name]['separator'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Fulltext separator'),
+ '#description' => t('For aggregation type "Fulltext", set the text that should be used to separate the aggregated field values. Use "\t" for tabs and "\n" for newline characters.'),
+ '#default_value' => addcslashes(isset($field['separator']) ? $field['separator'] : "\n\n", "\0..\37\\"),
+ '#states' => array(
+ 'visible' => array(
+ $type_selector => array(
+ 'value' => 'fulltext',
+ ),
+ ),
+ ),
+ );
+ $form['fields'][$name]['fields'] = array_merge($field_properties, array(
+ '#type' => 'checkboxes',
+ '#title' => t('Contained fields'),
+ '#options' => $field_options,
+ '#default_value' => backdrop_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) {
+ unset($values['fields'][$name]['actions']);
+ $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
+ if ($field['name'] && !$fields) {
+ form_error($form['fields'][$name]['fields'], t('You have to select at least one field to aggregate. If you want to remove an aggregated field, please delete its name.'));
+ }
+ $values['fields'][$name]['separator'] = stripcslashes($field['separator']);
+ }
+ }
+
+ /**
+ *
+ */
+ 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'];
+ $this->fulltextReductionSeparator = isset($field['separator']) ? $field['separator'] : "\n\n";
+ $item->$name = array_reduce($values, array($this, 'reduce'), NULL);
+ if ($field['type'] == 'count' && !$item->$name) {
+ $item->$name = 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for reducing an array to a single value.
+ */
+ public function reduce($a, $b) {
+ switch ($this->reductionType) {
+ case 'fulltext':
+ return isset($a) ? $a . $this->fulltextReductionSeparator . $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;
+ case 'first_char':
+ $b = "$b";
+ if (isset($a) || $b === '') {
+ return $a;
+ }
+ return backdrop_substr($b, 0, 1);
+ case 'last':
+ return isset($b) ? $b : $a;
+ case 'list':
+ if (!isset($a)) {
+ $a = array();
+ }
+ $a[] = $b;
+ return $a;
+ }
+ return NULL;
+ }
+
+ /**
+ * 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 string $info
+ * (optional) One of "name", "type" or "description", to indicate what
+ * information should be returned for the types.
+ *
+ * @return string[]
+ * An associative array of aggregation type identifiers mapped to their
+ * names, data types or descriptions, as requested.
+ */
+ protected function getTypes($info = 'name') {
+ switch ($info) {
+ case 'name':
+ return array(
+ 'fulltext' => t('Fulltext'),
+ 'sum' => t('Sum'),
+ 'count' => t('Count'),
+ 'max' => t('Maximum'),
+ 'min' => t('Minimum'),
+ 'first' => t('First'),
+ 'first_char' => t('First letter'),
+ 'last' => t('Last'),
+ 'list' => t('List'),
+ );
+ case 'type':
+ return array(
+ 'fulltext' => 'text',
+ 'sum' => 'integer',
+ 'count' => 'integer',
+ 'max' => 'integer',
+ 'min' => 'integer',
+ 'first' => 'token',
+ 'first_char' => 'token',
+ 'last' => 'token',
+ 'list' => 'list',
+ );
+ 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.'),
+ 'first_char' => t('The "First letter" aggregation uses just the first letter of the first encountered field value as the aggregated value. This can, for example, be used to build a Glossary view.'),
+ 'last' => t('The Last aggregation will simply keep the last encountered field value.'),
+ 'list' => t('The List aggregation collects all field values into a multi-valued field containing all values.'),
+ );
+ }
+ return array();
+ }
+
+ /**
+ * 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') {
+ // Increment $i until the corresponding field is not set, then create the
+ // field with that number as suffix.
+ for ($i = 1; isset($this->options['fields']['search_api_aggregation_' . $i]); ++$i) {
+ }
+ $this->options['fields']['search_api_aggregation_' . $i] = array(
+ '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/www/modules/contrib/search_api/includes/callback_add_hierarchy.inc b/www/modules/contrib/search_api/includes/callback_add_hierarchy.inc
new file mode 100644
index 000000000..63d71eee1
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_add_hierarchy.inc
@@ -0,0 +1,217 @@
+getHierarchicalFields();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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 = backdrop_map_assoc($this->options['fields']);
+ foreach ($values['fields'] as $field) {
+ list($key) = explode(':', $field);
+ if (empty($previous[$field]) && isset($fields[$key]['type'])) {
+ $fields[$key]['type'] = 'list<' . search_api_extract_inner_type($fields[$key]['type']) . '>';
+ $change = TRUE;
+ }
+ }
+ $new = backdrop_map_assoc($values['fields']);
+ foreach ($previous as $field) {
+ list($key) = 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);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ if (empty($this->options['fields'])) {
+ return;
+ }
+ 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 = array_values($value);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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_plus_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;
+ }
+
+ /**
+ * Finds all hierarchical fields for the current index.
+ *
+ * @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;
+ }
+ try {
+ $v = $wrapper->value(array('identifier' => TRUE));
+ if ($v && !isset($values[$v])) {
+ $values[$v] = $v;
+ if (isset($wrapper->$property) && $wrapper->value() && $wrapper->$property->value()) {
+ $this->extractHierarchy($wrapper->$property, $property, $values);
+ }
+ }
+ }
+ catch (EntityMetadataWrapperException $e) {
+ // Some properties like entity_metadata_book_get_properties() throw
+ // exceptions, so we catch them here and ignore the property.
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_add_url.inc b/www/modules/contrib/search_api/includes/callback_add_url.inc
new file mode 100644
index 000000000..5fba922af
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_add_url.inc
@@ -0,0 +1,40 @@
+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/www/modules/contrib/search_api/includes/callback_add_viewed_entity.inc b/www/modules/contrib/search_api/includes/callback_add_viewed_entity.inc
new file mode 100644
index 000000000..2f662600c
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_add_viewed_entity.inc
@@ -0,0 +1,165 @@
+getEntityType();
+ }
+
+ /**
+ *
+ */
+ public function configurationForm() {
+ $view_modes = array();
+ if ($entity_type = $this->index->getEntityType()) {
+ $info = entity_get_info($entity_type);
+ foreach ($info['view modes'] as $key => $mode) {
+ $view_modes[$key] = $mode['label'];
+ }
+ }
+ $this->options += array(
+ 'mode' => reset($view_modes),
+ // Backward compatible definition - if this is an existing config the
+ // language processing is disabled by default.
+ 'global_language_switch' => !isset($this->options['mode']),
+ );
+ 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'])) . '
',
+ );
+ }
+ }
+ $form['global_language_switch'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Adjust environment language when indexing'),
+ '#description' => t('If enabled, the indexing process will not just set the language for the entity view but also the global environment. This can prevent wrong translations leaking into the indexed data on multi-lingual sites, but causes problems in rare cases. Unless you notice any problems in connection with this, the recommended setting is enabled.'),
+ '#default_value' => !empty($this->options['global_language_switch']),
+ );
+ return $form;
+ }
+
+ /**
+ *
+ */
+ public function alterItems(array &$items) {
+ // Prevent session information from being saved while indexing.
+ backdrop_save_session(FALSE);
+
+ // Language handling.
+ $languages = language_list();
+ $global_language = array(
+ 'language' => $GLOBALS['language'],
+ 'language_url' => $GLOBALS['language_url'],
+ 'language_content' => $GLOBALS['language_content'],
+ );
+
+ // Force the current user to anonymous to prevent access bypass in search
+ // indexes.
+ $original_user = $GLOBALS['user'];
+ $GLOBALS['user'] = backdrop_anonymous_user();
+
+ // Switch to the default theme for rendering if it's not the current one.
+ $old_theme = _search_api_swap_theme(config_get('system.core', 'theme_default'));
+
+ $type = $this->index->getEntityType();
+ $mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
+ foreach ($items as &$item) {
+ // Since we can't really know what happens in entity_view() and render(),
+ // we use try/catch. This will at least prevent some errors, even though
+ // it's no protection against fatal errors and the like.
+ try {
+ // Check if the global language switch is enabled.
+ if (!empty($this->options['global_language_switch'])) {
+ // Language handling. We need to overwrite the global language
+ // configuration because parts of entity rendering won't rely on the
+ // passed in language (for instance, URL aliases).
+ if (isset($languages[$item->search_api_language])) {
+ $GLOBALS['language'] = $languages[$item->search_api_language];
+ $GLOBALS['language_url'] = $languages[$item->search_api_language];
+ $GLOBALS['language_content'] = $languages[$item->search_api_language];
+ }
+ else {
+ $GLOBALS['language'] = $global_language['language'];
+ $GLOBALS['language_url'] = $global_language['language_url'];
+ $GLOBALS['language_content'] = $global_language['language_content'];
+ }
+ }
+
+ $render = entity_plus_view($type, array($item->id() => $item), $mode, $item->search_api_language);
+ $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 global language settings.
+ if (!empty($this->options['global_language_switch'])) {
+ $GLOBALS['language'] = $global_language['language'];
+ $GLOBALS['language_url'] = $global_language['language_url'];
+ $GLOBALS['language_content'] = $global_language['language_content'];
+ }
+
+ // Switch back to the previous theme, if necessary.
+ if ($old_theme) {
+ _search_api_swap_theme($old_theme);
+ }
+
+ // Restore the user.
+ $GLOBALS['user'] = $original_user;
+ backdrop_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/www/modules/contrib/search_api/includes/callback_bundle_filter.inc b/www/modules/contrib/search_api/includes/callback_bundle_filter.inc
new file mode 100644
index 000000000..cde8fe27d
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_bundle_filter.inc
@@ -0,0 +1,135 @@
+isMultiEntityIndex($index)) {
+ $info = entity_get_info();
+ foreach ($index->options['datasource']['types'] as $type) {
+ if (isset($info[$type]) && self::hasBundles($info[$type])) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
+ return $index->getEntityType() && ($info = entity_get_info($index->getEntityType())) && self::hasBundles($info);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ if (!$this->supportsIndex($this->index) || !isset($this->options['bundles'])) {
+ return;
+ }
+
+ $multi_entity = $this->isMultiEntityIndex();
+ if ($multi_entity) {
+ $bundle_prop = 'item_bundle';
+ }
+ else {
+ $info = entity_get_info($this->index->getEntityType());
+ $bundle_prop = $info['entity keys']['bundle'];
+ }
+
+ $bundles = array_flip($this->options['bundles']);
+ $default = (bool) $this->options['default'];
+
+ foreach ($items as $id => $item) {
+ // Ignore types that have no bundles.
+ if ($multi_entity && !self::hasBundles(entity_get_info($item->item_type))) {
+ continue;
+ }
+ if (isset($bundles[$item->$bundle_prop]) == $default) {
+ unset($items[$id]);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm() {
+ if ($this->supportsIndex($this->index)) {
+ $options = array();
+ if ($this->isMultiEntityIndex()) {
+ $info = entity_get_info();
+ $unsupported_types = array();
+ foreach ($this->index->options['datasource']['types'] as $type) {
+ if (isset($info[$type]) && self::hasBundles($info[$type])) {
+ foreach ($info[$type]['bundles'] as $bundle => $bundle_info) {
+ $options["$type:$bundle"] = $info[$type]['label'] . ' » ' . $bundle_info['label'];
+ }
+ }
+ else {
+ $unsupported_types[] = isset($info[$type]['label']) ? $info[$type]['label'] : $type;
+ }
+ }
+ if ($unsupported_types) {
+ $form['unsupported_types']['#markup'] = '' . t('The following entity types do not contain any bundles: @types. All items of those types will therefore be included in the index.', array('@types' => implode(', ', $unsupported_types))) . '
';
+ }
+ }
+ else {
+ $info = entity_get_info($this->index->getEntityType());
+ foreach ($info['bundles'] as $bundle => $bundle_info) {
+ $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+ }
+ }
+ if (!empty($this->index->options['datasource']['bundles'])) {
+ $form['message']['#markup'] = '' . t("Note: This index is already restricted to certain bundles. If you use this data alteration, those will be reduced further. However, the index setting is better supported in the user interface and should therefore be prefered. For example, using this data alteration will not reduce the displayed total number of items to index (even though some of them will not be indexed). Consider creating a new index with appropriate bundle settings instead.") . '
';
+ $included_bundles = array_flip($this->index->options['datasource']['bundles']);
+ $options = array_intersect_key($options, $included_bundles);
+ }
+ $form['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'),
+ ),
+ );
+ $form['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;
+ }
+
+ /**
+ * Determines whether a certain entity type has any bundles.
+ *
+ * @param array $entity_info
+ * The entity type's entity_get_info() array.
+ *
+ * @return bool
+ * TRUE if the entity type has bundles, FALSE otherwise.
+ */
+ protected static function hasBundles(array $entity_info) {
+ return !empty($entity_info['entity keys']['bundle']) && !empty($entity_info['bundles']);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_comment_access.inc b/www/modules/contrib/search_api/includes/callback_comment_access.inc
new file mode 100644
index 000000000..e6273530e
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_comment_access.inc
@@ -0,0 +1,46 @@
+getEntityType() === 'comment';
+ }
+
+ /**
+ * Overrides SearchApiAlterNodeAccess::getNode().
+ *
+ * Returns the comment's node, instead of the item (i.e., the comment) itself.
+ */
+ protected function getNode($item) {
+ return node_load($item->nid);
+ }
+
+ /**
+ * Overrides SearchApiAlterNodeAccess::configurationFormSubmit().
+ *
+ * Doesn't index the comment's "Author".
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ $old_status = !empty($form_state['index']->options['data_alter_callbacks']['search_api_alter_comment_access']['status']);
+ $new_status = !empty($form_state['values']['callbacks']['search_api_alter_comment_access']['status']);
+
+ if (!$old_status && $new_status) {
+ $form_state['index']->options['fields']['status']['type'] = 'boolean';
+ }
+
+ return parent::configurationFormSubmit($form, $values, $form_state);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_file_entity_public.inc b/www/modules/contrib/search_api/includes/callback_file_entity_public.inc
new file mode 100644
index 000000000..91ff546fc
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_file_entity_public.inc
@@ -0,0 +1,42 @@
+isMultiEntityIndex($index)) {
+ return in_array('file', $index->options['datasource']['types']);
+ }
+ return $index->getEntityType() === 'file';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ $multi_types = $this->isMultiEntityIndex($this->index);
+ foreach ($items as $id => $item) {
+ $file = $item;
+ if ($multi_types) {
+ if ($item->item_type !== 'file') {
+ continue;
+ }
+ $file = $item->file;
+ }
+ if (empty($file->uri) || substr($file->uri, 0, 10) === 'private://') {
+ unset($items[$id]);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_language_control.inc b/www/modules/contrib/search_api/includes/callback_language_control.inc
new file mode 100644
index 000000000..6933b8dbe
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_language_control.inc
@@ -0,0 +1,126 @@
+ '',
+ 'languages' => array(),
+ );
+ parent::__construct($index, $options);
+ }
+
+ /**
+ * Overrides SearchApiAbstractAlterCallback::supportsIndex().
+ *
+ * Only returns TRUE if the system is multilingual.
+ *
+ * @see language_multilingual()
+ */
+ public function supportsIndex(SearchApiIndex $index) {
+ return language_multilingual();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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] = check_plain(($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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ $values['languages'] = array_filter($values['languages']);
+ return parent::configurationFormSubmit($form, $values, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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/www/modules/contrib/search_api/includes/callback_node_access.inc b/www/modules/contrib/search_api/includes/callback_node_access.inc
new file mode 100644
index 000000000..2ed3240c8
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_node_access.inc
@@ -0,0 +1,98 @@
+getEntityType() === 'node';
+ }
+
+ /**
+ * Overrides SearchApiAbstractAlterCallback::propertyInfo().
+ *
+ * Adds the "search_api_access_node" property.
+ */
+ 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',
+ ),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ static $account;
+
+ if (!isset($account)) {
+ // Load the anonymous user.
+ $account = backdrop_anonymous_user();
+ }
+
+ foreach ($items as $id => $item) {
+ $node = $this->getNode($item);
+ // Check whether all users have access to the node.
+ if (!node_access('view', $node, $account)) {
+ // Get node access grants.
+ $result = db_query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $node->nid));
+
+ // Store all grants together with their realms in the item.
+ foreach ($result as $grant) {
+ $items[$id]->search_api_access_node[] = "node_access_{$grant->realm}:{$grant->gid}";
+ }
+ }
+ else {
+ // Add the generic view grant if we are not using node access or the
+ // node is viewable by anonymous users.
+ $items[$id]->search_api_access_node = array('node_access__all');
+ }
+ }
+ }
+
+ /**
+ * Retrieves the node related to a search item.
+ *
+ * In the default implementation for nodes, the item is already the node.
+ * Subclasses may override this to easily provide node access checks for
+ * items related to nodes.
+ */
+ protected function getNode($item) {
+ return $item;
+ }
+
+ /**
+ * Overrides SearchApiAbstractAlterCallback::configurationFormSubmit().
+ *
+ * If the data alteration is being enabled, set "Published" and "Author" to
+ * "indexed", because both are needed for the node access filter.
+ */
+ 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/www/modules/contrib/search_api/includes/callback_node_status.inc b/www/modules/contrib/search_api/includes/callback_node_status.inc
new file mode 100644
index 000000000..0822b18b4
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_node_status.inc
@@ -0,0 +1,56 @@
+isMultiEntityIndex($index)) {
+ return in_array('node', $index->options['datasource']['types']);
+ }
+ return $index->getEntityType() === '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) {
+ $multi_types = $this->isMultiEntityIndex($this->index);
+ foreach ($items as $id => $item) {
+ $node = $item;
+ if ($multi_types) {
+ if ($item->item_type !== 'node') {
+ continue;
+ }
+ $node = $item->node;
+ }
+ if (empty($node->status)) {
+ unset($items[$id]);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_role_filter.inc b/www/modules/contrib/search_api/includes/callback_role_filter.inc
new file mode 100644
index 000000000..68b8d7220
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_role_filter.inc
@@ -0,0 +1,78 @@
+isMultiEntityIndex($index)) {
+ return in_array('user', $index->options['datasource']['types']);
+ }
+ return $index->getEntityType() == 'user';
+ }
+
+ /**
+ * Implements SearchApiAlterCallbackInterface::alterItems().
+ */
+ public function alterItems(array &$items) {
+ $selected_roles = $this->options['roles'];
+ $default = (bool) $this->options['default'];
+ $multi_types = $this->isMultiEntityIndex($this->index);
+ foreach ($items as $id => $item) {
+ if ($multi_types) {
+ if ($item->item_type !== 'user') {
+ continue;
+ }
+ $item_roles = $item->user->roles;
+ }
+ else {
+ $item_roles = $item->roles;
+ }
+ $role_match = (count(array_diff_key($item_roles, $selected_roles)) !== count($item_roles));
+ if ($role_match === $default) {
+ unset($items[$id]);
+ }
+ }
+ }
+
+ /**
+ * Overrides SearchApiAbstractAlterCallback::configurationForm().
+ *
+ * Add option for the roles to include/exclude.
+ */
+ public function configurationForm() {
+ $options = array_map('check_plain', user_roles());
+ $form = array(
+ 'default' => array(
+ '#type' => 'radios',
+ '#title' => t('Which users should be indexed?'),
+ '#default_value' => isset($this->options['default']) ? $this->options['default'] : 1,
+ '#options' => array(
+ 1 => t('All but those from one of the selected roles'),
+ 0 => t('Only those from the selected roles'),
+ ),
+ ),
+ 'roles' => array(
+ '#type' => 'select',
+ '#title' => t('Roles'),
+ '#default_value' => isset($this->options['roles']) ? $this->options['roles'] : array(),
+ '#options' => $options,
+ '#size' => min(4, count($options)),
+ '#multiple' => TRUE,
+ ),
+ );
+ return $form;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_user_content.inc b/www/modules/contrib/search_api/includes/callback_user_content.inc
new file mode 100644
index 000000000..168f3ae1c
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_user_content.inc
@@ -0,0 +1,57 @@
+getEntityType() === 'user';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function propertyInfo() {
+ return array(
+ 'search_api_user_content' => array(
+ 'label' => t('User content'),
+ 'description' => t('The nodes created by this user'),
+ 'type' => 'list',
+ ),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ $uids = array();
+ foreach ($items as $item) {
+ $uids[] = $item->uid;
+ }
+
+ $sql = 'SELECT nid, uid FROM {node} WHERE uid IN (:uids)';
+ $nids = db_query($sql, array(':uids' => $uids));
+ $user_nodes = array();
+ foreach ($nids as $row) {
+ $user_nodes[$row->uid][] = $row->nid;
+ }
+
+ foreach ($items as $item) {
+ $item->search_api_user_content = array();
+ if (!empty($user_nodes[$item->uid])) {
+ $item->search_api_user_content = $user_nodes[$item->uid];
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/callback_user_status.inc b/www/modules/contrib/search_api/includes/callback_user_status.inc
new file mode 100644
index 000000000..06c1bb996
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/callback_user_status.inc
@@ -0,0 +1,42 @@
+isMultiEntityIndex($index)) {
+ return in_array('user', $index->options['datasource']['types']);
+ }
+ return $index->getEntityType() == 'user';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ $multi_types = $this->isMultiEntityIndex($this->index);
+ foreach ($items as $id => $item) {
+ $account = $item;
+ if ($multi_types) {
+ if ($item->item_type !== 'user') {
+ continue;
+ }
+ $account = $item->user;
+ }
+ if (empty($account->status)) {
+ unset($items[$id]);
+ }
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/datasource.inc b/www/modules/contrib/search_api/includes/datasource.inc
new file mode 100644
index 000000000..0f131bbb0
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/datasource.inc
@@ -0,0 +1,878 @@
+") are not allowed.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getIdFieldInfo();
+
+ /**
+ * Loads items of the type of this data source controller.
+ *
+ * @param array $ids
+ * The IDs of the items to load.
+ *
+ * @return array
+ * The loaded items, keyed by ID.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function loadItems(array $ids);
+
+ /**
+ * Creates a metadata wrapper for this datasource controller's type.
+ *
+ * @param mixed $item
+ * Unless NULL, an item of the item type for this controller to be wrapped.
+ * @param array $info
+ * Optionally, additional information that should be used for creating the
+ * 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.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ *
+ * @see entity_metadata_wrapper()
+ */
+ public function getMetadataWrapper($item = NULL, array $info = array());
+
+ /**
+ * Retrieves the unique ID of an item.
+ *
+ * @param mixed $item
+ * An item of this controller's type.
+ *
+ * @return mixed
+ * Either the unique ID of the item, or NULL if none is available.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getItemId($item);
+
+ /**
+ * Retrieves a human-readable label for an item.
+ *
+ * @param mixed $item
+ * An item of this controller's type.
+ *
+ * @return string|null
+ * Either a human-readable label for the item, or NULL if none is available.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getItemLabel($item);
+
+ /**
+ * Retrieves a URL at which the item can be viewed on the web.
+ *
+ * @param mixed $item
+ * An item of this controller's type.
+ *
+ * @return array|null
+ * Either an array containing the 'path' and 'options' keys used to build
+ * the URL of the item, and matching the signature of url(), or NULL if the
+ * item has no URL of its own.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getItemUrl($item);
+
+ /**
+ * Initializes tracking of the index status of items for the given indexes.
+ *
+ * All currently known items of this data source's type should be inserted
+ * into the tracking table for the given indexes, with status "changed". If
+ * items were already present, these should also be set to "changed" and not
+ * be inserted again.
+ *
+ * @param SearchApiIndex[] $indexes
+ * The SearchApiIndex objects for which item tracking should be initialized.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function startTracking(array $indexes);
+
+ /**
+ * Stops tracking of the index status of items for the given indexes.
+ *
+ * The tracking tables of the given indexes should be completely cleared.
+ *
+ * @param SearchApiIndex[] $indexes
+ * The SearchApiIndex objects for which item tracking should be stopped.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function stopTracking(array $indexes);
+
+ /**
+ * Starts tracking the index status for the given items on the given indexes.
+ *
+ * @param array $item_ids
+ * The IDs of new items to track.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which items should be tracked.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were added; or NULL if items were added
+ * for all of them.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemInsert(array $item_ids, array $indexes);
+
+ /**
+ * Sets the tracking status of the given items to "changed"/"dirty".
+ *
+ * Unless $dequeue is set to TRUE, this operation is ignored for items whose
+ * status is not "indexed".
+ *
+ * @param array|false $item_ids
+ * Either an array with the IDs of the changed items. Or FALSE to mark all
+ * items as changed for the given indexes.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which the change should be tracked.
+ * @param bool $dequeue
+ * (deprecated) If set to TRUE, also change the status of queued items.
+ * The concept of queued items will be removed in the Backdrop 8 version of
+ * this module.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were updated; or NULL if items were
+ * updated for all of them.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE);
+
+ /**
+ * Sets the tracking status of the given items to "queued".
+ *
+ * Queued items are not marked as "dirty" even when they are changed, and they
+ * are not returned by the getChangedItems() method.
+ *
+ * @param array|false $item_ids
+ * Either an array with the IDs of the queued items. Or FALSE to mark all
+ * items as queued for the given indexes.
+ * @param SearchApiIndex $index
+ * The index for which the items were queued.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ *
+ * @deprecated
+ * As of Search API 1.10, the cron queue is not used for indexing anymore,
+ * therefore this method has become useless. It will be removed in the
+ * Backdrop 8 version of this module.
+ */
+ public function trackItemQueued($item_ids, SearchApiIndex $index);
+
+ /**
+ * Sets the tracking status of the given items to "indexed".
+ *
+ * @param array $item_ids
+ * The IDs of the indexed items.
+ * @param SearchApiIndex $index
+ * The index on which the items were indexed.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemIndexed(array $item_ids, SearchApiIndex $index);
+
+ /**
+ * Stops tracking the index status for the given items on the given indexes.
+ *
+ * @param array $item_ids
+ * The IDs of the removed items.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which the deletions should be tracked.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were deleted; or NULL if items were
+ * deleted for all of them.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemDelete(array $item_ids, array $indexes);
+
+ /**
+ * Retrieves a list of items that need to be indexed.
+ *
+ * If possible, completely unindexed items should be returned before items
+ * that were indexed but later changed. Also, items that were changed longer
+ * ago should be favored.
+ *
+ * @param SearchApiIndex $index
+ * The index for which changed items should be returned.
+ * @param int $limit
+ * The maximum number of items to return. Negative values mean "unlimited".
+ *
+ * @return array
+ * The IDs of items that need to be indexed for the given index.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getChangedItems(SearchApiIndex $index, $limit = -1);
+
+ /**
+ * Retrieves 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 any error state was encountered.
+ */
+ public function getIndexStatus(SearchApiIndex $index);
+
+ /**
+ * Retrieves the entity type of items from this datasource.
+ *
+ * @return string|null
+ * An entity type string if the items provided by this datasource are
+ * entities; NULL otherwise.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function getEntityType();
+
+ /**
+ * Form constructor for configuring the datasource for a given index.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $form_state
+ * The form state. $form_state['index'] will contain the edited index. If
+ * this key is empty, then a new index is being created. In case of an edit,
+ * $form_state['index']->options['datasource'] contains the previous
+ * settings for the datasource.
+ *
+ * @return array|false
+ * A form array for configuring this callback, or FALSE if no configuration
+ * is possible.
+ */
+ public function configurationForm(array $form, array &$form_state);
+
+ /**
+ * Validation callback for the form returned by configurationForm().
+ *
+ * This method will only be called if that form was non-empty.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $values
+ * The part of the $form_state['values'] array corresponding to this form.
+ * @param array $form_state
+ * The complete form state.
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state);
+
+ /**
+ * Submit callback for the form returned by configurationForm().
+ *
+ * This method will only be called if that form was non-empty.
+ *
+ * Any necessary changes to the submitted values should be made, afterwards
+ * they will automatically be stored as the index's "datasource" options. The
+ * method can also be used by the datasource controller to react to the
+ * possible change in its settings.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $values
+ * The part of the $form_state['values'] array corresponding to this form.
+ * @param array $form_state
+ * The complete form state.
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state);
+
+ /**
+ * Returns a summary of an index's current datasource configuration.
+ *
+ * @param SearchApiIndex $index
+ * The index whose datasource configuration should be summarized.
+ *
+ * @return string|null
+ * A translated string describing the index's current datasource
+ * configuration. Or NULL, if there is no configuration (or no description
+ * is available).
+ */
+ public function getConfigurationSummary(SearchApiIndex $index);
+
+}
+
+/**
+ * Provides a default base class for datasource controllers.
+ *
+ * Contains default implementations for a number of methods which will be
+ * similar for most data sources. Concrete data sources can decide to extend
+ * 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 entity type for this controller instance.
+ *
+ * @var string|null
+ *
+ * @see getEntityType()
+ */
+ protected $entityType = NULL;
+
+ /**
+ * The info array for the item type, as specified via
+ * hook_search_api_item_type_info().
+ *
+ * @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';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct($type) {
+ $this->type = $type;
+ $this->info = search_api_get_item_type_info($type);
+
+ if (!empty($this->info['entity_type'])) {
+ $this->entityType = $this->info['entity_type'];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getEntityType() {
+ return $this->entityType;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMetadataWrapper($item = NULL, array $info = array()) {
+ $info += $this->getPropertyInfo();
+ return entity_metadata_wrapper($this->entityType ? $this->entityType : $this->type, $item, $info);
+ }
+
+ /**
+ * Retrieves the property info for this item type.
+ *
+ * This is a helper method for getMetadataWrapper() that can be used by
+ * subclasses to specify the property information to use when creating a
+ * metadata wrapper.
+ *
+ * The data structure uses largely the format specified in
+ * hook_entity_property_info(). However, the first level of keys (containing
+ * the entity types) is omitted, and the "properties" key is called
+ * "property info" instead. So, an example return value would look like this:
+ *
+ * @code
+ * return array(
+ * 'property info' => array(
+ * 'foo' => array(
+ * 'label' => t('Foo'),
+ * 'type' => 'text',
+ * ),
+ * 'bar' => array(
+ * 'label' => t('Bar'),
+ * 'type' => 'list',
+ * ),
+ * ),
+ * );
+ * @endcode
+ *
+ * SearchApiExternalDataSourceController::getPropertyInfo() contains a working
+ * example of this method.
+ *
+ * If the item type is an entity type, no additional property information is
+ * required, the method will thus just return an empty array. You can still
+ * use this to append additional properties to the entities, or the like,
+ * though.
+ *
+ * @return array
+ * Property information as specified by entity_metadata_wrapper().
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ *
+ * @see getMetadataWrapper()
+ * @see hook_entity_property_info()
+ */
+ protected function getPropertyInfo() {
+ // If this is an entity type, no additional property info is needed.
+ if ($this->entityType) {
+ return array();
+ }
+ throw new SearchApiDataSourceException(t('No known property information for type @type.', array('@type' => $this->type)));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemLabel($item) {
+ $label = $this->getMetadataWrapper($item)->label();
+ return $label ? $label : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemUrl($item) {
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function startTracking(array $indexes) {
+ if (!$this->table) {
+ return;
+ }
+ // We first clear the tracking table for all indexes, so we can just insert
+ // all items again without any key conflicts.
+ $this->stopTracking($indexes);
+ // Insert all items as new.
+ $this->trackItemInsert($this->getAllItemIds(), $indexes);
+ }
+
+ /**
+ * Returns the IDs of all items that are known for this controller's type.
+ *
+ * Helper method that can be used by subclasses instead of implementing
+ * startTracking().
+ *
+ * @return array
+ * An array containing all item IDs for this type.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ protected function getAllItemIds() {
+ throw new SearchApiDataSourceException(t('Items not known for type @type.', array('@type' => $this->type)));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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);
+ db_delete($this->table)
+ ->condition($this->indexIdColumn, $index->id)
+ ->execute();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemInsert(array $item_ids, array $indexes) {
+ if (!$this->table || $item_ids === array()) {
+ return;
+ }
+
+ foreach ($indexes as $index) {
+ $this->checkIndex($index);
+ }
+
+ // Since large amounts of items can overstrain the database, only add items
+ // in chunks.
+ foreach (array_chunk($item_ids, 1000) as $chunk) {
+ $insert = db_insert($this->table)
+ ->fields(array($this->itemIdColumn, $this->indexIdColumn, $this->changedColumn));
+
+ foreach ($indexes as $index) {
+ // We have to make sure we don't try to insert duplicate items.
+ $select = db_select($this->table, 't');
+ $select->addField('t', $this->itemIdColumn);
+ $select->condition($this->indexIdColumn, $index->id);
+ $select->condition($this->itemIdColumn, $chunk, 'IN');
+ $existing = $select
+ ->execute()
+ ->fetchCol();
+ $existing = array_flip($existing);
+
+ foreach ($chunk as $item_id) {
+ if (isset($existing[$item_id])) {
+ continue;
+ }
+ $insert->values(array(
+ $this->itemIdColumn => $item_id,
+ $this->indexIdColumn => $index->id,
+ $this->changedColumn => 1,
+ ));
+ }
+ }
+ try {
+ $insert->execute();
+ }
+ catch (Exception $e) {
+ $tmp = array_slice($item_ids, 0, 10);
+ $item_ids_string = '"' . implode('", "', $tmp) . '"';
+ $index_names = array();
+ foreach ($indexes as $index) {
+ $index_names[] = $index->name;
+ }
+ $vars = array(
+ '%indexes' => implode(', ', $index_names),
+ '@item_ids' => $item_ids_string,
+ );
+ watchdog_exception('search_api', $e, '%type while tracking item inserts (IDs: @item_ids) on index(es) %indexes: !message in %function (line %line of %file).', $vars);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) {
+ if (!$this->table || $item_ids === array()) {
+ return NULL;
+ }
+
+ $indexes_by_id = array();
+ foreach ($indexes as $index) {
+ $this->checkIndex($index);
+ $update = db_update($this->table)
+ ->fields(array(
+ $this->changedColumn => REQUEST_TIME,
+ ))
+ ->condition($this->indexIdColumn, $index->id)
+ ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+ if ($item_ids !== FALSE) {
+ $update->condition($this->itemIdColumn, $item_ids, 'IN');
+ }
+ try {
+ $update->execute();
+ $indexes_by_id[$index->id] = $index;
+ }
+ catch (Exception $e) {
+ if ($item_ids === FALSE) {
+ $item_ids_string = t('all');
+ }
+ else {
+ $tmp = array_slice($item_ids, 0, 10);
+ $item_ids_string = '"' . implode('", "', $tmp) . '"';
+ }
+ $vars = array(
+ '%index' => $index->name,
+ '@item_ids' => $item_ids_string,
+ );
+ watchdog_exception('search_api', $e, '%type while tracking item updates (IDs: @item_ids) on index %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+
+ // Determine and return the indexes with any changed items. If $item_ids is
+ // FALSE, all items are marked as changed and, thus, all indexes will be
+ // affected (unless they don't have any items, but no real point in treating
+ // that special case).
+ if ($item_ids !== FALSE) {
+ $indexes_with_items = db_select($this->table, 't')
+ ->fields('t', array($this->indexIdColumn))
+ ->distinct()
+ ->condition($this->indexIdColumn, array_keys($indexes_by_id), 'IN')
+ ->condition($this->itemIdColumn, $item_ids, 'IN')
+ ->execute()
+ ->fetchCol();
+ return array_intersect_key($indexes_by_id, array_flip($indexes_with_items));
+ }
+
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemQueued($item_ids, SearchApiIndex $index) {
+ $this->checkIndex($index);
+ if (!$this->table || $item_ids === array()) {
+ 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();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
+ if (!$this->table || $item_ids === array()) {
+ return;
+ }
+ $this->checkIndex($index);
+ try {
+ db_update($this->table)
+ ->fields(array(
+ $this->changedColumn => 0,
+ ))
+ ->condition($this->itemIdColumn, $item_ids, 'IN')
+ ->condition($this->indexIdColumn, $index->id)
+ ->execute();
+ }
+ catch (Exception $e) {
+ $tmp = array_slice($item_ids, 0, 10);
+ $item_ids_string = '"' . implode('", "', $tmp) . '"';
+ $vars = array(
+ '%index' => $index->name,
+ '@item_ids' => $item_ids_string,
+ );
+ watchdog_exception('search_api', $e, '%type while tracking indexed items (IDs: @item_ids) on index %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemDelete(array $item_ids, array $indexes) {
+ if (!$this->table || $item_ids === array()) {
+ return NULL;
+ }
+
+ $ret = array();
+
+ foreach ($indexes as $index) {
+ $this->checkIndex($index);
+ $delete = db_delete($this->table)
+ ->condition($this->indexIdColumn, $index->id)
+ ->condition($this->itemIdColumn, $item_ids, 'IN');
+ try {
+ if ($delete->execute()) {
+ $ret[] = $index;
+ }
+ }
+ catch (Exception $e) {
+ $tmp = array_slice($item_ids, 0, 10);
+ $item_ids_string = '"' . implode('", "', $tmp) . '"';
+ $vars = array(
+ '%index' => $index->name,
+ '@item_ids' => $item_ids_string,
+ );
+ watchdog_exception('search_api', $e, '%type while tracking deleted items (IDs: @item_ids) on index %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getChangedItems(SearchApiIndex $index, $limit = -1) {
+ if ($limit == 0) {
+ return array();
+ }
+ $this->checkIndex($index);
+ $select = db_select($this->table, 't');
+ $select->addField('t', $this->itemIdColumn);
+ $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();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ return NULL;
+ }
+
+ /**
+ * Checks whether the given index is valid for this datasource controller.
+ *
+ * Helper method used by various methods in this class. By default only checks
+ * whether the types match.
+ *
+ * @param SearchApiIndex $index
+ * The index to check.
+ *
+ * @throws SearchApiDataSourceException
+ * If the index doesn't fit to this datasource controller.
+ */
+ protected function checkIndex(SearchApiIndex $index) {
+ if ($index->item_type != $this->type) {
+ $index_type = search_api_get_item_type_info($index->item_type);
+ $index_type = empty($index_type['name']) ? $index->item_type : $index_type['name'];
+ $msg = t(
+ 'Invalid index @index of type @index_type passed to data source controller for type @this_type.',
+ array('@index' => $index->name, '@index_type' => $index_type, '@this_type' => $this->info['name'])
+ );
+ throw new SearchApiDataSourceException($msg);
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/datasource_entity.inc b/www/modules/contrib/search_api/includes/datasource_entity.inc
new file mode 100644
index 000000000..32a84ecd1
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/datasource_entity.inc
@@ -0,0 +1,364 @@
+entityInfo = entity_get_info($this->entityType);
+ if (!empty($this->entityInfo['entity keys']['id'])) {
+ $this->idKey = $this->entityInfo['entity keys']['id'];
+ }
+ if (!empty($this->entityInfo['entity keys']['bundle'])) {
+ $this->bundleKey = $this->entityInfo['entity keys']['bundle'];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getIdFieldInfo() {
+ $properties = entity_plus_get_property_info($this->entityType);
+ if (!$this->idKey) {
+ throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $this->entityInfo['label'])));
+ }
+ if (empty($properties['properties'][$this->idKey]['type'])) {
+ throw new SearchApiDataSourceException(t("Entity type @type doesn't specify a type for the @prop property.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
+ }
+ $type = $properties['properties'][$this->idKey]['type'];
+ if (search_api_is_list_type($type)) {
+ throw new SearchApiDataSourceException(t("Entity type @type uses list field @prop as its ID.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
+ }
+ if ($type == 'token') {
+ $type = 'string';
+ }
+ return array(
+ 'key' => $this->idKey,
+ 'type' => $type,
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function loadItems(array $ids) {
+ $items = entity_load_multiple($this->entityType, $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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMetadataWrapper($item = NULL, array $info = array()) {
+ return entity_metadata_wrapper($this->entityType, $item, $info);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemId($item) {
+ return $item->id();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemLabel($item) {
+ $label = entity_label($this->entityType, $item);
+ return $label ? $label : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemUrl($item) {
+ if ($this->entityType == 'file') {
+ return array(
+ 'path' => file_create_url($item->uri),
+ 'options' => array(
+ 'entity_type' => 'file',
+ 'entity' => $item,
+ ),
+ );
+ }
+ $url = entity_uri($this->entityType, $item);
+ return $url ? $url : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function startTracking(array $indexes) {
+ if (!$this->table) {
+ return;
+ }
+ // We first clear the tracking table for all indexes, so we can just insert
+ // all items again without any key conflicts.
+ $this->stopTracking($indexes);
+
+ if (!empty($this->entityInfo['base table']) && $this->idKey) {
+ // Use a subselect, which will probably be much faster than entity_load_multiple().
+ // Assumes that all entities use the "base table" property and the
+ // "entity keys[id]" in the same way as the default controller.
+ $table = $this->entityInfo['base table'];
+
+ // We could also use a single insert (with a UNION in the nested query),
+ // but this method will be mostly called with a single index, anyways.
+ foreach ($indexes as $index) {
+ // Select all entity ids.
+ $query = db_select($table, 't');
+ $query->addField('t', $this->idKey, 'item_id');
+ $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+ $query->addExpression('1', 'changed');
+ if ($bundles = $this->getIndexBundles($index)) {
+ $bundle_column = $this->bundleKey;
+ if (!db_field_exists($table, $bundle_column)) {
+ if ($this->entityType == 'flagging') {
+ $bundle_column = 'fid';
+ $bundles = db_query('SELECT fid FROM {flag} WHERE name IN (:bundles)', array(':bundles' => $bundles))->fetchCol();
+ }
+ elseif ($this->entityType == 'comment') {
+ // Comments are significantly more complicated, since they don't
+ // store their bundle explicitly in their database table. Instead,
+ // we need to get all the nodes from the enabled types and filter
+ // by those.
+ $bundle_column = 'nid';
+ $node_types = array();
+ foreach ($bundles as $bundle) {
+ if (substr($bundle, 0, 13) === 'comment_node_') {
+ $node_types[] = substr($bundle, 13);
+ }
+ }
+ if ($node_types) {
+ $bundles = db_query('SELECT nid FROM {node} WHERE type IN (:bundles)', array(':bundles' => $node_types))->fetchCol();
+ }
+ else {
+ continue;
+ }
+ }
+ else {
+ $this->startTrackingFallback(array($index->machine_name => $index));
+ continue;
+ }
+ }
+ if ($bundles) {
+ $query->condition($bundle_column, $bundles);
+ }
+ }
+
+ // INSERT ... SELECT ...
+ db_insert($this->table)
+ ->from($query)
+ ->execute();
+ }
+ }
+ else {
+ $this->startTrackingFallback($indexes);
+ }
+ }
+
+ /**
+ * Initializes tracking of the index status of items for the given indexes.
+ *
+ * Fallback for when the items cannot directly be loaded into
+ * {search_api_item} via "INSERT INTO … SELECT …".
+ *
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which item tracking should be initialized.
+ *
+ * @throws SearchApiDataSourceException
+ * Thrown if any error state was encountered.
+ *
+ * @see SearchApiEntityDataSourceController::startTracking()
+ */
+ protected function startTrackingFallback(array $indexes) {
+ // In the absence of a 'base table', use the slower way of retrieving the
+ // items and inserting them "manually". For each index we get the item IDs
+ // (since selected bundles might differ) and insert all of them as new.
+ foreach ($indexes as $index) {
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $this->entityType);
+ if ($bundles = $this->getIndexBundles($index)) {
+ $query->entityCondition('bundle', $bundles);
+ }
+ $result = $query->execute();
+ $ids = !empty($result[$this->entityType]) ? array_keys($result[$this->entityType]) : array();
+ if ($ids) {
+ $this->trackItemInsert($ids, array($index));
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemInsert(array $item_ids, array $indexes) {
+ $ret = array();
+
+ foreach ($indexes as $index_id => $index) {
+ $ids = $item_ids;
+ if ($bundles = $this->getIndexBundles($index)) {
+ $ids = backdrop_map_assoc($ids);
+ foreach (entity_load_multiple($this->entityType, $ids) as $id => $entity) {
+ if (empty($bundles[$entity->{$this->bundleKey}])) {
+ unset($ids[$id]);
+ }
+ }
+ }
+ if ($ids) {
+ parent::trackItemInsert($ids, array($index));
+ $ret[$index_id] = $index;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ $options = $this->getAvailableBundles();
+ if (!$options) {
+ return FALSE;
+ }
+ $form['bundles'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Bundles'),
+ '#description' => t('Restrict the entity bundles that will be included in this index. Leave blank to include all bundles. This setting cannot be changed for enabled indexes.'),
+ '#options' => array_map('check_plain', $options),
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ '#disabled' => !empty($form_state['index']) && $form_state['index']->enabled,
+ );
+ if (!empty($form_state['index']->options['datasource'])) {
+ $form['bundles']['#default_value'] = backdrop_map_assoc($form_state['index']->options['datasource']['bundles']);
+ }
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ if (!empty($values['bundles'])) {
+ $values['bundles'] = array_keys(array_filter($values['bundles']));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ if ($bundles = $this->getIndexBundles($index)) {
+ $args['!bundles'] = implode(', ', array_intersect_key($this->getAvailableBundles(), $bundles));
+ return format_plural(count($bundles), 'Indexed bundle: !bundles.', 'Indexed bundles: !bundles.', $args);
+ }
+ return NULL;
+ }
+
+ /**
+ * Retrieves the available bundles for this entity type.
+ *
+ * @return array
+ * An array (which might be empty) mapping this entity type's bundle keys to
+ * their labels.
+ */
+ protected function getAvailableBundles() {
+ if (!$this->bundleKey || empty($this->entityInfo['bundles'])) {
+ return array();
+ }
+ $bundles = array();
+ foreach ($this->entityInfo['bundles'] as $bundle => $bundle_info) {
+ $bundles[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+ }
+ return $bundles;
+ }
+
+ /**
+ * Computes the bundles that should be indexed for an index.
+ *
+ * @param SearchApiIndex $index
+ * The index for which to check.
+ *
+ * @return array
+ * An array containing all bundles that should be included in this index, as
+ * both the keys and values. An empty array means all current bundles should
+ * be included.
+ *
+ * @throws SearchApiException
+ * If the index doesn't belong to this datasource controller.
+ */
+ protected function getIndexBundles(SearchApiIndex $index) {
+ $this->checkIndex($index);
+
+ if (!isset($this->bundles[$index->machine_name])) {
+ $this->bundles[$index->machine_name] = array();
+ if (!empty($index->options['datasource']['bundles'])) {
+ // We retrieve the available bundles here to check whether all of them
+ // are included by the index's setting. In this case, we return an empty
+ // array, too, to save on complexity.
+ // On the other hand, we still want to return deleted bundles since we
+ // do not want to suddenly include all bundles when all selected bundles
+ // were deleted.
+ $available = $this->getAvailableBundles();
+ foreach ($index->options['datasource']['bundles'] as $bundle) {
+ $this->bundles[$index->machine_name][$bundle] = $bundle;
+ unset($available[$bundle]);
+ }
+ if (!$available) {
+ $this->bundles[$index->machine_name] = array();
+ }
+ }
+ }
+
+ return $this->bundles[$index->machine_name];
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/datasource_external.inc b/www/modules/contrib/search_api/includes/datasource_external.inc
new file mode 100644
index 000000000..8e9e04db9
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/datasource_external.inc
@@ -0,0 +1,259 @@
+") 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 load.
+ *
+ * @return array
+ * The loaded items, keyed by ID.
+ */
+ public function loadItems(array $ids) {
+ return array();
+ }
+
+ /**
+ * Overrides SearchApiAbstractDataSourceController::getPropertyInfo().
+ *
+ * Only returns a single string ID field.
+ */
+ 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/www/modules/contrib/search_api/includes/datasource_multiple.inc b/www/modules/contrib/search_api/includes/datasource_multiple.inc
new file mode 100644
index 000000000..96ba5b01f
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/datasource_multiple.inc
@@ -0,0 +1,360 @@
+ 'item_id',
+ 'type' => 'string',
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function loadItems(array $ids) {
+ $ids_by_type = array();
+ foreach ($ids as $id) {
+ list($type, $entity_id) = explode('/', $id);
+ $ids_by_type[$type][$entity_id] = $id;
+ }
+
+ $items = array();
+ foreach ($ids_by_type as $type => $type_ids) {
+ foreach (entity_load_multiple($type, array_keys($type_ids)) as $entity_id => $entity) {
+ $id = $type_ids[$entity_id];
+ $item = (object) array($type => $entity);
+ $item->item_id = $id;
+ $item->item_type = $type;
+ $item->item_entity_id = $entity_id;
+ $item->item_bundle = NULL;
+ // Add the item language so the "search_api_language" field will work
+ // correctly.
+ $item->language = isset($entity->language) ? $entity->language : NULL;
+ try {
+ list(, , $bundle) = entity_extract_ids($type, $entity);
+ $item->item_bundle = $bundle ? "$type:$bundle" : NULL;
+ }
+ catch (EntityMalformedException $e) {
+ // Will probably make problems at some other place, but for extracting
+ // the bundle it is really not critical enough to fail on – just
+ // ignore this exception.
+ }
+ $items[$id] = $item;
+ unset($type_ids[$entity_id]);
+ }
+ if ($type_ids) {
+ search_api_track_item_delete($type, array_keys($type_ids));
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getPropertyInfo() {
+ $info = array(
+ 'item_id' => array(
+ 'label' => t('ID'),
+ 'description' => t('The combined ID of the item, containing both entity type and entity ID.'),
+ 'type' => 'token',
+ ),
+ 'item_type' => array(
+ 'label' => t('Entity type'),
+ 'description' => t('The entity type of the item.'),
+ 'type' => 'token',
+ 'options list' => 'search_api_entity_type_options_list',
+ ),
+ 'item_entity_id' => array(
+ 'label' => t('Entity ID'),
+ 'description' => t('The entity ID of the item.'),
+ 'type' => 'token',
+ ),
+ 'item_bundle' => array(
+ 'label' => t('Bundle'),
+ 'description' => t('The bundle of the item, if applicable.'),
+ 'type' => 'token',
+ 'options list' => 'search_api_combined_bundle_options_list',
+ ),
+ 'item_label' => array(
+ 'label' => t('Label'),
+ 'description' => t('The label of the item.'),
+ 'type' => 'text',
+ // Since this needs a bit more computation than the others, we don't
+ // include it always when loading the item but use a getter callback.
+ 'getter callback' => 'search_api_get_multi_type_item_label',
+ ),
+ );
+
+ foreach ($this->getSelectedEntityTypeOptions() as $type => $label) {
+ $info[$type] = array(
+ 'label' => $label,
+ 'description' => t('The indexed entity, if it is of type %type.', array('%type' => $label)),
+ 'type' => $type,
+ );
+ }
+
+ return array('property info' => $info);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemId($item) {
+ return isset($item->item_id) ? $item->item_id : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemLabel($item) {
+ return search_api_get_multi_type_item_label($item);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemUrl($item) {
+ if ($item->item_type == 'file') {
+ return array(
+ 'path' => file_create_url($item->file->uri),
+ 'options' => array(
+ 'entity_type' => 'file',
+ 'entity' => $item,
+ ),
+ );
+ }
+ $url = entity_uri($item->item_type, $item->{$item->item_type});
+ return $url ? $url : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function startTracking(array $indexes) {
+ if (!$this->table) {
+ return;
+ }
+ // We first clear the tracking table for all indexes, so we can just insert
+ // all items again without any key conflicts.
+ $this->stopTracking($indexes);
+
+ foreach ($indexes as $index) {
+ $types = $this->getEntityTypes($index);
+
+ // Wherever possible, use a sub-select instead of the much slower
+ // entity_load_multiple().
+ foreach ($types as $type) {
+ $entity_info = entity_get_info($type);
+
+ if (!empty($entity_info['base table'])) {
+ // Assumes that all entities use the "base table" property and the
+ // "entity keys[id]" in the same way as the default controller.
+ $id_field = $entity_info['entity keys']['id'];
+ $table = $entity_info['base table'];
+
+ // Select all entity ids.
+ $query = db_select($table, 't');
+ $query->addExpression("CONCAT(:prefix, t.$id_field)", 'item_id', array(':prefix' => $type . '/'));
+ $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+ $query->addExpression('1', 'changed');
+
+ // INSERT ... SELECT ...
+ db_insert($this->table)
+ ->from($query)
+ ->execute();
+
+ unset($types[$type]);
+ }
+ }
+
+ // In the absence of a "base table", use the slow entity_load_multiple().
+ if ($types) {
+ foreach ($types as $type) {
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $type);
+ $result = $query->execute();
+ $ids = !empty($result[$type]) ? array_keys($result[$type]) : array();
+ if ($ids) {
+ foreach ($ids as $i => $id) {
+ $ids[$i] = $type . '/' . $id;
+ }
+ $this->trackItemInsert($ids, array($index), TRUE);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Starts tracking the index status for the given items on the given indexes.
+ *
+ * @param array $item_ids
+ * The IDs of new items to track.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which items should be tracked.
+ * @param bool $skip_type_check
+ * (optional) If TRUE, don't check whether the type matches the index's
+ * datasource configuration. Internal use only.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were added; or NULL if items were added
+ * for all of them.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemInsert(array $item_ids, array $indexes, $skip_type_check = FALSE) {
+ $ret = array();
+
+ foreach ($indexes as $index_id => $index) {
+ $ids = backdrop_map_assoc($item_ids);
+
+ if (!$skip_type_check) {
+ $types = $this->getEntityTypes($index);
+ foreach ($ids as $id) {
+ list($type) = explode('/', $id);
+ if (!isset($types[$type])) {
+ unset($ids[$id]);
+ }
+ }
+ }
+
+ if ($ids) {
+ parent::trackItemInsert($ids, array($index));
+ $ret[$index_id] = $index;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ $form['types'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Entity types'),
+ '#description' => t('Select the entity types which should be included in this index.'),
+ '#options' => array_map('check_plain', search_api_entity_type_options_list()),
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ '#disabled' => !empty($form_state['index']),
+ '#required' => TRUE,
+ );
+ if (!empty($form_state['index']->options['datasource']['types'])) {
+ $form['types']['#default_value'] = $this->getEntityTypes($form_state['index']);
+ }
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ if (!empty($values['types'])) {
+ $values['types'] = array_keys(array_filter($values['types']));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ if ($type_labels = $this->getSelectedEntityTypeOptions($index)) {
+ $args['!types'] = implode(', ', $type_labels);
+ return format_plural(count($type_labels), 'Indexed entity types: !types.', 'Indexed entity types: !types.', $args);
+ }
+ return NULL;
+ }
+
+ /**
+ * Retrieves the index for which the current method was called.
+ *
+ * Very ugly method which uses the stack trace to find the right object.
+ *
+ * @return SearchApiIndex
+ * The active index.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getCallingIndex() {
+ foreach (debug_backtrace() as $trace) {
+ if (isset($trace['object']) && $trace['object'] instanceof SearchApiIndex) {
+ return $trace['object'];
+ }
+ }
+ // If there's only a single index on the site, it's also easy.
+ $indexes = search_api_index_load_multiple(FALSE);
+ if (count($indexes) === 1) {
+ return reset($indexes);
+ }
+ throw new SearchApiException('Could not determine the active index of the datasource.');
+ }
+
+ /**
+ * Returns the entity types for which this datasource is configured.
+ *
+ * Depends on the index from which this method is (indirectly) called.
+ *
+ * @param SearchApiIndex $index
+ * (optional) The index for which to get the enabled entity types. If not
+ * given, will be determined automatically.
+ *
+ * @return string[]
+ * The machine names of the datasource's enabled entity types, as both keys
+ * and values.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getEntityTypes(SearchApiIndex $index = NULL) {
+ if (!$index) {
+ $index = $this->getCallingIndex();
+ }
+ if (isset($index->options['datasource']['types'])) {
+ return backdrop_map_assoc($index->options['datasource']['types']);
+ }
+ return array();
+ }
+
+ /**
+ * Returns the selected entity type options for this datasource.
+ *
+ * Depends on the index from which this method is (indirectly) called.
+ *
+ * @param SearchApiIndex $index
+ * (optional) The index for which to get the enabled entity types. If not
+ * given, will be determined automatically.
+ *
+ * @return string[]
+ * An associative array, mapping the machine names of the enabled entity
+ * types to their labels.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getSelectedEntityTypeOptions(SearchApiIndex $index = NULL) {
+ return array_intersect_key(search_api_entity_type_options_list(), $this->getEntityTypes($index));
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/exception.inc b/www/modules/contrib/search_api/includes/exception.inc
new file mode 100644
index 000000000..2e4f79035
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/exception.inc
@@ -0,0 +1,34 @@
+fields[$only_indexed][$get_additional].
+ *
+ * @var array
+ */
+ protected $fields = array();
+
+ /**
+ * An array containing two arrays.
+ *
+ * At index 0, all fulltext fields of this index. At index 1, all indexed
+ * fulltext fields of this index.
+ *
+ * @var array
+ */
+ protected $fulltext_fields = array();
+
+ // Database values that will be set when object is loaded.
+
+ /**
+ * An integer identifying the index.
+ * Immutable.
+ *
+ * @var integer
+ */
+ public $id;
+
+ /**
+ * A name to be displayed for the index.
+ *
+ * @var string
+ */
+ public $name;
+
+ /**
+ * The machine name of the index.
+ * Immutable.
+ *
+ * @var string
+ */
+ public $machine_name;
+
+ /**
+ * A string describing the index' use to users.
+ *
+ * @var string
+ */
+ public $description;
+
+ /**
+ * The machine_name of the server with which data should be indexed.
+ *
+ * @var string
+ */
+ public $server;
+
+ /**
+ * The type of items stored in this index.
+ * Immutable.
+ *
+ * @var string
+ */
+ public $item_type;
+
+ /**
+ * An array of options for configuring this index. The layout is as follows
+ * (with all keys being optional):
+ * - cron_limit: The maximum number of items to be indexed per cron batch.
+ * - index_directly: Boolean setting whether entities are indexed immediately
+ * after they are created or updated.
+ * - fields: An array of all indexed fields for this index. Keys are the field
+ * identifiers, the values are arrays for specifying the field settings. The
+ * structure of those arrays looks like this:
+ * - type: The type set for this field. One of the types returned by
+ * search_api_default_field_types().
+ * - real_type: (optional) If a custom data type was selected for this
+ * field, this type will be stored here, and "type" contain the fallback
+ * default data type.
+ * - boost: (optional) A boost value for terms found in this field during
+ * searches. Usually only relevant for fulltext fields. Defaults to 1.0.
+ * - entity_type (optional): If set, the type of this field is really an
+ * entity. The "type" key will then just contain the primitive data type
+ * of the ID field, meaning that servers will ignore this and merely index
+ * the entity's ID. Components displaying this field, though, are advised
+ * to use the entity label instead of the ID.
+ * - additional fields: An associative array with keys and values being the
+ * field identifiers of related entities whose fields should be displayed.
+ * - data_alter_callbacks: An array of all data alterations available. Keys
+ * are the alteration identifiers, the values are arrays containing the
+ * settings for that data alteration. The inner structure looks like this:
+ * - status: Boolean indicating whether the data alteration is enabled.
+ * - weight: Used for sorting the data alterations.
+ * - settings: Alteration-specific settings, configured via the alteration's
+ * configuration form.
+ * - processors: An array of all processors available for the index. The keys
+ * are the processor identifiers, the values are arrays containing the
+ * settings for that processor. The inner structure looks like this:
+ * - status: Boolean indicating whether the processor is enabled.
+ * - weight: Used for sorting the processors.
+ * - settings: Processor-specific settings, configured via the processor's
+ * configuration form.
+ * - datasource: Datasource-specific settings, configured via the datasource's
+ * configuration form.
+ *
+ * @var array
+ */
+ public $options = array();
+
+ /**
+ * A flag indicating whether this index is enabled.
+ *
+ * @var integer
+ */
+ public $enabled = 1;
+
+ /**
+ * A flag indicating whether to write to this index.
+ *
+ * @var integer
+ */
+ public $read_only = 0;
+
+ /**
+ * Status constant for exportable entities
+ */
+ public $status = ENTITY_PLUS_CUSTOM;
+
+ /**
+ * Constructor as a helper to the parent constructor.
+ */
+ public function __construct(array $values = array(), $entity_type = 'search_api_index') {
+ parent::__construct($values, $entity_type);
+ }
+
+ /**
+ * Returns a status for exportable entities
+ */
+ public function hasStatus($status) {
+ if (!empty($this->entityInfo['exportable'])) {
+ return isset($this->{$this->statusKey}) && ($this->{$this->statusKey} & $status) == $status;
+ }
+ }
+
+ /**
+ * Execute necessary tasks for a newly created index.
+ */
+ public function postCreate() {
+ try {
+ if ($server = $this->server()) {
+ // Tell the server about the new index.
+ $server->addIndex($this);
+ if ($this->enabled) {
+ $this->queueItems();
+ }
+ }
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ }
+
+ /**
+ * Execute necessary tasks when the index is removed from the database.
+ */
+ public function postDelete() {
+ try {
+ if ($server = $this->server()) {
+ $server->removeIndex($this);
+ }
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+
+ // Stop tracking entities for indexing.
+ $this->dequeueItems();
+ }
+
+ /**
+ * Record entities to index.
+ */
+ public function queueItems() {
+ if (!$this->read_only) {
+ try {
+ $this->datasource()->startTracking(array($this));
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ }
+ }
+
+ /**
+ * Remove all records of entities to index.
+ */
+ public function dequeueItems() {
+ try {
+ $this->datasource()->stopTracking(array($this));
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ }
+
+ /**
+ * Saves this index to the database.
+ *
+ * Either creates a new record or updates the existing one with the same ID.
+ *
+ * @return int|false
+ * Failure to save the index will return FALSE. Otherwise, SAVED_NEW or
+ * SAVED_UPDATED is returned depending on the operation performed. $this->id
+ * will be set if a new index was inserted.
+ */
+ public function save() {
+ if (empty($this->description)) {
+ $this->description = NULL;
+ }
+ $server = FALSE;
+ if (!empty($this->server)) {
+ $server = search_api_server_load($this->server);
+ if (!$server) {
+ $vars['%server'] = $this->server;
+ $vars['%index'] = $this->name;
+ watchdog('search_api', 'Unknown server %server specified for index %index.', $vars, WATCHDOG_ERROR);
+ }
+ }
+ if (!$server) {
+ $this->server = NULL;
+ $this->enabled = FALSE;
+ }
+ if (!empty($this->options['fields'])) {
+ ksort($this->options['fields']);
+ }
+
+ $this->resetCaches();
+
+ 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 int|false
+ * 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 bool
+ * 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 bool
+ * TRUE on success, FALSE on failure.
+ */
+ public function clear() {
+ if (!$this->server || $this->read_only) {
+ return TRUE;
+ }
+
+ try {
+ $this->server()->deleteItems('all', $this);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+
+ _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 entity type of items in this index.
+ *
+ * @return string|null
+ * An entity type string if the items in this index are entities; NULL
+ * otherwise.
+ */
+ public function getEntityType() {
+ try {
+ return $this->datasource()->getEntityType();
+ }
+ catch (SearchApiException $e) {
+ return NULL;
+ }
+ }
+
+ /**
+ * Get the server this index lies on.
+ *
+ * @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 or its server doesn't exist.
+ *
+ * @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, of this index's item type.
+ *
+ * @return array
+ * An array of the IDs of all items that should be marked as indexed.
+ *
+ * @throws SearchApiException
+ * If an error occurred during indexing.
+ */
+ public function index(array $items) {
+ if ($this->read_only) {
+ 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);
+ backdrop_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_plus_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_plus_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;
+ }
+
+ /**
+ * Loads all enabled data alterations for this index in proper order.
+ *
+ * @return array
+ * All enabled callbacks for this index, as SearchApiAlterCallbackInterface
+ * objects.
+ */
+ public 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;
+ }
+
+ /**
+ * Loads all enabled processors for this index in proper order.
+ *
+ * @return array
+ * All enabled processors for this index, as SearchApiProcessorInterface
+ * objects.
+ */
+ public 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 (optional) $only_indexed
+ * Return only indexed fields, not all known fields. Defaults to TRUE.
+ * @param (optional) $get_additional
+ * 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) {
+ global $language;
+
+ $only_indexed = $only_indexed ? 1 : 0;
+ $get_additional = $get_additional ? 1 : 0;
+
+ // First, try the static cache and the persistent cache bin.
+ if (empty($this->fields[$only_indexed][$get_additional])) {
+ $cid = $this->getCacheId() . "-$only_indexed-$get_additional-{$language->langcode}";
+ $cache = cache_get($cid);
+ if ($cache) {
+ $this->fields[$only_indexed][$get_additional] = $cache->data;
+ }
+ }
+
+ // Otherwise, we have to compute the result.
+ if (empty($this->fields[$only_indexed][$get_additional])) {
+ $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
+ $wrapper = $this->entityWrapper();
+ $additional = array();
+ $entity_types = entity_get_info();
+
+ // First we need all already added prefixes.
+ $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
+ foreach (array_keys($fields) as $key) {
+ $len = strlen($key) + 1;
+ $pos = $len;
+ // The third parameter ($offset) to strrpos has rather weird behaviour,
+ // necessitating this rather awkward code. It will iterate over all
+ // prefixes of each field, beginning with the longest, adding all of them
+ // to $added until one is encountered that was already added (which means
+ // all shorter ones will have already been added, too).
+ while ($pos = strrpos($key, ':', $pos - $len)) {
+ $prefix = substr($key, 0, $pos);
+ if (isset($added[$prefix])) {
+ break;
+ }
+ $added[$prefix] = $prefix;
+ }
+ }
+
+ // 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) {
+ $this->fields[$only_indexed][$get_additional] = $flat;
+ }
+ else {
+ $options = array();
+ $options['fields'] = $flat;
+ $options['additional fields'] = $additional;
+ $this->fields[$only_indexed][$get_additional] = $options;
+ }
+ cache_set($cid, $this->fields[$only_indexed][$get_additional]);
+ }
+
+ return $this->fields[$only_indexed][$get_additional];
+ }
+
+ /**
+ * 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();
+ if ($only_indexed) {
+ $fields = isset($this->options['fields']) ? $this->options['fields'] : array();
+ }
+ else {
+ $fields = $this->getFields(FALSE);
+ }
+ foreach ($fields as $key => $field) {
+ if (search_api_is_text_type($field['type'])) {
+ $this->fulltext_fields[$i][] = $key;
+ }
+ }
+ }
+ return $this->fulltext_fields[$i];
+ }
+
+ /**
+ * Get the cache ID prefix used for this index's caches.
+ *
+ * @param $type
+ * The type of cache. Currently only "fields" is used.
+ *
+ * @return
+ * The cache ID (prefix) for this index's caches.
+ */
+ public function getCacheId($type = 'fields') {
+ return 'search_api:index-' . $this->machine_name . '--' . $type;
+ }
+
+ /**
+ * Helper function for creating an entity metadata wrapper appropriate for
+ * this index.
+ *
+ * @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 (if $alter wasn't set to FALSE).
+ */
+ public function entityWrapper($item = NULL, $alter = TRUE) {
+ try {
+ $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
+ $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
+ return $this->datasource()->getMetadataWrapper($item, $info);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return entity_metadata_wrapper($this->item_type);
+ }
+ }
+
+ /**
+ * 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) {
+ try {
+ return $this->datasource()->loadItems($ids);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return array();
+ }
+ }
+
+ /**
+ * Reset internal caches.
+ *
+ * Should be used when things like fields or data alterations change to avoid
+ * using stale data.
+ */
+ public function resetCaches() {
+ cache_clear_all($this->getCacheId(''), 'cache', TRUE);
+
+ $this->datasource = NULL;
+ $this->server_object = NULL;
+ $this->callbacks = NULL;
+ $this->processors = NULL;
+ $this->added_properties = NULL;
+ $this->fields = array();
+ $this->fulltext_fields = array();
+ }
+
+ /**
+ * Return a label for a signup form.
+ */
+ public function label() {
+ // Return $this->title;.
+ return 'the index entity label';
+ }
+
+ /**
+ * Overrides Entity\Entity::uri().
+ */
+ public function uri() {
+ return array();
+ }
+ /**
+ * Implements EntityInterface::id().
+ */
+ public function id() {
+ return $this->id;
+ }
+
+ /**
+ * Implements EntityInterface::entityType().
+ */
+ public function entityType() {
+ return 'search_api_index';
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/processor.inc b/www/modules/contrib/search_api/includes/processor.inc
new file mode 100644
index 000000000..788fd908c
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor.inc
@@ -0,0 +1,495 @@
+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'][] = backdrop_get_path('module', 'search_api') . '/css/search_api.admin.css';
+
+ $fields = $this->index->getFields();
+ $field_options = array();
+ $default_fields = array();
+ if (isset($this->options['fields'])) {
+ $default_fields = backdrop_map_assoc(array_keys($this->options['fields']));
+ }
+ foreach ($fields as $name => $field) {
+ $field_options[$name] = check_plain($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) {
+ if (isset($values['fields']) && is_array($values['fields'])) {
+ $fields = array_filter($values['fields']);
+ if ($fields) {
+ $fields = array_fill_keys($fields, TRUE);
+ }
+ $values['fields'] = $fields;
+ }
+ else {
+ $values['fields'] = array();
+ }
+ }
+
+ /**
+ *
+ */
+ 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)) {
+ // Don't tokenize non-fulltext content!
+ if (in_array($type, array('text', 'tokens'))) {
+ $type = 'tokens';
+ $value = $this->normalizeTokens($value);
+ }
+ else {
+ $value = $this->implodeTokens($value);
+ }
+ }
+ }
+
+ /**
+ * Internal helper function for normalizing tokens.
+ */
+ protected function normalizeTokens($tokens, $score = 1) {
+ $ret = array();
+ foreach ($tokens as $token) {
+ if (!is_array($token)) {
+ continue;
+ }
+ 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;
+ }
+
+ /**
+ * Internal helper function for imploding tokens into a single string.
+ *
+ * @param array $tokens
+ * The tokens array to implode.
+ *
+ * @return string
+ * The text data from the tokens concatenated into a single string.
+ */
+ protected function implodeTokens(array $tokens) {
+ $ret = array();
+ foreach ($tokens as $token) {
+ if (empty($token['value']) && !is_numeric($token['value'])) {
+ // Filter out empty tokens.
+ continue;
+ }
+ if (is_array($token['value'])) {
+ $ret[] = $this->implodeTokens($token['value']);
+ }
+ else {
+ $ret[] = $token['value'];
+ }
+ }
+ return implode(' ', $ret);
+ }
+
+ /**
+ * Method for preprocessing search keys.
+ */
+ 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 $key => &$f) {
+ if (is_array($f)) {
+ if (isset($fields[$f[0]]) && $this->testField($f[0], $fields[$f[0]])) {
+ // We want to allow processors also to easily remove complete filters.
+ // However, we can't use empty() or the like, as that would sort out
+ // filters for 0 or NULL. So we specifically check only for the empty
+ // string, and we also make sure the filter value was actually changed
+ // by storing whether it was empty before.
+ $empty_string = $f[1] === '';
+ $this->processFilterValue($f[1]);
+
+ if ($f[1] === '' && !$empty_string) {
+ unset($filters[$key]);
+ }
+ }
+ }
+ else {
+ $child_filters = &$f->getFilters();
+ $this->processFilters($child_filters);
+ }
+ }
+ }
+
+ /**
+ * Determines whether to process data from the given field.
+ *
+ * @param $name
+ * The field's machine name.
+ * @param array $field
+ * The field's information.
+ *
+ * @return bool
+ * TRUE, if the field should be processed, FALSE otherwise.
+ */
+ protected function testField($name, array $field) {
+ if (empty($this->options['fields'])) {
+ return $this->testType($field['type']);
+ }
+ return !empty($this->options['fields'][$name]);
+ }
+
+ /**
+ * Determines whether fields of the given type should normally be processed.
+ *
+ * Defaults to processing text types, but can easily be overridden by
+ * subclasses.
+ *
+ * @return bool
+ * TRUE, if the type should be processed, FALSE otherwise.
+ */
+ protected function testType($type) {
+ return search_api_is_text_type($type, array('text', 'tokens'));
+ }
+
+ /**
+ * 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/www/modules/contrib/search_api/includes/processor_highlight.inc b/www/modules/contrib/search_api/includes/processor_highlight.inc
new file mode 100644
index 000000000..55e0191ac
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_highlight.inc
@@ -0,0 +1,530 @@
+options += array(
+ 'prefix' => '',
+ 'suffix' => '',
+ 'excerpt' => TRUE,
+ 'excerpt_length' => 256,
+ 'highlight' => 'always',
+ 'highlight_partial' => FALSE,
+ 'exclude_fields' => array(),
+ );
+
+ $form['prefix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Highlighting prefix'),
+ '#description' => t('Text/HTML that will be prepended to all occurrences of search keywords in highlighted text.'),
+ '#default_value' => $this->options['prefix'],
+ );
+ $form['suffix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Highlighting suffix'),
+ '#description' => t('Text/HTML that will be appended to all occurrences of search keywords in highlighted text.'),
+ '#default_value' => $this->options['suffix'],
+ );
+ $form['excerpt'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Create excerpt'),
+ '#description' => t('When enabled, an excerpt will be created for searches with keywords, containing all occurrences of keywords in a fulltext field.'),
+ '#default_value' => $this->options['excerpt'],
+ );
+ $form['excerpt_length'] = array(
+ '#title' => t('Excerpt length'),
+ '#description' => t('The requested length of the excerpt, in characters.'),
+ '#default_value' => $this->options['excerpt_length'],
+ '#type' => 'number',
+ '#min' => 1,
+ '#step' => 1,
+ '#size' => 4,
+ '#maxlength' => 4,
+ '#states' => array(
+ 'visible' => array(
+ '#edit-processors-search-api-highlighting-settings-excerpt' => array(
+ 'checked' => TRUE,
+ ),
+ ),
+ ),
+ );
+ // Exclude certain fulltext fields.
+ $fields = $this->index->getFields();
+ $fulltext_fields = array();
+ foreach ($this->index->getFulltextFields() as $field) {
+ if (isset($fields[$field])) {
+ $fulltext_fields[$field] = check_plain($fields[$field]['name'] . ' (' . $field . ')');
+ }
+ }
+ $form['exclude_fields'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Exclude fields from excerpt'),
+ '#description' => t('Exclude certain fulltext fields from being displayed in the excerpt.'),
+ '#options' => $fulltext_fields,
+ '#default_value' => $this->options['exclude_fields'],
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ );
+ $form['highlight'] = array(
+ '#type' => 'select',
+ '#title' => t('Highlight returned field data'),
+ '#description' => t('Select whether returned fields should be highlighted.'),
+ '#options' => array(
+ 'always' => t('Always'),
+ 'server' => t('If the server returns fields'),
+ 'never' => t('Never'),
+ ),
+ '#default_value' => $this->options['highlight'],
+ );
+
+ $form['highlight_partial'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Highlight partial matches'),
+ '#description' => t('When enabled, matches in parts of words will be highlighted as well.'),
+ '#default_value' => $this->options['highlight_partial'],
+ );
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+ $values['exclude_fields'] = array_filter($values['exclude_fields']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+ if (empty($response['results']) || !($keys = $this->getKeywords($query))) {
+ return;
+ }
+
+ $fulltext_fields = $this->index->getFulltextFields();
+ if (!empty($this->options['exclude_fields'])) {
+ $fulltext_fields = backdrop_map_assoc($fulltext_fields);
+ foreach ($this->options['exclude_fields'] as $field) {
+ unset($fulltext_fields[$field]);
+ }
+ }
+
+ foreach ($response['results'] as $id => &$result) {
+ if ($this->options['excerpt']) {
+ $text = array();
+ $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields);
+ foreach ($fields as $data) {
+ if (is_array($data)) {
+ $text = array_merge($text, $data);
+ }
+ else {
+ $text[] = $data;
+ }
+ }
+
+ $result['excerpt'] = $this->createExcerpt($this->flattenArrayValues($text), $keys);
+ }
+ if ($this->options['highlight'] != 'never') {
+ $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields, $this->options['highlight'] == 'always');
+ foreach ($fields as $field => $data) {
+ $result['fields'][$field] = array('#sanitize_callback' => FALSE);
+ if (is_array($data)) {
+ foreach ($data as $i => $text) {
+ $result['fields'][$field]['#value'][$i] = $this->highlightField($text, $keys);
+ }
+ }
+ else {
+ $result['fields'][$field]['#value'] = $this->highlightField($data, $keys);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieves the fulltext data of a result.
+ *
+ * @param array $results
+ * All results returned in the search, by reference.
+ * @param int|string $i
+ * The index in the results array of the result whose data should be
+ * returned.
+ * @param array $fulltext_fields
+ * The fulltext fields from which the excerpt should be created.
+ * @param bool $load
+ * TRUE if the item should be loaded if necessary, FALSE if only fields
+ * already returned in the results should be used.
+ *
+ * @return array
+ * An array containing fulltext field names mapped to the text data
+ * contained in them for the given result.
+ */
+ protected function getFulltextFields(array &$results, $i, array $fulltext_fields, $load = TRUE) {
+ global $language;
+ $data = array();
+
+ $result = &$results[$i];
+ // Act as if $load is TRUE if we have a loaded item.
+ $load |= !empty($result['entity']);
+ $result += array('fields' => array());
+ // We only need detailed fields data if $load is TRUE.
+ $fields = $load ? $this->index->getFields() : array();
+ $needs_extraction = array();
+ $returned_fields = search_api_get_sanitized_field_values(array_intersect_key($result['fields'], array_flip($fulltext_fields)));
+ foreach ($fulltext_fields as $field) {
+ if (array_key_exists($field, $returned_fields)) {
+ $data[$field] = $returned_fields[$field];
+ }
+ elseif ($load) {
+ $needs_extraction[$field] = $fields[$field];
+ }
+ }
+
+ if (!$needs_extraction) {
+ return $data;
+ }
+
+ if (empty($result['entity'])) {
+ $items = $this->index->loadItems(array_keys($results));
+ foreach ($items as $id => $item) {
+ $results[$id]['entity'] = $item;
+ }
+ }
+ // If we still don't have a loaded item, we should stop trying.
+ if (empty($result['entity'])) {
+ return $data;
+ }
+ $wrapper = $this->index->entityWrapper($result['entity'], FALSE);
+ $wrapper->language($language->langcode);
+ $extracted = search_api_extract_fields($wrapper, $needs_extraction, array('sanitize' => TRUE));
+
+ foreach ($extracted as $field => $info) {
+ if (isset($info['value'])) {
+ $data[$field] = $info['value'];
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Extracts the positive keywords used in a search query.
+ *
+ * @param SearchApiQuery $query
+ * The query from which to extract the keywords.
+ *
+ * @return array
+ * An array of all unique positive keywords used in the query.
+ */
+ protected function getKeywords(SearchApiQuery $query) {
+ $keys = $query->getKeys();
+ if (!$keys) {
+ return array();
+ }
+ if (is_array($keys)) {
+ return $this->flattenKeysArray($keys);
+ }
+
+ $keywords = preg_split(self::$split, $keys);
+ // Assure there are no duplicates. (This is actually faster than
+ // array_unique() by a factor of 3 to 4.)
+ $keywords = backdrop_map_assoc(array_filter($keywords));
+ // Remove quotes from keywords.
+ foreach ($keywords as $key) {
+ $keywords[$key] = trim($key, "'\" ");
+ }
+ return backdrop_map_assoc(array_filter($keywords));
+ }
+
+ /**
+ * Extracts the positive keywords from a keys array.
+ *
+ * @param array $keys
+ * A search keys array, as specified by SearchApiQueryInterface::getKeys().
+ *
+ * @return array
+ * An array of all unique positive keywords contained in the keys.
+ */
+ protected function flattenKeysArray(array $keys) {
+ if (!empty($keys['#negation'])) {
+ return array();
+ }
+
+ $keywords = array();
+ foreach ($keys as $i => $key) {
+ if (!element_child($i)) {
+ continue;
+ }
+ if (is_array($key)) {
+ $keywords += $this->flattenKeysArray($key);
+ }
+ else {
+ $keywords[$key] = trim($key);
+ }
+ }
+
+ return $keywords;
+ }
+
+ /**
+ * Returns snippets from a piece of text, with certain keywords highlighted.
+ *
+ * Largely copied from search_excerpt().
+ *
+ * @param string $text
+ * The text to extract fragments from.
+ * @param array $keys
+ * Search keywords entered by the user.
+ *
+ * @return string|null
+ * A string containing HTML for the excerpt, or NULL if none could be
+ * created.
+ */
+ protected function createExcerpt($text, array $keys) {
+ // Prepare text by stripping HTML tags and decoding HTML entities.
+ $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text));
+ $text = decode_entities($text);
+ $text = preg_replace('/\s+/', ' ', $text);
+ $text = trim($text, ' ');
+ $text_length = strlen($text);
+
+ // Try to reach the requested excerpt length with about two fragments (each
+ // with a keyword and some context).
+ $ranges = array();
+ $length = 0;
+ $look_start = array();
+ $remaining_keys = $keys;
+
+ // Get the set excerpt length from the configuration. If the length is too
+ // small, only use one fragment.
+ $excerpt_length = $this->options['excerpt_length'];
+ $context_length = round($excerpt_length / 4) - 3;
+ if ($context_length < 32) {
+ $context_length = round($excerpt_length / 2) - 1;
+ }
+
+ while ($length < $excerpt_length && !empty($remaining_keys)) {
+ $found_keys = array();
+ foreach ($remaining_keys as $key) {
+ if ($length >= $excerpt_length) {
+ break;
+ }
+
+ // Remember where we last found $key, in case we are coming through a
+ // second time.
+ if (!isset($look_start[$key])) {
+ $look_start[$key] = 0;
+ }
+
+ // See if we can find $key after where we found it the last time. Since
+ // we are requiring a match on a word boundary, make sure $text starts
+ // and ends with a space.
+ $matches = array();
+
+ if (empty($this->options['highlight_partial'])) {
+ $found_position = FALSE;
+ $regex = '/' . static::$boundary . preg_quote($key, '/') . static::$boundary . '/iu';
+ if (preg_match($regex, ' ' . $text . ' ', $matches, PREG_OFFSET_CAPTURE, $look_start[$key])) {
+ $found_position = $matches[0][1];
+ }
+ }
+ else {
+ $found_position = stripos($text, $key, $look_start[$key]);
+ }
+ if ($found_position !== FALSE) {
+ $look_start[$key] = $found_position + 1;
+ // Keep track of which keys we found this time, in case we need to
+ // pass through again to find more text.
+ $found_keys[] = $key;
+
+ // Locate a space before and after this match, leaving some context on
+ // each end.
+ if ($found_position > $context_length) {
+ $before = strpos($text, ' ', $found_position - $context_length);
+ if ($before !== FALSE) {
+ ++$before;
+ }
+ }
+ else {
+ $before = 0;
+ }
+ if ($before !== FALSE && $before <= $found_position) {
+ if ($text_length > $found_position + $context_length) {
+ $after = strrpos(substr($text, 0, $found_position + $context_length), ' ', $found_position);
+ }
+ else {
+ $after = $text_length;
+ }
+ if ($after !== FALSE && $after > $found_position) {
+ if ($before < $after) {
+ // Save this range.
+ $ranges[$before] = $after;
+ $length += $after - $before;
+ }
+ }
+ }
+ }
+ }
+ // Next time through this loop, only look for keys we found this time,
+ // if any.
+ $remaining_keys = $found_keys;
+ }
+
+ if (!$ranges) {
+ // We didn't find any keyword matches, return NULL.
+ return NULL;
+ }
+
+ // Sort the text ranges by starting position.
+ ksort($ranges);
+
+ // Collapse overlapping text ranges into one. The sorting makes it O(n).
+ $newranges = array();
+ $from1 = $to1 = NULL;
+ foreach ($ranges as $from2 => $to2) {
+ if ($from1 === NULL) {
+ // This is the first time through this loop: initialize.
+ $from1 = $from2;
+ $to1 = $to2;
+ continue;
+ }
+ if ($from2 <= $to1) {
+ // The ranges overlap: combine them.
+ $to1 = max($to1, $to2);
+ }
+ else {
+ // The ranges do not overlap: save the working range and start a new
+ // one.
+ $newranges[$from1] = $to1;
+ $from1 = $from2;
+ $to1 = $to2;
+ }
+ }
+ // Save the remaining working range.
+ $newranges[$from1] = $to1;
+
+ // Fetch text within the combined ranges we found.
+ $out = array();
+ foreach ($newranges as $from => $to) {
+ $out[] = substr($text, $from, $to - $from);
+ }
+ if (!$out) {
+ return NULL;
+ }
+
+ // Let translators have the ... separator text as one chunk.
+ $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...'));
+
+ $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
+ $text = check_plain($text);
+
+ // Since we stripped the tags at the beginning, highlighting doesn't need to
+ // handle HTML anymore.
+ return $this->highlightField($text, $keys, FALSE);
+ }
+
+ /**
+ * Marks occurrences of the search keywords in a text field.
+ *
+ * @param string $text
+ * The text of the field.
+ * @param array $keys
+ * Search keywords entered by the user.
+ * @param bool $html
+ * Whether the text can contain HTML tags or not. In the former case, text
+ * inside tags (i.e., tag names and attributes) won't be highlighted.
+ *
+ * @return string
+ * The field's text with all occurrences of search keywords highlighted.
+ */
+ protected function highlightField($text, array $keys, $html = TRUE) {
+ if (is_array($text)) {
+ $text = $this->flattenArrayValues($text);
+ }
+
+ if ($html) {
+ $texts = preg_split('#((?:?[[:alpha:]](?:[^>"\']*|"[^"]*"|\'[^\']\')*>)+)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+ for ($i = 0; $i < count($texts); $i += 2) {
+ $texts[$i] = $this->highlightField($texts[$i], $keys, FALSE);
+ }
+ return implode('', $texts);
+ }
+
+ $keys = implode('|', array_map('preg_quote', $keys, array_fill(0, count($keys), '/')));
+ // If "Highlight partial matches" is disabled, we only want to highlight
+ // matches that are complete words. Otherwise, we want all of them.
+ $boundary = empty($this->options['highlight_partial']) ? self::$boundary : '';
+ $regex = '/' . $boundary . '(?:' . $keys . ')' . $boundary . '/iu';
+ $replace = $this->options['prefix'] . '\0' . $this->options['suffix'];
+ $text = preg_replace($regex, $replace, ' ' . $text . ' ');
+ return substr($text, 1, -1);
+ }
+
+ /**
+ * Flattens a (possibly multidimensional) array into a string.
+ *
+ * @param array $array
+ * The array to flatten.
+ * @param string $glue
+ * (optional) The separator to insert between individual array items.
+ *
+ * @return string
+ * The glued string.
+ */
+ protected function flattenArrayValues(array $array, $glue = " \n\n ") {
+ $ret = array();
+ foreach ($array as $item) {
+ if (is_array($item)) {
+ $ret[] = $this->flattenArrayValues($item, $glue);
+ }
+ else {
+ $ret[] = $item;
+ }
+ }
+
+ return implode($glue, $ret);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/processor_html_filter.inc b/www/modules/contrib/search_api/includes/processor_html_filter.inc
new file mode 100644
index 000000000..b4a8ef133
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_html_filter.inc
@@ -0,0 +1,180 @@
+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 = backdrop_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/backdrop_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 = backdrop_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 = $this->decodeHtml(strip_tags($text));
+ }
+ }
+
+ /**
+ *
+ */
+ protected function parseText(&$text, $active_tag = NULL, $boost = 1) {
+ $ret = array();
+ while (($pos = strpos($text, '<')) !== FALSE) {
+ if ($boost && $pos > 0) {
+ $token = substr($text, 0, $pos);
+ $ret[] = array(
+ 'value' => $this->decodeHtml($token),
+ 'score' => $boost,
+ );
+ }
+ $text = substr($text, $pos + 1);
+ if (!preg_match('#^(/?)([:_a-zA-Z][-:_a-zA-Z0-9.]*)#', $text, $m)) {
+ continue;
+ }
+ $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' => $this->decodeHtml($text),
+ 'score' => $boost,
+ );
+ $text = '';
+ }
+ return $ret;
+ }
+
+ /**
+ * Decodes HTML entities in a token and normalizes whitespace.
+ *
+ * All whitespace in the token will be converted to single spaces, with no
+ * leading or trailing whitespace.
+ *
+ * @param string $token
+ * The token to process.
+ *
+ * @return string
+ * The processed token.
+ */
+ protected function decodeHtml($token) {
+ $token = html_entity_decode($token, ENT_QUOTES, 'UTF-8');
+ // Remove any multiple/leading/trailing spaces we might have introduced.
+ $token = trim(preg_replace('/[\pZ\pC]+/u', ' ', $token));
+ return $token;
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/processor_ignore_case.inc b/www/modules/contrib/search_api/includes/processor_ignore_case.inc
new file mode 100644
index 000000000..6e59a5fab
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_ignore_case.inc
@@ -0,0 +1,23 @@
+ url('http://snowball.tartarus.org/algorithms/english/stemmer.html'),
+ );
+ $form += array(
+ 'help' => array(
+ '#markup' => '' . t('Optionally, provide an exclusion list to override the stemmer algorithm. (Read about the algorithm.)', $args) . '
',
+ ),
+ 'exceptions' => array(
+ '#type' => 'textarea',
+ '#title' => t('Exceptions'),
+ '#description' => t('Enter exceptions in the form of WORD=STEM, where "WORD" is the term entered and "STEM" is the resulting stem. List each exception on a separate line.'),
+ '#default_value' => "texan=texa",
+ ),
+ );
+
+ if (!empty($this->options['exceptions'])) {
+ $form['exceptions']['#default_value'] = $this->options['exceptions'];
+ }
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function process(&$value) {
+ if (!$value) {
+ return;
+ }
+
+ // Load custom exceptions.
+ $exceptions = $this->getExceptions();
+
+ $words = preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $stemmed = array();
+ foreach ($words as $i => $word) {
+ if ($i % 2 == 0 && strlen($word)) {
+ if (!isset($this->stems[$word])) {
+ $stem = new SearchApiPorter2($word, $exceptions);
+ $this->stems[$word] = $stem->stem();
+ }
+ $stemmed[] = $this->stems[$word];
+ }
+ else {
+ $stemmed[] = $word;
+ }
+ }
+ $value = implode(' ', $stemmed);
+ }
+
+ /**
+ * Retrieves the processor's configured exceptions.
+ *
+ * @return string[]
+ * An associative array of exceptions, with words as keys and stems as their
+ * replacements.
+ */
+ protected function getExceptions() {
+ if (!empty($this->options['exceptions'])) {
+ $exceptions = parse_ini_string($this->options['exceptions'], TRUE);
+ return is_array($exceptions) ? $exceptions : array();
+ }
+ return array();
+ }
+
+}
+
+/**
+ * Implements the Porter2 stemming algorithm.
+ *
+ * @see https://github.com/markfullmer/porter2
+ */
+class SearchApiPorter2 {
+
+ /**
+ * The word being stemmed.
+ *
+ * @var string
+ */
+ protected $word;
+
+ /**
+ * The R1 of the word.
+ *
+ * @var int
+ *
+ * @see http://snowball.tartarus.org/texts/r1r2.html.
+ */
+ protected $r1;
+
+ /**
+ * The R2 of the word.
+ *
+ * @var int
+ *
+ * @see http://snowball.tartarus.org/texts/r1r2.html.
+ */
+ protected $r2;
+
+ /**
+ * List of exceptions to be used.
+ *
+ * @var string[]
+ */
+ protected $exceptions = array();
+
+ /**
+ * Constructs a SearchApiPorter2 object.
+ *
+ * @param string $word
+ * The word to stem.
+ * @param string[] $custom_exceptions
+ * (optional) A custom list of exceptions.
+ */
+ public function __construct($word, $custom_exceptions = array()) {
+ $this->word = $word;
+ $this->exceptions = $custom_exceptions + array(
+ 'skis' => 'ski',
+ 'skies' => 'sky',
+ 'dying' => 'die',
+ 'lying' => 'lie',
+ 'tying' => 'tie',
+ 'idly' => 'idl',
+ 'gently' => 'gentl',
+ 'ugly' => 'ugli',
+ 'early' => 'earli',
+ 'only' => 'onli',
+ 'singly' => 'singl',
+ 'sky' => 'sky',
+ 'news' => 'news',
+ 'howe' => 'howe',
+ 'atlas' => 'atlas',
+ 'cosmos' => 'cosmos',
+ 'bias' => 'bias',
+ 'andes' => 'andes',
+ );
+
+ // Set initial y, or y after a vowel, to Y.
+ $inc = 0;
+ while ($inc <= $this->length()) {
+ if (substr($this->word, $inc, 1) === 'y' && ($inc == 0 || $this->isVowel($inc - 1))) {
+ $this->word = substr_replace($this->word, 'Y', $inc, 1);
+
+ }
+ $inc++;
+ }
+ // Establish the regions R1 and R2. See function R().
+ $this->r1 = $this->R(1);
+ $this->r2 = $this->R(2);
+ }
+
+ /**
+ * Computes the stem of the word.
+ *
+ * @return string
+ * The word's stem.
+ */
+ public function stem() {
+ // Ignore exceptions & words that are two letters or less.
+ if ($this->exceptions() || $this->length() <= 2) {
+ return strtolower($this->word);
+ }
+ else {
+ $this->step0();
+ $this->step1a();
+ $this->step1b();
+ $this->step1c();
+ $this->step2();
+ $this->step3();
+ $this->step4();
+ $this->step5();
+ }
+ return strtolower($this->word);
+ }
+
+ /**
+ * Determines whether the word is contained in our list of exceptions.
+ *
+ * If so, the $word property is changed to the stem listed in the exceptions.
+ *
+ * @return bool
+ * TRUE if the word was an exception, FALSE otherwise.
+ */
+ protected function exceptions() {
+ if (isset($this->exceptions[$this->word])) {
+ $this->word = $this->exceptions[$this->word];
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Searches for the longest among the "s" suffixes and removes it.
+ *
+ * Implements step 0 of the Porter2 algorithm.
+ */
+ protected function step0() {
+ $found = FALSE;
+ $checks = array("'s'", "'s", "'");
+ foreach ($checks as $check) {
+ if (!$found && $this->hasEnding($check)) {
+ $this->removeEnding($check);
+ $found = TRUE;
+ }
+ }
+ }
+
+ /**
+ * Handles various suffixes, of which the longest is replaced.
+ *
+ * Implements step 1a of the Porter2 algorithm.
+ */
+ protected function step1a() {
+ $found = FALSE;
+ if ($this->hasEnding('sses')) {
+ $this->removeEnding('sses');
+ $this->addEnding('ss');
+ $found = TRUE;
+ }
+ $checks = array('ied', 'ies');
+ foreach ($checks as $check) {
+ if (!$found && $this->hasEnding($check)) {
+ $length = $this->length();
+ $this->removeEnding($check);
+ if ($length > 4) {
+ $this->addEnding('i');
+ }
+ else {
+ $this->addEnding('ie');
+ }
+ $found = TRUE;
+ }
+ }
+ if ($this->hasEnding('us') || $this->hasEnding('ss')) {
+ $found = TRUE;
+ }
+ // Delete if preceding word part has a vowel not immediately before the s.
+ if (!$found && $this->hasEnding('s') && $this->containsVowel(substr($this->word, 0, -2))) {
+ $this->removeEnding('s');
+ }
+ }
+
+ /**
+ * Handles various suffixes, of which the longest is replaced.
+ *
+ * Implements step 1b of the Porter2 algorithm.
+ */
+ protected function step1b() {
+ $exceptions = array(
+ 'inning',
+ 'outing',
+ 'canning',
+ 'herring',
+ 'earring',
+ 'proceed',
+ 'exceed',
+ 'succeed',
+ );
+ if (in_array($this->word, $exceptions)) {
+ return;
+ }
+ $checks = array('eedly', 'eed');
+ foreach ($checks as $check) {
+ if ($this->hasEnding($check)) {
+ if ($this->r1 !== $this->length()) {
+ $this->removeEnding($check);
+ $this->addEnding('ee');
+ }
+ return;
+ }
+ }
+ $checks = array('ingly', 'edly', 'ing', 'ed');
+ $second_endings = array('at', 'bl', 'iz');
+ foreach ($checks as $check) {
+ // If the ending is present and the previous part contains a vowel.
+ if ($this->hasEnding($check) && $this->containsVowel(substr($this->word, 0, -strlen($check)))) {
+ $this->removeEnding($check);
+ foreach ($second_endings as $ending) {
+ if ($this->hasEnding($ending)) {
+ $this->addEnding('e');
+ return;
+ }
+ }
+ // If the word ends with a double, remove the last letter.
+ $found = $this->removeDoubles();
+ // If the word is short, add e (so hop -> hope).
+ if (!$found && ($this->isShort())) {
+ $this->addEnding('e');
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Replaces suffix y or Y with i if after non-vowel not @ word begin.
+ *
+ * Implements step 1c of the Porter2 algorithm.
+ */
+ protected function step1c() {
+ if (($this->hasEnding('y') || $this->hasEnding('Y')) && $this->length() > 2 && !($this->isVowel($this->length() - 2))) {
+ $this->removeEnding('y');
+ $this->addEnding('i');
+ }
+ }
+
+ /**
+ * Implements step 2 of the Porter2 algorithm.
+ */
+ protected function step2() {
+ $checks = array(
+ "ization" => "ize",
+ "iveness" => "ive",
+ "fulness" => "ful",
+ "ational" => "ate",
+ "ousness" => "ous",
+ "biliti" => "ble",
+ "tional" => "tion",
+ "lessli" => "less",
+ "fulli" => "ful",
+ "entli" => "ent",
+ "ation" => "ate",
+ "aliti" => "al",
+ "iviti" => "ive",
+ "ousli" => "ous",
+ "alism" => "al",
+ "abli" => "able",
+ "anci" => "ance",
+ "alli" => "al",
+ "izer" => "ize",
+ "enci" => "ence",
+ "ator" => "ate",
+ "bli" => "ble",
+ "ogi" => "og",
+ );
+ foreach ($checks as $find => $replace) {
+ if ($this->hasEnding($find)) {
+ if ($this->inR1($find)) {
+ $this->removeEnding($find);
+ $this->addEnding($replace);
+ }
+ return;
+ }
+ }
+ if ($this->hasEnding('li')) {
+ if ($this->length() > 4 && $this->validLi($this->charAt(-3))) {
+ $this->removeEnding('li');
+ }
+ }
+ }
+
+ /**
+ * Implements step 3 of the Porter2 algorithm.
+ */
+ protected function step3() {
+ $checks = array(
+ 'ational' => 'ate',
+ 'tional' => 'tion',
+ 'alize' => 'al',
+ 'icate' => 'ic',
+ 'iciti' => 'ic',
+ 'ical' => 'ic',
+ 'ness' => '',
+ 'ful' => '',
+ );
+ foreach ($checks as $find => $replace) {
+ if ($this->hasEnding($find)) {
+ if ($this->inR1($find)) {
+ $this->removeEnding($find);
+ $this->addEnding($replace);
+ }
+ return;
+ }
+ }
+ if ($this->hasEnding('ative')) {
+ if ($this->inR2('ative')) {
+ $this->removeEnding('ative');
+ }
+ }
+ }
+
+ /**
+ * Implements step 4 of the Porter2 algorithm.
+ */
+ protected function step4() {
+ $checks = array(
+ 'ement',
+ 'ment',
+ 'ance',
+ 'ence',
+ 'able',
+ 'ible',
+ 'ant',
+ 'ent',
+ 'ion',
+ 'ism',
+ 'ate',
+ 'iti',
+ 'ous',
+ 'ive',
+ 'ize',
+ 'al',
+ 'er',
+ 'ic',
+ );
+ foreach ($checks as $check) {
+ // Among the suffixes, if found and in R2, delete.
+ if ($this->hasEnding($check)) {
+ if ($this->inR2($check)) {
+ if ($check !== 'ion' || in_array($this->charAt(-4), array('s', 't'))) {
+ $this->removeEnding($check);
+ }
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Implements step 5 of the Porter2 algorithm.
+ */
+ protected function step5() {
+ if ($this->hasEnding('e')) {
+ // Delete if in R2, or in R1 and not preceded by a short syllable.
+ if ($this->inR2('e') || ($this->inR1('e') && !$this->isShortSyllable($this->length() - 3))) {
+ $this->removeEnding('e');
+ }
+ return;
+ }
+ if ($this->hasEnding('l')) {
+ // Delete if in R2 and preceded by l.
+ if ($this->inR2('l') && $this->charAt(-2) == 'l') {
+ $this->removeEnding('l');
+ }
+ }
+ }
+
+ /**
+ * Removes certain double consonants from the word's end.
+ *
+ * @return bool
+ * TRUE if a match was found and removed, FALSE otherwise.
+ */
+ protected function removeDoubles() {
+ $found = FALSE;
+ $doubles = array('bb', 'dd', 'ff', 'gg', 'mm', 'nn', 'pp', 'rr', 'tt');
+ foreach ($doubles as $double) {
+ if (substr($this->word, -2) == $double) {
+ $this->word = substr($this->word, 0, -1);
+ $found = TRUE;
+ break;
+ }
+ }
+ return $found;
+ }
+
+ /**
+ * Checks whether a character is a vowel.
+ *
+ * @param int $position
+ * The character's position.
+ * @param string|null $word
+ * (optional) The word in which to check. Defaults to $this->word.
+ * @param string[] $additional
+ * (optional) Additional characters that should count as vowels.
+ *
+ * @return bool
+ * TRUE if the character is a vowel, FALSE otherwise.
+ */
+ protected function isVowel($position, $word = NULL, $additional = array()) {
+ if ($word === NULL) {
+ $word = $this->word;
+ }
+ $vowels = array_merge(array('a', 'e', 'i', 'o', 'u', 'y'), $additional);
+ return in_array($this->charAt($position, $word), $vowels);
+ }
+
+ /**
+ * Retrieves the character at the given position.
+ *
+ * @param int $position
+ * The 0-based index of the character. If a negative number is given, the
+ * position is counted from the end of the string.
+ * @param string|null $word
+ * (optional) The word from which to retrieve the character. Defaults to
+ * $this->word.
+ *
+ * @return string
+ * The character at the given position, or an empty string if the given
+ * position was illegal.
+ */
+ protected function charAt($position, $word = NULL) {
+ if ($word === NULL) {
+ $word = $this->word;
+ }
+ $length = strlen($word);
+ if (abs($position) >= $length) {
+ return '';
+ }
+ if ($position < 0) {
+ $position += $length;
+ }
+ return $word[$position];
+ }
+
+ /**
+ * Determines whether the word ends in a "vowel-consonant" suffix.
+ *
+ * Unless the word is only two characters long, it also checks that the
+ * third-last character is neither "w", "x" nor "Y".
+ *
+ * @param int|null $position
+ * (optional) If given, do not check the end of the word, but the character
+ * at the given position, and the next one.
+ *
+ * @return bool
+ * TRUE if the word has the described suffix, FALSE otherwise.
+ */
+ protected function isShortSyllable($position = NULL) {
+ if ($position === NULL) {
+ $position = $this->length() - 2;
+ }
+ // A vowel at the beginning of the word followed by a non-vowel.
+ if ($position === 0) {
+ return $this->isVowel(0) && !$this->isVowel(1);
+ }
+ // Vowel followed by non-vowel other than w, x, Y and preceded by
+ // non-vowel.
+ $additional = array('w', 'x', 'Y');
+ return !$this->isVowel($position - 1) && $this->isVowel($position) && !$this->isVowel($position + 1, NULL, $additional);
+ }
+
+ /**
+ * Determines whether the word is short.
+ *
+ * A word is called short if it ends in a short syllable and if R1 is null.
+ *
+ * @return bool
+ * TRUE if the word is short, FALSE otherwise.
+ */
+ protected function isShort() {
+ return $this->isShortSyllable() && $this->r1 == $this->length();
+ }
+
+ /**
+ * Determines the start of a certain "R" region.
+ *
+ * R is a region after the first non-vowel following a vowel, or end of word.
+ *
+ * @param int $type
+ * (optional) 1 or 2. If 2, then calculate the R after the R1.
+ *
+ * @return int
+ * The R position.
+ */
+ protected function R($type = 1) {
+ $inc = 1;
+ if ($type === 2) {
+ $inc = $this->r1;
+ }
+ elseif ($this->length() > 5) {
+ $prefix_5 = substr($this->word, 0, 5);
+ if ($prefix_5 === 'gener' || $prefix_5 === 'arsen') {
+ return 5;
+ }
+ if ($this->length() > 6 && substr($this->word, 0, 6) === 'commun') {
+ return 6;
+ }
+ }
+
+ while ($inc <= $this->length()) {
+ if (!$this->isVowel($inc) && $this->isVowel($inc - 1)) {
+ $position = $inc;
+ break;
+ }
+ $inc++;
+ }
+ if (!isset($position)) {
+ $position = $this->length();
+ }
+ else {
+ // We add one, as this is the position AFTER the first non-vowel.
+ $position++;
+ }
+ return $position;
+ }
+
+ /**
+ * Checks whether the given string is contained in R1.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the string is in R1, FALSE otherwise.
+ */
+ protected function inR1($string) {
+ $r1 = substr($this->word, $this->r1);
+ return strpos($r1, $string) !== FALSE;
+ }
+
+ /**
+ * Checks whether the given string is contained in R2.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the string is in R2, FALSE otherwise.
+ */
+ protected function inR2($string) {
+ $r2 = substr($this->word, $this->r2);
+ return strpos($r2, $string) !== FALSE;
+ }
+
+ /**
+ * Determines the string length of the current word.
+ *
+ * @return int
+ * The string length of the current word.
+ */
+ protected function length() {
+ return strlen($this->word);
+ }
+
+ /**
+ * Checks whether the word ends with the given string.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the word ends with the given string, FALSE otherwise.
+ */
+ protected function hasEnding($string) {
+ $length = strlen($string);
+ if ($length > $this->length()) {
+ return FALSE;
+ }
+ return (substr_compare($this->word, $string, -1 * $length, $length) === 0);
+ }
+
+ /**
+ * Appends a given string to the current word.
+ *
+ * @param string $string
+ * The ending to append.
+ */
+ protected function addEnding($string) {
+ $this->word = $this->word . $string;
+ }
+
+ /**
+ * Removes a given string from the end of the current word.
+ *
+ * Does not check whether the ending is actually there.
+ *
+ * @param string $string
+ * The ending to remove.
+ */
+ protected function removeEnding($string) {
+ $this->word = substr($this->word, 0, -strlen($string));
+ }
+
+ /**
+ * Checks whether the given string contains a vowel.
+ *
+ * @param string $string
+ * The string to check.
+ *
+ * @return bool
+ * TRUE if the string contains a vowel, FALSE otherwise.
+ */
+ protected function containsVowel($string) {
+ $inc = 0;
+ $return = FALSE;
+ while ($inc < strlen($string)) {
+ if ($this->isVowel($inc, $string)) {
+ $return = TRUE;
+ break;
+ }
+ $inc++;
+ }
+ return $return;
+ }
+
+ /**
+ * Checks whether the given string is a valid -li prefix.
+ *
+ * @param string $string
+ * The string to check.
+ *
+ * @return bool
+ * TRUE if the given string is a valid -li prefix, FALSE otherwise.
+ */
+ protected function validLi($string) {
+ return in_array($string, array(
+ 'c',
+ 'd',
+ 'e',
+ 'g',
+ 'h',
+ 'k',
+ 'm',
+ 'n',
+ 'r',
+ 't',
+ ));
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/processor_stopwords.inc b/www/modules/contrib/search_api/includes/processor_stopwords.inc
new file mode 100644
index 000000000..d83a697c2
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_stopwords.inc
@@ -0,0 +1,132 @@
+ 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'),
+ '#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);
+
+ $uri = $values['file'];
+ 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) || !is_string($value)) {
+ 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 preprocessSearchQuery(SearchApiQuery $query) {
+ $this->ignored = array();
+ parent::preprocessSearchQuery($query);
+ }
+
+ /**
+ *
+ */
+ public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+ if ($this->ignored) {
+ if (isset($response['ignored'])) {
+ $response['ignored'] = array_merge($response['ignored'], $this->ignored);
+ }
+ else {
+ $response['ignored'] = $this->ignored;
+ }
+ }
+ }
+
+ /**
+ * Retrieves the processor's configured stopwords.
+ *
+ * @return array
+ * 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;
+ }
+}
diff --git a/www/modules/contrib/search_api/includes/processor_tokenizer.inc b/www/modules/contrib/search_api/includes/processor_tokenizer.inc
new file mode 100644
index 000000000..ef38c3b93
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_tokenizer.inc
@@ -0,0 +1,129 @@
+index->getFields();
+ $field_options = array();
+ foreach ($fields as $name => $field) {
+ if (empty($field['real_type']) && search_api_is_text_type($field['type'])) {
+ $field_options[$name] = $field['name'];
+ }
+ }
+ $form['fields']['#options'] = $field_options;
+
+ $form += array(
+ 'spaces' => array(
+ '#type' => 'textfield',
+ '#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) {
+ // We don't touch integers, NULL values or the like.
+ if (is_string($value)) {
+ $this->prepare();
+ if ($this->ignorable) {
+ $value = preg_replace('/' . $this->ignorable . '+/u', '', $value);
+ }
+ if ($this->spaces) {
+ $value = preg_replace('/' . $this->spaces . '+/u', ' ', $value);
+ }
+ }
+ }
+
+ /**
+ *
+ */
+ protected function prepare() {
+ if (!isset($this->spaces)) {
+ $this->spaces = str_replace('/', '\/', $this->options['spaces']);
+ $this->ignorable = str_replace('/', '\/', $this->options['ignorable']);
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/processor_transliteration.inc b/www/modules/contrib/search_api/includes/processor_transliteration.inc
new file mode 100644
index 000000000..9f3984839
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/processor_transliteration.inc
@@ -0,0 +1,23 @@
+langcode);
+ }
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/query.inc b/www/modules/contrib/search_api/includes/query.inc
new file mode 100644
index 000000000..7a3f20d03
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/query.inc
@@ -0,0 +1,1122 @@
+", "<", "<=", ">=", ">". 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 = '=');
+
+ /**
+ * Adds a sort directive to this search query.
+ *
+ * If no sort is manually set, the results will be sorted descending by
+ * relevance.
+ *
+ * @param string $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. Also, if
+ * the search server supports the "search_api_random_sort" feature, the
+ * "search_api_random" special field can be used to sort randomly.
+ * @param string $order
+ * The order to sort items in - either 'ASC' or 'DESC'.
+ *
+ * @return SearchApiQueryInterface
+ * The called object.
+ *
+ * @throws SearchApiException
+ * If the field is multi-valued or of a fulltext type.
+ */
+ 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 int|null $offset
+ * The zero-based offset of the first result returned.
+ * @param int|null $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, or
+ * skipped entirely if the "skip result count" option was set on this
+ * query.
+ * - 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. The format of the array is
+ * field IDs (as used by the Search API internally) mapped to either the
+ * raw value of the field (scalar or array value), or an associative
+ * array with the following keys:
+ * - #value: The raw field value.
+ * - #sanitize_callback: The callback to use for sanitizing the field
+ * value for HTML output, or FALSE to state that the field value is
+ * already sanitized.
+ * In the simple form, it's assumed the field value should be sanitized
+ * with check_plain().
+ * - 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 'results' always have to be set, all other entries are optional.
+ *
+ * @throws SearchApiException
+ * If an error prevented the search from completing.
+ */
+ 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.
+ *
+ * @throws SearchApiException
+ * If any error occurred during the preparation of the query.
+ */
+ public function preExecute();
+
+ /**
+ * Postprocesses 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. The data
+ * structure is the same as returned by execute().
+ */
+ public function postExecute(array &$results);
+
+ /**
+ * Retrieves the index associated with this search.
+ *
+ * @return SearchApiIndex
+ * The search index this query should be executed on.
+ */
+ public function getIndex();
+
+ /**
+ * Retrieves the search keys for this query.
+ *
+ * @return array|string|null
+ * 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.
+ *
+ * @see keys()
+ */
+ public function &getKeys();
+
+ /**
+ * Retrieves the unparsed search keys for this query as originally entered.
+ *
+ * @return array|string|null
+ * The unprocessed search keys, exactly as passed to this object. Has the
+ * same format as the return value of getKeys().
+ *
+ * @see keys()
+ */
+ public function getOriginalKeys();
+
+ /**
+ * Retrieves the fulltext fields that will be searched for the search keys.
+ *
+ * @return array
+ * An array containing the fields that should be searched for the search
+ * keys.
+ *
+ * @see fields()
+ */
+ public function &getFields();
+
+ /**
+ * Retrieves the filter object associated with this search query.
+ *
+ * @return SearchApiQueryFilterInterface
+ * This object's associated filter object.
+ */
+ public function getFilter();
+
+ /**
+ * Retrieves the sorts set for this query.
+ *
+ * @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.
+ *
+ * @see sort()
+ */
+ public function &getSort();
+
+ /**
+ * Retrieves an option set on this search query.
+ *
+ * @param string $name
+ * The name of an option.
+ * @param mixed $default
+ * 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);
+
+ /**
+ * Sets an option for this search query.
+ *
+ * @param string $name
+ * The name of an option.
+ * @param mixed $value
+ * The new value of the option.
+ *
+ * @return mixed
+ * The option's previous value.
+ */
+ public function setOption($name, $value);
+
+ /**
+ * Retrieves all options set for this search query.
+ *
+ * The return value is a reference to the options so they can also be altered
+ * this way.
+ *
+ * @return array
+ * An associative array of query options.
+ */
+ public function &getOptions();
+
+}
+
+/**
+ * Provides a standard implementation of the SearchApiQueryInterface.
+ */
+class SearchApiQuery implements SearchApiQueryInterface {
+
+ /**
+ * The index this query will use.
+ *
+ * @var SearchApiIndex
+ */
+ protected $index;
+
+ /**
+ * The index's machine name.
+ *
+ * Used during serialization to avoid serializing the whole index object.
+ *
+ * @var string
+ */
+ protected $index_id;
+
+ /**
+ * 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;
+
+ /**
+ * Flag for whether preExecute() was already called for this query.
+ *
+ * @var bool
+ */
+ protected $pre_execute = FALSE;
+
+ /**
+ * {@inheritdoc}
+ */
+ 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();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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 separated by spaces. ' .
+ 'Keywords containing spaces may be "quoted". Quoted keywords must still be separated by spaces.'),
+ );
+ // @todo Add fourth mode for complicated expressions, e.g.: »"vanilla ice" OR (love NOT hate)«
+ return $modes;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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 = preg_split('/\s+/u', $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);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createFilter($conjunction = 'AND', $tags = array()) {
+ $filter_class = $this->options['filter class'];
+ return new $filter_class($conjunction, $tags);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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;
+ }
+ /**
+ * {@inheritdoc}
+ */
+ 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function filter(SearchApiQueryFilterInterface $filter) {
+ $this->filter->filter($filter);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function condition($field, $value, $operator = '=') {
+ $this->filter->condition($field, $value, $operator);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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 ($this->getIndex()->server()->supportsFeature('search_api_random_sort')) {
+ $fields['search_api_random'] = 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function range($offset = NULL, $limit = NULL) {
+ $this->options['offset'] = $offset;
+ $this->options['limit'] = $limit;
+ return $this;
+ }
+
+
+ /**
+ * {@inheritdoc}
+ */
+ public 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;
+ }
+
+ /**
+ * Adds language filters for the query.
+ *
+ * Internal helper function.
+ *
+ * @param array $languages
+ * The languages for which results should be returned.
+ *
+ * @throws SearchApiException
+ * If there was a logical error in the combination of filters and languages.
+ */
+ protected function addLanguages(array $languages) {
+ if (array_search(LANGUAGE_NONE, $languages) === FALSE) {
+ $languages[] = LANGUAGE_NONE;
+ }
+
+ $languages = backdrop_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);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preExecute() {
+ // Make sure to only execute this once per query.
+ if (!$this->pre_execute) {
+ $this->pre_execute = TRUE;
+ // 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.
+ backdrop_alter('search_api_query', $this);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postExecute(array &$results) {
+ // Postprocess results.
+ $this->index->postprocessSearchResults($results, $this);
+
+ // Let modules alter the results.
+ backdrop_alter('search_api_results', $results, $this);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getIndex() {
+ return $this->index;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getKeys() {
+ return $this->keys;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOriginalKeys() {
+ return $this->orig_keys;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getFields() {
+ return $this->fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFilter() {
+ return $this->filter;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getSort() {
+ return $this->sort;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOption($name, $default = NULL) {
+ return array_key_exists($name, $this->options) ? $this->options[$name] : $default;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOption($name, $value) {
+ $old = $this->getOption($name);
+ $this->options[$name] = $value;
+ return $old;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getOptions() {
+ return $this->options;
+ }
+
+ /**
+ * Implements the magic __sleep() method to avoid serializing the index.
+ */
+ public function __sleep() {
+ $this->index_id = $this->index->machine_name;
+ $keys = get_object_vars($this);
+ unset($keys['index']);
+ return array_keys($keys);
+ }
+
+ /**
+ * Implements the magic __wakeup() method to reload the query's index.
+ */
+ public function __wakeup() {
+ if (!isset($this->index) && !empty($this->index_id)) {
+ $this->index = search_api_index_load($this->index_id);
+ unset($this->index_id);
+ }
+ }
+
+ /**
+ * Implements the magic __clone() method to clone the filter, too.
+ */
+ public function __clone() {
+ $this->filter = clone $this->filter;
+ }
+
+ /**
+ * Implements the magic __toString() method to simplify debugging.
+ */
+ public function __toString() {
+ $ret = 'Index: ' . $this->index->machine_name . "\n";
+ $ret .= 'Keys: ' . str_replace("\n", "\n ", var_export($this->orig_keys, TRUE)) . "\n";
+ if (isset($this->keys)) {
+ $ret .= 'Parsed keys: ' . str_replace("\n", "\n ", var_export($this->keys, TRUE)) . "\n";
+ $ret .= 'Searched fields: ' . (isset($this->fields) ? implode(', ', $this->fields) : '[ALL]') . "\n";
+ }
+ if ($filter = (string) $this->filter) {
+ $filter = str_replace("\n", "\n ", $filter);
+ $ret .= "Filters:\n $filter\n";
+ }
+ if ($this->sort) {
+ $sort = array();
+ foreach ($this->sort as $field => $order) {
+ $sort[] = "$field $order";
+ }
+ $ret .= 'Sorting: ' . implode(', ', $sort) . "\n";
+ }
+ $options = $this->sanitizeOptions($this->options);
+ $options = str_replace("\n", "\n ", var_export($options, TRUE));
+ $ret .= 'Options: ' . $options . "\n";
+ return $ret;
+ }
+
+ /**
+ * Sanitizes an array of options in a way that plays nice with var_export().
+ *
+ * @param array $options
+ * An array of options.
+ *
+ * @return array
+ * The sanitized options.
+ */
+ protected function sanitizeOptions(array $options) {
+ foreach ($options as $key => $value) {
+ if (is_object($value)) {
+ $options[$key] = 'object (' . get_class($value) . ')';
+ }
+ elseif (is_array($value)) {
+ $options[$key] = $this->sanitizeOptions($value);
+ }
+ }
+ return $options;
+ }
+
+}
+
+/**
+ * Represents a filter on a search query.
+ *
+ * Filters apply conditions on one or more fields with a specific conjunction
+ * (AND or OR) and may contain nested filters.
+ */
+interface SearchApiQueryFilterInterface {
+
+ /**
+ * Constructs a new filter that uses the specified conjunction.
+ *
+ * @param string $conjunction
+ * (optional) The conjunction to use for this filter - either 'AND' or 'OR'.
+ * @param array $tags
+ * (optional) An arbitrary set of tags. Can be used to identify this filter
+ * down the line if necessary. This is primarily used by the facet system
+ * to support OR facet queries.
+ */
+ public function __construct($conjunction = 'AND', array $tags = array());
+
+ /**
+ * Sets this filter's conjunction.
+ *
+ * @param string $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 SearchApiQueryFilterInterface $filter
+ * A SearchApiQueryFilterInterface object that should be added as a
+ * subfilter.
+ *
+ * @return SearchApiQueryFilterInterface
+ * The called object.
+ */
+ public function filter(SearchApiQueryFilterInterface $filter);
+
+ /**
+ * Adds a new ($field $operator $value) condition.
+ *
+ * @param string $field
+ * The field to filter on, e.g. 'title'.
+ * @param mixed $value
+ * The value the field should have (or be related to by the operator).
+ * @param string $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 = '=');
+
+ /**
+ * Retrieves the conjunction used by this filter.
+ *
+ * @return string
+ * The conjunction used by this filter - either 'AND' or 'OR'.
+ */
+ public function getConjunction();
+
+ /**
+ * Return all conditions and nested filters contained in this filter.
+ *
+ * @return array
+ * An array containing this filter's subfilters. Each of these is either a
+ * condition, represented as a numerically indexed array with the arguments
+ * of a previous SearchApiQueryFilterInterface::condition() call (field,
+ * value, operator); or a nested filter, represented by a
+ * SearchApiQueryFilterInterface filter object.
+ */
+ public function &getFilters();
+
+ /**
+ * Checks whether a certain tag was set on this filter.
+ *
+ * @param string $tag
+ * A tag to check for.
+ *
+ * @return bool
+ * TRUE if the tag was set for this filter, FALSE otherwise.
+ */
+ public function hasTag($tag);
+
+ /**
+ * Retrieves the tags set on this filter.
+ *
+ * @return array
+ * The tags associated with this filter, as both the array keys and values.
+ */
+ public function &getTags();
+
+}
+
+/**
+ * Provides a 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.
+ *
+ * @var array
+ */
+ protected $filters;
+
+ /**
+ * String specifying this filter's conjunction ('AND' or 'OR').
+ *
+ * @var string
+ */
+ protected $conjunction;
+
+ /**
+ * Array of tags for the filter. Can be used to identify this filter down the
+ * line if necessary. This is primarily used by the facet system to support OR
+ * facet queries.
+ *
+ * @var array
+ */
+ protected $tags;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct($conjunction = 'AND', array $tags = array()) {
+ $this->setConjunction($conjunction);
+ $this->filters = array();
+ $this->tags = backdrop_map_assoc($tags);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setConjunction($conjunction) {
+ $this->conjunction = strtoupper(trim($conjunction)) == 'OR' ? 'OR' : 'AND';
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function filter(SearchApiQueryFilterInterface $filter) {
+ $this->filters[] = $filter;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function condition($field, $value, $operator = '=') {
+ $this->filters[] = array($field, $value, $operator);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConjunction() {
+ return $this->conjunction;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getFilters() {
+ return $this->filters;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasTag($tag) {
+ return isset($this->tags[$tag]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getTags() {
+ // Tags can sometimes be NULL for old serialized query filter objects.
+ if (!isset($this->tags)) {
+ $this->tags = array();
+ }
+ return $this->tags;
+ }
+
+ /**
+ * Implements the magic __clone() method to clone nested filters, too.
+ */
+ public function __clone() {
+ foreach ($this->filters as $i => $filter) {
+ if (is_object($filter)) {
+ $this->filters[$i] = clone $filter;
+ }
+ }
+ }
+
+ /**
+ * Implements the magic __toString() method to simplify debugging.
+ */
+ public function __toString() {
+ // Special case for a single, nested filter:
+ if (count($this->filters) == 1 && is_object($this->filters[0])) {
+ return (string) $this->filters[0];
+ }
+ $ret = array();
+ foreach ($this->filters as $filter) {
+ if (is_object($filter)) {
+ $ret[] = "[\n " . str_replace("\n", "\n ", (string) $filter) . "\n ]";
+ }
+ else {
+ $ret[] = "$filter[0] $filter[2] " . str_replace("\n", "\n ", var_export($filter[1], TRUE));
+ }
+ }
+ return $ret ? ' ' . implode("\n{$this->conjunction}\n ", $ret) : '';
+ }
+
+}
diff --git a/www/modules/contrib/search_api/includes/server_entity.inc b/www/modules/contrib/search_api/includes/server_entity.inc
new file mode 100644
index 000000000..7a416a322
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/server_entity.inc
@@ -0,0 +1,441 @@
+entityInfo['exportable'])) {
+ return isset($this->{$this->statusKey}) && ($this->{$this->statusKey} & $status) == $status;
+ }
+ }
+
+ /**
+ * 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 int|false
+ * SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
+ * the specified values.
+ */
+ public function update(array $fields) {
+ $changeable = array(
+ 'name' => 1,
+ 'enabled' => 1,
+ 'description' => 1,
+ 'options' => 1,
+ );
+ $changed = FALSE;
+ foreach ($fields as $field => $value) {
+ if (isset($changeable[$field]) && $value !== $this->$field) {
+ $this->$field = $value;
+ $changed = TRUE;
+ }
+ }
+ // If there are no new values, just return 0.
+ if (!$changed) {
+ return 0;
+ }
+ return $this->save();
+ }
+
+ /**
+ * Magic method for determining which fields should be serialized.
+ *
+ * Serialize all properties except the proxy object.
+ *
+ * @return array
+ * An array of properties to be serialized.
+ */
+ public function __sleep() {
+ $ret = get_object_vars($this);
+ unset($ret['proxy'], $ret['status'], $ret['module'], $ret['is_new']);
+ return array_keys($ret);
+ }
+
+ /**
+ * Helper method for ensuring the proxy object is set up.
+ */
+ protected function ensureProxy() {
+ if (!isset($this->proxy)) {
+ $class = search_api_get_service_info($this->class);
+ if ($class && class_exists($class['class'])) {
+ if (empty($this->options)) {
+ // We always have to provide the options.
+ $this->options = array();
+ }
+ $this->proxy = new $class['class']($this);
+ }
+ if (!($this->proxy instanceof SearchApiServiceInterface)) {
+ throw new SearchApiException(t('Search server with machine name @name specifies illegal service class @class.', array('@name' => $this->machine_name, '@class' => $this->class)));
+ }
+ }
+ }
+
+ /**
+ * Reacts to calls of undefined methods on this object.
+ *
+ * If the service class defines additional methods, not specified in the
+ * SearchApiServiceInterface interface, then they are called via this magic
+ * method.
+ */
+ 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. This also gives us the
+ // opportunity to do additional error handling.
+
+ /**
+ * Form constructor for the server configuration form.
+ *
+ * @see SearchApiServiceInterface::configurationForm()
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ $this->ensureProxy();
+ return $this->proxy->configurationForm($form, $form_state);
+ }
+
+ /**
+ * Validation callback for the form returned by configurationForm().
+ *
+ * @see SearchApiServiceInterface::configurationFormValidate()
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+ $this->ensureProxy();
+ return $this->proxy->configurationFormValidate($form, $values, $form_state);
+ }
+
+ /**
+ * Submit callback for the form returned by configurationForm().
+ *
+ * @see SearchApiServiceInterface::configurationFormSubmit()
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ $this->ensureProxy();
+ return $this->proxy->configurationFormSubmit($form, $values, $form_state);
+ }
+
+ /**
+ * Determines whether this service class supports a given feature.
+ *
+ * @see SearchApiServiceInterface::supportsFeature()
+ */
+ public function supportsFeature($feature) {
+ $this->ensureProxy();
+ return $this->proxy->supportsFeature($feature);
+ }
+
+ /**
+ * Displays this server's settings.
+ *
+ * @see SearchApiServiceInterface::viewSettings()
+ */
+ public function viewSettings() {
+ $this->ensureProxy();
+ return $this->proxy->viewSettings();
+ }
+
+ /**
+ * Reacts to the server's creation.
+ *
+ * @see SearchApiServiceInterface::postCreate()
+ */
+ public function postCreate() {
+ $this->ensureProxy();
+ return $this->proxy->postCreate();
+ }
+
+ /**
+ * Notifies this server that its fields are about to be updated.
+ *
+ * @see SearchApiServiceInterface::postUpdate()
+ */
+ public function postUpdate() {
+ $this->ensureProxy();
+ return $this->proxy->postUpdate();
+ }
+
+ /**
+ * Notifies this server that it is about to be deleted from the database.
+ *
+ * @see SearchApiServiceInterface::preDelete()
+ */
+ public function preDelete() {
+ $this->ensureProxy();
+ return $this->proxy->preDelete();
+ }
+
+ /**
+ * Adds a new index to this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::addIndex()
+ * @see search_api_server_tasks_add()
+ */
+ public function addIndex(SearchApiIndex $index) {
+ $this->ensureProxy();
+ try {
+ $this->proxy->addIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => $index->name,
+ );
+ watchdog_exception('search_api', $e, '%type while adding index %index to server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index);
+ }
+ }
+
+ /**
+ * Notifies the server that the field settings for the index have changed.
+ *
+ * If the service class implementation of the method returns TRUE, this will
+ * automatically take care of marking the items on the index for re-indexing.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::fieldsUpdated()
+ * @see search_api_server_tasks_add()
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ $this->ensureProxy();
+ try {
+ if ($this->proxy->fieldsUpdated($index)) {
+ _search_api_index_reindex($index);
+ return TRUE;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => $index->name,
+ );
+ watchdog_exception('search_api', $e, '%type while updating the fields of index %index on server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index, isset($index->original) ? $index->original : NULL);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Removes an index from this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::removeIndex()
+ * @see search_api_server_tasks_add()
+ */
+ public function removeIndex($index) {
+ // When removing an index from a server, it doesn't make any sense anymore to
+ // delete items from it, or react to other changes.
+ search_api_server_tasks_delete(NULL, $this, $index);
+
+ $this->ensureProxy();
+ try {
+ $this->proxy->removeIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => is_object($index) ? $index->name : $index,
+ );
+ watchdog_exception('search_api', $e, '%type while removing index %index from server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index);
+ }
+ }
+
+ /**
+ * Indexes the specified items.
+ *
+ * @see SearchApiServiceInterface::indexItems()
+ */
+ public function indexItems(SearchApiIndex $index, array $items) {
+ $this->ensureProxy();
+ return $this->proxy->indexItems($index, $items);
+ }
+
+ /**
+ * Deletes indexed items from this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::deleteItems()
+ * @see search_api_server_tasks_add()
+ */
+ public function deleteItems($ids = 'all', ?SearchApiIndex $index = NULL) {
+ $this->ensureProxy();
+ try {
+ $this->proxy->deleteItems($ids, $index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ );
+ watchdog_exception('search_api', $e, '%type while deleting items from server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index, $ids);
+ }
+ }
+
+ /**
+ * Creates a query object for searching on an index lying on this server.
+ *
+ * @see SearchApiServiceInterface::query()
+ */
+ public function query(SearchApiIndex $index, $options = array()) {
+ $this->ensureProxy();
+ return $this->proxy->query($index, $options);
+ }
+
+ /**
+ * Executes a search on the server represented by this object.
+ *
+ * @see SearchApiServiceInterface::search()
+ */
+ public function search(SearchApiQueryInterface $query) {
+ $this->ensureProxy();
+ return $this->proxy->search($query);
+ }
+
+ /**
+ * Retrieves additional information for the server, if available.
+ *
+ * Retrieving such information is only supported if the service class supports
+ * the "search_api_service_extra" feature.
+ *
+ * @return array
+ * An array containing additional, service class-specific information about
+ * the server.
+ *
+ * @see SearchApiAbstractService::getExtraInformation()
+ */
+ public function getExtraInformation() {
+ if ($this->proxy->supportsFeature('search_api_service_extra')) {
+ return $this->proxy->getExtraInformation();
+ }
+ return array();
+ }
+
+ /**
+ * Return a label for a signup form.
+ */
+ public function label() {
+ // Return $this->title;.
+ return 'the label';
+ }
+
+ /**
+ * Overrides Entity\Entity::uri().
+ */
+ public function uri() {
+ return array();
+ }
+ /**
+ * Implements EntityInterface::id().
+ */
+ public function id() {
+ return $this->id;
+ }
+
+ /**
+ * Implements EntityInterface::entityType().
+ */
+ public function entityType() {
+ return 'search_api_server';
+ }
+}
diff --git a/www/modules/contrib/search_api/includes/service.inc b/www/modules/contrib/search_api/includes/service.inc
new file mode 100644
index 000000000..6d439a420
--- /dev/null
+++ b/www/modules/contrib/search_api/includes/service.inc
@@ -0,0 +1,473 @@
+ listing all relevant settings
+ * is preferred.
+ */
+ public function viewSettings();
+
+ /**
+ * Reacts to the server's creation.
+ *
+ * Called once, when the server is first created. Allows it to set up its
+ * necessary infrastructure.
+ */
+ public function postCreate();
+
+ /**
+ * Notifies this server that its fields are about to be updated.
+ *
+ * The server's $original property can be used to inspect the old property
+ * values.
+ *
+ * @return bool
+ * TRUE, if the update requires reindexing of all content on the server.
+ */
+ public function postUpdate();
+
+ /**
+ * Notifies this server that it is about to be deleted from the database.
+ *
+ * This should execute any necessary cleanup operations.
+ *
+ * Note that you shouldn't call the server's save() method, or any
+ * methods that might do that, from inside of this method as the server isn't
+ * present in the database anymore at this point.
+ */
+ public function preDelete();
+
+ /**
+ * Adds a new index to this server.
+ *
+ * If the index was already added to the server, the object should treat this
+ * as if removeIndex() and then addIndex() were called.
+ *
+ * @param SearchApiIndex $index
+ * The index to add.
+ *
+ * @throws SearchApiException
+ * If an error occurred while adding the index.
+ */
+ public function addIndex(SearchApiIndex $index);
+
+ /**
+ * Notifies the server that the field settings for the index have changed.
+ *
+ * If any user action is necessary as a result of this, the method should
+ * use backdrop_set_message() to notify the user.
+ *
+ * @param SearchApiIndex $index
+ * The updated index.
+ *
+ * @return bool
+ * TRUE, if this change affected the server in any way that forces it to
+ * re-index the content. FALSE otherwise.
+ *
+ * @throws SearchApiException
+ * If an error occurred while reacting to the change of fields.
+ */
+ public function fieldsUpdated(SearchApiIndex $index);
+
+ /**
+ * Removes an index from this server.
+ *
+ * This might mean that the index has been deleted, or reassigned to a
+ * different server. If you need to distinguish between these cases, inspect
+ * $index->server.
+ *
+ * If the index wasn't added to the server, the method call should be ignored.
+ *
+ * Implementations of this method should also check whether $index->read_only
+ * is set, and don't delete any indexed data if it is.
+ *
+ * @param $index
+ * Either an object representing the index to remove, or its machine name
+ * (if the index was completely deleted).
+ *
+ * @throws SearchApiException
+ * If an error occurred while removing the index.
+ */
+ public function removeIndex($index);
+
+ /**
+ * Indexes 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);
+
+ /**
+ * Deletes indexed items from this server.
+ *
+ * Might be either used to delete some items (given by their ids) from a
+ * specified index, or all items from that index, or all items from all
+ * 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').
+ *
+ * @throws SearchApiException
+ * If an error occurred while trying to delete the items.
+ */
+ public function deleteItems($ids = 'all', ?SearchApiIndex $index = NULL);
+
+ /**
+ * Creates 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.
+ *
+ * For creating your own service class extending this class, you only need to
+ * implement indexItems(), deleteItems() and search() from the
+ * SearchApiServiceInterface interface.
+ */
+abstract class SearchApiAbstractService implements SearchApiServiceInterface {
+
+ /**
+ * @var SearchApiServer
+ */
+ protected $server;
+
+ /**
+ * Direct reference to the server's $options property.
+ *
+ * @var array
+ */
+ protected $options = array();
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation sets $this->server and $this->options.
+ */
+ public function __construct(SearchApiServer $server) {
+ $this->server = $server;
+ $this->options = &$server->options;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * Returns an empty form by default.
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ return array();
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * Does nothing by default.
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+ return;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation just ensures that additional elements in
+ * $options, not present in the form, don't get lost at the update.
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ if (!empty($this->options)) {
+ $values += $this->options;
+ }
+ $this->options = $values;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation always returns FALSE.
+ */
+ public function supportsFeature($feature) {
+ return FALSE;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * 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
" : '';
+ }
+
+ /**
+ * Returns additional, service-specific information about this server.
+ *
+ * If a service class implements this method and supports the
+ * "search_api_service_extra" option, this method will be used to add extra
+ * information to the server's "View" tab.
+ *
+ * In the default theme implementation this data will be output in a table
+ * with two columns along with other, generic information about the server.
+ *
+ * @return array
+ * An array of additional server information, with each piece of information
+ * being an associative array with the following keys:
+ * - label: The human-readable label for this data.
+ * - info: The information, as HTML.
+ * - status: (optional) The status associated with this information. One of
+ * "info", "ok", "warning" or "error". Defaults to "info".
+ *
+ * @see supportsFeature()
+ */
+ public function getExtraInformation() {
+ return array();
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * Does nothing, by default.
+ */
+ public function postCreate() {
+ return;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation always returns FALSE.
+ */
+ public function postUpdate() {
+ return FALSE;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * By default, deletes all indexes from this server.
+ */
+ public function preDelete() {
+ $indexes = search_api_index_load_multiple(FALSE, array('server' => $this->server->machine_name));
+ foreach ($indexes as $index) {
+ // removeIndex() might throw exceptions, but this method mustn't.
+ try {
+ $this->removeIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $variables['%index'] = $index->name;
+ $variables['%server'] = $this->server->name;
+ watchdog_exception('search_api', $e, '%type while trying to remove index %index from deleted server %server: !message in %function (line %line of %file).', $variables);
+ }
+ }
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * Does nothing, by default.
+ */
+ public function addIndex(SearchApiIndex $index) {
+ return;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation always returns FALSE.
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ return FALSE;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * By default, removes all items from that index.
+ */
+ public function removeIndex($index) {
+ if (is_object($index) && empty($index->read_only)) {
+ $this->deleteItems('all', $index);
+ }
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::__construct().
+ *
+ * The default implementation returns a SearchApiQuery object.
+ */
+ public function query(SearchApiIndex $index, $options = array()) {
+ return new SearchApiQuery($index, $options);
+ }
+
+}
diff --git a/www/modules/contrib/search_api/js/search_api.admin.js b/www/modules/contrib/search_api/js/search_api.admin.js
new file mode 100644
index 000000000..875b5616e
--- /dev/null
+++ b/www/modules/contrib/search_api/js/search_api.admin.js
@@ -0,0 +1,53 @@
+/**
+ * @file
+ * Javascript enhancements for the Search API admin pages.
+ */
+
+(function ($) {
+
+/**
+ * Allows the re-ordering of enabled data alterations and processors.
+ */
+// Copied from filter.admin.js
+Backdrop.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.
+ Backdrop.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.backdropSetSummary(function (tabContext) {
+ return $checkbox.is(':checked') ? Backdrop.t('Enabled') : Backdrop.t('Disabled');
+ });
+ }
+
+ // Trigger our bound click handler to update elements to initial state.
+ $checkbox.triggerHandler('click.searchApiUpdate');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/www/modules/contrib/search_api/search_api.admin.inc b/www/modules/contrib/search_api/search_api.admin.inc
new file mode 100644
index 000000000..f86bb8d73
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.admin.inc
@@ -0,0 +1,2408 @@
+server][$index->machine_name] = $index;
+ if (!$show_config_status && $index->status != ENTITY_PLUS_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_PLUS_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');
+ $pre_server = 'admin/config/search/search_api/server';
+ $pre_index = 'admin/config/search/search_api/index';
+ $enable = '/enable';
+ 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_plus_status', array('status' => $server->status));
+ }
+ $row[] = $t_server;
+ $row[] = l($server->name, $url);
+ $links = array();
+ // The "Enable" function has no menu link, since a token is required. We add
+ // it as the first link, since it will most likely be the most useful link
+ // for a disabled server. (Same for indexes below.)
+ if (!$server->enabled) {
+ $links[] = array(
+ 'title' => $t_enable,
+ 'href' => $url . $enable,
+ 'query' => array('token' => backdrop_get_token($server->machine_name)),
+ );
+ }
+ $links = array_merge($links, menu_contextual_links('search-api-server', $pre_server, array($server->machine_name)));
+ $row[] = array(
+ 'data' => array(
+ '#type' => 'dropbutton',
+ '#links' => $links,
+ ),
+ );
+ $rows[] = _search_api_deep_copy($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_plus_status', array('status' => $index->status));
+ }
+ $row[] = ' ';
+ $row[] = $t_index;
+ $row[] = l($index->name, $url);
+ $links = array();
+ if (!$index->enabled && $server->enabled) {
+ $links[] = array(
+ 'title' => $t_enable,
+ 'href' => $url . $enable,
+ 'query' => array('token' => backdrop_get_token($index->machine_name)),
+ );
+ }
+ $links = array_merge($links, menu_contextual_links('search-api-index', $pre_index, array($index->machine_name)));
+ $row[] = array(
+ 'data' => array(
+ '#type' => 'dropbutton',
+ '#links' => $links,
+ ),
+ );
+ $rows[] = _search_api_deep_copy($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_plus_status', array('status' => $index->status));
+ }
+ $row[] = array(
+ 'data' => $t_index,
+ 'colspan' => 2,
+ );
+ $row[] = l($index->name, $url);
+ $links = menu_contextual_links('search-api-index', $pre_index, array($index->machine_name));
+ $row[] = array(
+ 'data' => array(
+ '#type' => 'dropbutton',
+ '#links' => $links,
+ ),
+ );
+ $rows[] = _search_api_deep_copy($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'));
+ $intro = '' . t('A search server and search index are used to execute searches. Several indexes can exist per server.
You need at least one server and one index to create searches on your site.') . '
';
+
+ return array(
+ '#prefix' => $intro,
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#attributes' => array('class' => array('search-api-overview')),
+ '#empty' => t('There are no search servers or indexes defined yet.'),
+ );
+}
+
+/**
+ * Form callback showing a form for adding a server.
+ */
+function search_api_admin_add_server(array $form, array &$form_state) {
+ backdrop_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'] = '
';
+
+ // If $info is not set, there are no service classes. Display an error message
+ // telling the user how to change that and return an empty form.
+ if (!isset($info)) {
+ backdrop_set_message(t('There are no service classes available for the Search API. Please install a module that provides a service class to proceed.', array('@url' => url('https://github.com/backdrop-contrib/search_api/wiki/Search-API-Documentation#service-class'))), 'error');
+ return array();
+ }
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create server'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form AJAX handler for search_api_admin_add_server().
+ *
+ * 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 handler 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;
+ if (!empty($form_state['values']['options']['form'])) {
+ $service->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+ }
+}
+
+/**
+ * Form submission handler 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;
+ backdrop_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;
+ backdrop_set_message(t('The server was successfully created.'));
+}
+
+/**
+ * Page callback: Displays information about a server.
+ *
+ * @param SearchApiServer $server
+ * The server to display.
+ * @param string|null $action
+ * (optional) An action to execute for the server. One of 'enable', 'disable'
+ * or 'clear'.
+ *
+ * @see search_api_menu()
+ */
+function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
+ if (!empty($action)) {
+ if ($action == 'enable') {
+ if (isset($_GET['token']) && backdrop_valid_token($_GET['token'], $server->machine_name)) {
+ if ($server->update(array('enabled' => 1))) {
+ backdrop_set_message(t('The server was successfully enabled.'));
+ }
+ else {
+ backdrop_set_message(t('The server could not be enabled. Check the logs for details.'), 'error');
+ }
+ backdrop_goto('admin/config/search/search_api/server/' . $server->machine_name);
+ }
+ else {
+ return MENU_ACCESS_DENIED;
+ }
+ }
+ else {
+ $ret = backdrop_get_form('search_api_admin_confirm', 'server', $action, $server);
+ if (!empty($ret['actions'])) {
+ return $ret;
+ }
+ }
+ }
+
+ backdrop_set_title(search_api_admin_item_title($server));
+ $class = search_api_get_service_info($server->class);
+ $options = $server->viewSettings();
+ $indexes = array();
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ if (!$indexes) {
+ $indexes['#theme'] = 'links';
+ $indexes['#attributes']['class'] = array('inline');
+ }
+ $indexes['#links'][] = array(
+ 'title' => $index->name,
+ 'href' => 'admin/config/search/search_api/index/' . $index->machine_name,
+ );
+ }
+ $render['view'] = array(
+ '#theme' => 'search_api_server',
+ '#id' => $server->id,
+ '#name' => $server->name,
+ '#machine_name' => $server->machine_name,
+ '#description' => $server->description,
+ '#enabled' => $server->enabled,
+ '#class_id' => $server->class,
+ '#class_name' => $class['name'],
+ '#class_description' => $class['description'],
+ '#indexes' => $indexes,
+ '#options' => $options,
+ '#status' => $server->status,
+ '#extra' => $server->getExtraInformation(),
+ );
+ $render['#attached']['css'][] = backdrop_get_path('module', 'search_api') . '/css/search_api.admin.css';
+ if ($server->enabled) {
+ $render['form'] = backdrop_get_form('search_api_server_status_form', $server);
+ }
+ return $render;
+}
+
+/**
+ * Returns HTML 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_id: The used service class' ID.
+ * - class_name: The used service class' display name.
+ * - class_description: The used service class' description.
+ * - indexes: A list of indexes associated with this server, either as an HTML
+ * string or a render array.
+ * - 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.).
+ * - extra: An array of additional server information in the format specified
+ * by SearchApiAbstractService::getExtraInformation().
+ *
+ * @return string
+ * HTML for displaying a server.
+ *
+ * @ingroup themeable
+ */
+function theme_search_api_server(array $variables) {
+ $machine_name = $variables['machine_name'];
+ $description = $variables['description'];
+ $enabled = $variables['enabled'];
+ $class_id = $variables['class_id'];
+ $class_name = $variables['class_name'];
+ $indexes = $variables['indexes'];
+ $options = $variables['options'];
+ $status = $variables['status'];
+ $extra = $variables['extra'];
+
+ // First, output the index description if there is one set.
+ $output = '';
+
+ if ($description) {
+ $output .= '' . nl2br(check_plain($description)) . '
';
+ }
+
+ // Then, display a table summarizing the index's status.
+ $rows = array();
+ // Create a row template with references so we don't have to deal with the
+ // complicated structure for each individual row.
+ $row = array(
+ 'data' => array(
+ array('header' => TRUE),
+ '',
+ ),
+ 'class' => array(''),
+ );
+ $label = & $row['data'][0]['data'];
+ $info = & $row['data'][1];
+ $class = & $row['class'][0];
+
+ if ($enabled) {
+ $class = 'ok';
+ $info = t('enabled (!disable_link)', array('!disable_link' => l(t('disable'), 'admin/config/search/search_api/server/' . $machine_name . '/disable')));
+ }
+ else {
+ $class = 'warning';
+ $info = t('disabled (!enable_link)', array('!enable_link' => l(t('enable'), 'admin/config/search/search_api/server/' . $machine_name . '/enable', array('query' => array('token' => backdrop_get_token($machine_name))))));
+ }
+ $label = t('Status');
+ $rows[] = _search_api_deep_copy($row);
+ $class = '';
+
+ $label = t('Service class');
+ if (module_exists('help')) {
+ $url_options['fragment'] = backdrop_clean_css_identifier($class_id);
+ $info = l($class_name, 'admin/help/search_api', $url_options);
+ }
+ else {
+ $info = check_plain($class_name);
+ }
+ $rows[] = _search_api_deep_copy($row);
+
+ if ($indexes) {
+ $label = t('Search indexes');
+ $info = render($indexes);
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ if ($options) {
+ $label = t('Service options');
+ $info = render($options);
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ if ($status != ENTITY_PLUS_CUSTOM) {
+ $label = t('Configuration status');
+ $info = theme('entity_plus_status', array('status' => $status));
+ $class = ($status == ENTITY_PLUS_OVERRIDDEN) ? 'warning' : 'ok';
+ $rows[] = _search_api_deep_copy($row);
+ $class = '';
+ }
+
+ if ($extra) {
+ foreach ($extra as $information) {
+ $label = $information['label'];
+ $info = $information['info'];
+ $class = !empty($information['status']) ? $information['status'] : '';
+ $rows[] = _search_api_deep_copy($row);
+ }
+ }
+
+ $theme['rows'] = $rows;
+ $theme['attributes']['class'][] = 'search-api-summary';
+ $theme['attributes']['class'][] = 'search-api-server-summary';
+ $theme['attributes']['class'][] = 'system-status-report';
+ $output .= theme('table', $theme);
+
+ return $output;
+}
+
+/**
+ * Form constructor for server operations.
+ *
+ * @param SearchApiServer $server
+ * The server for which the form is displayed.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_server_status_form_submit()
+ */
+function search_api_server_status_form(array $form, array &$form_state, SearchApiServer $server) {
+ $form_state['server'] = $server;
+
+ $form['clear'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete all indexed data on this server'),
+ '#submit' => array('search_api_server_status_form_clear_submit'),
+ );
+
+ $count = $server->enabled ? search_api_server_tasks_count($server) : 0;
+ if ($count) {
+ $message = format_plural($count, '@count pending task must be executed before indexing.', '@count pending tasks must be executed before indexing.');
+ backdrop_set_message($message, 'warning', FALSE);
+ $form['execute_pending_tasks'] = array(
+ '#type' => 'submit',
+ '#value' => t('Execute all pending tasks on this server'),
+ '#submit' => array('search_api_server_status_form_execute_pending_tasks_submit'),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Form submission handler for search_api_server_status_form().
+ *
+ * Used for the "Execute all pending tasks" button.
+ */
+function search_api_server_status_form_execute_pending_tasks_submit($form, &$form_state) {
+ $server_id = $form_state['server']->machine_name;
+ $form_state['redirect'] = "admin/config/search/search_api/server/$server_id/execute-tasks";
+}
+
+/**
+ * Form submission handler for search_api_server_status_form().
+ *
+ * Used for the "Delete all indexed data" button.
+ */
+function search_api_server_status_form_clear_submit(array $form, array &$form_state) {
+ $server_id = $form_state['server']->machine_name;
+ $form_state['redirect'] = "admin/config/search/search_api/server/$server_id/clear";
+}
+
+/**
+ * Form constructor for editing a server's settings.
+ *
+ * @param SearchApiServer $server
+ * The server to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_server_edit_validate()
+ * @see search_api_admin_server_edit_submit()
+ */
+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['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save settings'),
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#submit' => array('search_api_admin_form_delete_submit'),
+ '#limit_validation_errors' => array(),
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for search_api_admin_server_edit().
+ *
+ * @see search_api_admin_server_edit_submit()
+ */
+function search_api_admin_server_edit_validate(array $form, array &$form_state) {
+ if (!empty($form['options']['form']) && !empty($form_state['values']['options']['form'])) {
+ $form_state['server']->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+ }
+}
+
+/**
+ * Form submission handler for search_api_admin_server_edit().
+ *
+ * @see search_api_admin_server_edit_validate()
+ */
+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;
+ backdrop_set_message(t('The search server was successfully edited.'));
+}
+
+/**
+ * Form submission handler for search_api_admin_server_edit().
+ *
+ * Handles the 'Delete' button on the server and index edit forms.
+ *
+ * @see search_api_admin_server_edit()
+ * @see search_api_admin_index_edit()
+ */
+function search_api_admin_form_delete_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = backdrop_get_destination();
+ unset($_GET['destination']);
+ }
+ if (isset($form_state['server'])) {
+ $server = $form_state['server'];
+ $form_state['redirect'] = array('admin/config/search/search_api/server/' . $server->machine_name . '/delete', array('query' => $destination));
+ }
+ elseif (isset($form_state['index'])) {
+ $index = $form_state['index'];
+ $form_state['redirect'] = array('admin/config/search/search_api/index/' . $index->machine_name . '/delete', array('query' => $destination));
+ }
+}
+
+/**
+ * Form constructor for adding an index.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_add_index_ajax_callback()
+ * @see search_api_admin_add_index_validate()
+ * @see search_api_admin_add_index_submit()
+ */
+function search_api_admin_add_index(array $form, array &$form_state) {
+ backdrop_set_title(t('Add index'));
+
+ $old_type = empty($form_state['values']['item_type']) ? '' : $form_state['values']['item_type'];
+
+ $form['#attached']['css'][] = backdrop_get_path('module', 'search_api') . '/css/search_api.admin.css';
+ $form['#tree'] = TRUE;
+
+ if (empty($form_state['step_one'])) {
+ $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,
+ '#ajax' => array(
+ 'callback' => 'search_api_admin_add_index_ajax_callback',
+ 'wrapper' => 'search-api-datasource-options',
+ ),
+ );
+ $form['datasource'] = array();
+ 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 you also select a server for the index.'),
+ '#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, array('enabled' => 1));
+ // 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' => 'number',
+ '#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')),
+ '#min' => -1,
+ );
+ }
+ elseif (!$old_type) {
+ $old_type = $form_state['step_one']['item_type'];
+ }
+
+ if ($old_type) {
+ $datasource = search_api_get_datasource_controller($old_type);
+ $datasource_form = array();
+ $datasource_form = $datasource->configurationForm($datasource_form, $form_state);
+ if ($datasource_form) {
+ $form['datasource'] = $datasource_form;
+ $form['datasource']['#parents'] = array('options', 'datasource');
+ }
+ }
+ $form['datasource']['#prefix'] = '';
+ $form['datasource']['#suffix'] = '
';
+
+ $form['old_type'] = array(
+ '#type' => 'value',
+ '#value' => $old_type,
+ );
+ $form['datasource_config'] = array(
+ '#type' => 'value',
+ '#value' => !empty($datasource_form),
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create index'),
+ );
+
+ return $form;
+}
+
+/**
+ * AJAX submit callback for search_api_admin_add_index().
+ *
+ * Used for displaying the matching datasource configuration form for the
+ * selected item type.
+ */
+function search_api_admin_add_index_ajax_callback(array $form, array &$form_state) {
+ return $form['datasource'];
+}
+
+/**
+ * Form validation handler for search_api_admin_add_index().
+ *
+ * @see search_api_admin_add_index_submit()
+ */
+function search_api_admin_add_index_validate(array $form, array &$form_state) {
+ $values = $form_state['values'];
+ $name = $values['machine_name'];
+ if (is_numeric($name)) {
+ form_set_error('machine_name', t('The machine name must not be a pure number.'));
+ }
+
+ if (!$values['datasource_config'] || empty($values['item_type']) || $values['item_type'] != $values['old_type']) {
+ return;
+ }
+ $datasource = search_api_get_datasource_controller($values['item_type']);
+ $datasource->configurationFormValidate($form['datasource'], $form_state['values']['options']['datasource'], $form_state);
+}
+
+/**
+ * Form submission handler for search_api_admin_add_index().
+ *
+ * @see search_api_admin_add_index_validate()
+ */
+function search_api_admin_add_index_submit(array $form, array &$form_state) {
+ form_state_values_clean($form_state);
+ $values = $form_state['values'];
+
+ if (!empty($form_state['step_one'])) {
+ $values += $form_state['step_one'];
+ unset($form_state['step_one']);
+ }
+
+ // The type was changed (or the form submitted without JS for the first time).
+ // If the new type has a configuration form, we have to display it now.
+ $datasource = search_api_get_datasource_controller($values['item_type']);
+ if ($values['item_type'] != $values['old_type']) {
+ $datasource_form = array();
+ if ($datasource->configurationForm($datasource_form, $form_state)) {
+ unset($values['options']['datasource']);
+ $form_state['step_one'] = $values;
+ $form_state['rebuild'] = TRUE;
+ backdrop_set_message(t('Please specify further configuration options.'));
+ return;
+ }
+ }
+
+ // If the current type has a configuration form, call the datasource
+ // controller's config submit callback.
+ if ($values['datasource_config']) {
+ $datasource->configurationFormSubmit($form['datasource'], $values['options']['datasource'], $form_state);
+ }
+
+ // Validation of whether a server is set for the index is done in the
+ // SearchApiIndex::save() method.
+ search_api_index_insert($values);
+
+ backdrop_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';
+}
+
+/**
+ * Page callback for displaying an index's status.
+ *
+ * @param SearchApiIndex $index
+ * The index to display.
+ * @param string|null $action
+ * (optional) An action to execute for the index. One of "reindex", "clear",
+ * "enable" or "disable". For "disable", a confirm dialog will be shown.
+ *
+ * @see search_api_menu()
+ */
+function search_api_admin_index_view(SearchApiIndex $index, $action = NULL) {
+ if (!empty($action)) {
+ if ($action == 'enable') {
+ if (isset($_GET['token']) && backdrop_valid_token($_GET['token'], $index->machine_name)) {
+ if ($index->update(array('enabled' => 1))) {
+ backdrop_set_message(t('The index was successfully enabled.'));
+ }
+ else {
+ backdrop_set_message(t('The index could not be enabled. Check the logs for details.'), 'error');
+ }
+ backdrop_goto('admin/config/search/search_api/index/' . $index->machine_name);
+ }
+ else {
+ return MENU_ACCESS_DENIED;
+ }
+ }
+ else {
+ $ret = backdrop_get_form('search_api_admin_confirm', 'index', $action, $index);
+ if (!empty($ret['actions'])) {
+ return $ret;
+ }
+ }
+ }
+
+ $status = search_api_index_status($index);
+ try {
+ $server = $index->server();
+ }
+ catch (SearchApiException $e) {
+ $server = NULL;
+ $vars['%server'] = $index->server;
+ $message = t('The index has an unknown server (ID: %server) set. Please check the index settings.', $vars);
+ backdrop_set_message($message, 'error');
+ }
+ $ret['view'] = array(
+ '#theme' => 'search_api_index',
+ '#id' => $index->id,
+ '#name' => $index->name,
+ '#machine_name' => $index->machine_name,
+ '#description' => $index->description,
+ '#item_type' => $index->item_type,
+ '#datasource_config' => $index->datasource()->getConfigurationSummary($index),
+ '#enabled' => $index->enabled,
+ '#server' => $server,
+ '#options' => $index->options,
+ '#fields' => $index->getFields(),
+ '#indexed_items' => $status['indexed'],
+ '#on_server' => NULL,
+ '#total_items' => $status['total'],
+ '#status' => $index->status,
+ '#read_only' => $index->read_only,
+ );
+ try {
+ $ret['view']['#on_server'] = _search_api_get_items_on_server($index);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ if ($index->enabled && !$index->read_only) {
+ $ret['form'] = backdrop_get_form('search_api_admin_index_status_form', $index, $status);
+ }
+ return $ret;
+}
+
+/**
+ * Returns HTML for a search 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.
+ * - datasource_config: A summary of the datasource's configuration.
+ * - 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.
+ * - on_server: The number of items actually indexed on the server. NULL if
+ * the search for finding out the item count failed.
+ * - 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.
+ *
+ * @return string
+ * HTML for a search index.
+ *
+ * @ingroup themeable
+ */
+function theme_search_api_index(array $variables) {
+ $machine_name = $variables['machine_name'];
+ $description = $variables['description'];
+ $enabled = $variables['enabled'];
+ $item_type = $variables['item_type'];
+ $datasource_config = $variables['datasource_config'];
+ $server = $variables['server'];
+ $options = $variables['options'];
+ $status = $variables['status'];
+ $indexed_items = $variables['indexed_items'];
+ $on_server = $variables['on_server'];
+ $total_items = $variables['total_items'];
+
+ // First, output the index description if there is one set.
+ $output = '';
+
+ if ($description) {
+ $output .= '' . nl2br(check_plain($description)) . '
';
+ }
+
+ // Then, display a table summarizing the index's status.
+ $rows = array();
+ // Create a row template with references so we don't have to deal with the
+ // complicated structure for each individual row.
+ $row = array(
+ 'data' => array(
+ array('header' => TRUE),
+ '',
+ ),
+ 'class' => array(''),
+ );
+ $label = &$row['data'][0]['data'];
+ $info = &$row['data'][1];
+ $class = &$row['class'][0];
+
+ $class = 'warning';
+ if ($enabled) {
+ $info = t('enabled (!disable_link)', array('!disable_link' => l(t('disable'), 'admin/config/search/search_api/index/' . $machine_name . '/disable')));
+ $class = 'ok';
+ }
+ elseif ($server) {
+ $info = t('disabled (!enable_link)', array('!enable_link' => l(t('enable'), 'admin/config/search/search_api/index/' . $machine_name . '/enable', array('query' => array('token' => backdrop_get_token($machine_name))))));
+ }
+ else {
+ $info = t('disabled');
+ }
+ $label = t('Status');
+ $rows[] = _search_api_deep_copy($row);
+ $class = '';
+
+ $label = t('Item type');
+ $type = search_api_get_item_type_info($item_type);
+ $item_type = !empty($type['name']) ? $type['name'] : $item_type;
+ $info = check_plain($item_type);
+ $rows[] = _search_api_deep_copy($row);
+
+ if ($datasource_config) {
+ $label = t('Item type configuration');
+ $info = check_plain($datasource_config);
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ if ($server) {
+ $label = t('Server');
+ $info = l($server->name, 'admin/config/search/search_api/server/' . $server->machine_name);
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ if ($enabled) {
+ $options += array('cron_limit' => SEARCH_API_DEFAULT_CRON_LIMIT);
+ if ($options['cron_limit']) {
+ $class = 'ok';
+ $info = format_plural(
+ $options['cron_limit'],
+ 'During cron runs, 1 item will be indexed per batch.',
+ 'During cron runs, @count items will be indexed per batch.'
+ );
+ }
+ else {
+ $class = 'warning';
+ $info = t('No items will be indexed during cron runs.');
+ }
+ $label = t('Cron batch size');
+ $rows[] = _search_api_deep_copy($row);
+
+ $theme = array(
+ 'percent' => $total_items ? (int) (100 * $indexed_items / $total_items) : 100,
+ 'message' => t('@indexed/@total indexed', array('@indexed' => $indexed_items, '@total' => $total_items)),
+ );
+ $output .= '' . t('Index status') . '
';
+ $output .= '' . theme('progress_bar', $theme) . '
';
+
+ if (!isset($on_server)) {
+ $info = t('An error occurred while trying to determine the server index status. Please check the logs for details.');
+ $class = 'error';
+ }
+ else {
+ $vars['@url'] = url('https://github.com/backdrop-contrib/search_api/wiki/FAQ#what-is-the-server-index-status-for-a-search-index');
+ $info = format_plural($on_server, 'There is 1 item indexed on the server for this index. (More information)', 'There are @count items indexed on the server for this index. (More information)', $vars);
+ $class = '';
+ }
+ $label = t('Server index status');
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ if ($status != ENTITY_PLUS_CUSTOM) {
+ $label = t('Configuration status');
+ $info = theme('entity_plus_status', array('status' => $status));
+ $class = ($status == ENTITY_PLUS_OVERRIDDEN) ? 'warning' : 'ok';
+ $rows[] = _search_api_deep_copy($row);
+ }
+
+ $theme['rows'] = $rows;
+ $theme['attributes']['class'][] = 'search-api-summary';
+ $theme['attributes']['class'][] = 'search-api-index-summary';
+ $theme['attributes']['class'][] = 'system-status-report';
+ $output .= theme('table', $theme);
+
+ return $output;
+}
+
+/**
+ * Form constructor for an index status form.
+ *
+ * Should only be used for enabled indexes which aren't read-only.
+ *
+ * @param SearchApiIndex $index
+ * The index whose status should be displayed.
+ * @param array $status
+ * The indexing status of the index, as returned by search_api_index_status().
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_status_form_validate()
+ * @see search_api_admin_index_status_form_submit()
+ */
+function search_api_admin_index_status_form(array $form, array &$form_state, SearchApiIndex $index, array $status) {
+ $form['#attached']['css'][] = backdrop_get_path('module', 'search_api') . '/css/search_api.admin.css';
+ $form_state['index'] = $index;
+
+ $form['index'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Index now'),
+ );
+ $form['index']['#attributes']['class'][] = 'container-inline';
+
+ $allow_indexing = ($status['indexed'] < $status['total']);
+ $all = t('all', array(), array('context' => 'items to index'));
+ $limit = array(
+ '#type' => 'textfield',
+ '#default_value' => $all,
+ '#size' => 4,
+ '#attributes' => array('class' => array('search-api-limit')),
+ '#disabled' => !$allow_indexing,
+ );
+ $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
+ $batch_size = $batch_size > 0 ? $batch_size : $all;
+ $batch_size = array(
+ '#type' => 'textfield',
+ '#default_value' => $batch_size,
+ '#size' => 4,
+ '#attributes' => array('class' => array('search-api-batch-size')),
+ '#disabled' => !$allow_indexing,
+ );
+
+ // Here it gets complicated. We want to build a sentence from the form input
+ // elements, but to translate that we have to make the two form elements (for
+ // limit and batch size) pseudo-variables in the t() call. Since we can't
+ // pass them directly, we split the translated sentence (which still has the
+ // two tokens), figure out their order and then put the pieces together again
+ // using the form elements' #prefix and #suffix properties.
+ $sentence = t('Index @limit items in batches of @batch_size items');
+ $sentence = preg_split('/@(limit|batch_size)/', $sentence, -1, PREG_SPLIT_DELIM_CAPTURE);
+ if (count($sentence) == 5) {
+ $first = $sentence[1];
+ $form['index'][$first] = $$first;
+ $form['index'][$first]['#prefix'] = $sentence[0];
+ $form['index'][$first]['#suffix'] = $sentence[2];
+ $second = $sentence[3];
+ $form['index'][$second] = $$second;
+ $form['index'][$second]['#suffix'] = $sentence[4] . ' ';
+ }
+ else {
+ // PANIC!
+ $limit['#title'] = t('Number of items to index');
+ $form['index']['limit'] = $limit;
+ $batch_size['#title'] = t('Number of items per batch run');
+ $form['index']['batch_size'] = $batch_size;
+ }
+
+ $form['index']['button'] = array(
+ '#type' => 'submit',
+ '#value' => t('Index now'),
+ '#disabled' => !$allow_indexing,
+ );
+ $form['index']['total'] = array(
+ '#type' => 'value',
+ '#value' => $status['total'],
+ );
+ $form['index']['remaining'] = array(
+ '#type' => 'value',
+ '#value' => $status['total'] - $status['indexed'],
+ );
+ $form['index']['all'] = array(
+ '#type' => 'value',
+ '#value' => $all,
+ );
+
+ $form['reindex'] = array(
+ '#type' => 'submit',
+ '#value' => t('Queue all items for reindexing'),
+ '#prefix' => '',
+ '#suffix' => '
',
+ );
+ $form['clear'] = array(
+ '#type' => 'submit',
+ '#value' => t('Clear all indexed data'),
+ '#prefix' => '',
+ '#suffix' => '
',
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for search_api_admin_index_status_form().
+ *
+ * @see search_api_admin_index_status_form_submit()
+ */
+function search_api_admin_index_status_form_validate(array $form, array &$form_state) {
+ $values = $form_state['values'];
+ if ($values['op'] == t('Index now')) {
+ $all_lower = backdrop_strtolower($values['all']);
+ foreach (array('limit', 'batch_size') as $field) {
+ $val = trim($values[$field]);
+ if (backdrop_strtolower($val) == $all_lower) {
+ $val = -1;
+ }
+ elseif (!$val || !is_numeric($val) || ((int) $val) != $val) {
+ form_error($form['index'][$field], t('Enter a non-zero integer. Use "-1" or "@all" for "all items".', array('@all' => $values['all'])));
+ }
+ else {
+ $val = (int) $val;
+ }
+ $form_state['values'][$field] = $val;
+ }
+ }
+}
+
+/**
+ * Form submission handler for search_api_admin_index_status_form().
+ *
+ * @see search_api_admin_index_status_form_validate()
+ */
+function search_api_admin_index_status_form_submit(array $form, array &$form_state) {
+ $values = $form_state['values'];
+ $index = $form_state['index'];
+ $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name;
+
+ // There is a Form API bug here that will let a user submit the form via the
+ // "Index now" button even if it is disabled, and then just set "op" to the
+ // value of an arbitrary other button. We therefore have to take care to spot
+ // this case ourselves.
+ if ($form_state['input']['op'] == t('Index now') && !empty($form['index']['button']['#disabled'])) {
+ backdrop_set_message(t('All items have already been indexed.'), 'warning');
+ return;
+ }
+
+ switch ($values['op']) {
+ case t('Index now'):
+ if (!_search_api_batch_indexing_create($index, $values['batch_size'], $values['limit'], $values['remaining'])) {
+ backdrop_set_message(t("Couldn't create a batch, please check the batch size and limit."), 'warning');
+ }
+ break;
+
+ case t('Queue all items for reindexing'):
+ $form_state['redirect'] .= '/reindex';
+ break;
+
+ case t('Clear all indexed data'):
+ $form_state['redirect'] .= '/clear';
+ break;
+ }
+}
+
+/**
+ * Form constructor for editing an index's settings.
+ *
+ * @param SearchApiIndex $index
+ * The index to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_edit_validate()
+ * @see search_api_admin_index_edit_submit()
+ */
+function search_api_admin_index_edit(array $form, array &$form_state, SearchApiIndex $index) {
+ $form_state['index'] = $index;
+
+ $form['#attached']['css'][] = backdrop_get_path('module', 'search_api') . '/css/search_api.admin.css';
+ $form['#tree'] = TRUE;
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Index name'),
+ '#maxlength' => 50,
+ '#default_value' => $index->name,
+ '#required' => TRUE,
+ );
+ try {
+ $enabled_fixed = !$index->server();
+ }
+ catch (Exception $e) {
+ watchdog_exception('search_api', $e);
+ // The exception only occurs if the index is disabled, and for an unknown
+ // server we of course want do prevent the index from being enabled.
+ $enabled_fixed = TRUE;
+ }
+ $form['enabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enabled'),
+ '#default_value' => $index->enabled,
+ // Can't enable an index that's not lying on any server.
+ '#disabled' => $enabled_fixed,
+ );
+ $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, array('enabled' => 1));
+ // List enabled servers first.
+ foreach ($servers as $server) {
+ $form['server']['#options'][$server->machine_name] = $server->name;
+ }
+
+ $datasource_form = !empty($form['options']['datasource']) ? $form['options']['datasource'] : array();
+ $datasource_form = $index->datasource()->configurationForm($datasource_form, $form_state);
+ if ($datasource_form) {
+ $form['options']['datasource'] = $datasource_form;
+ $form['options']['datasource']['#type'] = 'fieldset';
+ $form['options']['datasource']['#title'] = t('Datasource options');
+ }
+
+ $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' => 'number',
+ '#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')),
+ '#min' => -1,
+ '#states' => array(
+ 'invisible' => array(':input[name="read_only"]' => array('checked' => TRUE)),
+ ),
+ );
+
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save settings'),
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#submit' => array('search_api_admin_form_delete_submit'),
+ '#limit_validation_errors' => array(),
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for search_api_admin_index_edit().
+ *
+ * @see search_api_admin_index_edit_submit()
+ */
+function search_api_admin_index_edit_validate(array $form, array &$form_state) {
+ if (!empty($form['options']['datasource'])) {
+ $form_state['values']['options'] += array('datasource' => array());
+ $form_state['index']->datasource()->configurationFormValidate($form['options']['datasource'], $form_state['values']['options']['datasource'], $form_state);
+ }
+}
+
+/**
+ * Form submission handler for search_api_admin_index_edit().
+ *
+ * @see search_api_admin_index_edit_validate()
+ */
+function search_api_admin_index_edit_submit(array $form, array &$form_state) {
+ form_state_values_clean($form_state);
+ $values = $form_state['values'];
+ /** @var SearchApiIndex $index */
+ $index = $form_state['index'];
+
+ if (!empty($form['options']['datasource'])) {
+ $index->datasource()->configurationFormSubmit($form['options']['datasource'], $values['options']['datasource'], $form_state);
+ }
+
+ $values['options'] += $index->options;
+
+ $ret = $index->update($values);
+ $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name;
+ if ($ret) {
+ backdrop_set_message(t('The search index was successfully edited.'));
+ }
+ else {
+ backdrop_set_message(t('No values were changed.'));
+ }
+}
+
+/**
+ * Form constructor for editing an index's data alterations and processors.
+ *
+ * @param SearchApiIndex $index
+ * The index to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_workflow_validate()
+ * @see search_api_admin_index_workflow_submit()
+ */
+// Copied from filter_admin_format_form()
+/**
+ * @todo Please document this function.
+ * @see http://drupal.org/node/1354
+ */
+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'][] = backdrop_get_path('module', 'search_api') . '/js/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.
Also, some processors shouldn't be used with more advanced search engines (like Solr or Elasticsearch), since the search engine already provides this functionality.") . '
',
+ '#collapsible' => TRUE,
+ );
+ if ($index->server) {
+ $form['processors']['#description'] .= '' . t("Check the server's service class description for details.",
+ array('@server-url' => url('admin/config/search/search_api/server/' . $index->server . '/edit'))) . '
';
+ }
+
+ // 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(
+ backdrop_render($element[$name]['item']),
+ backdrop_render($element[$name]['weight']),
+ ),
+ 'class' => array('draggable'),
+ );
+ }
+ $output = backdrop_render_children($element);
+ $output .= theme('table', array('rows' => $rows, 'attributes' => array('id' => $element['#table_id'])));
+ backdrop_add_tabledrag($element['#table_id'], 'order', 'sibling', 'search-api-order-weight', NULL, NULL, TRUE);
+
+ return $output;
+}
+
+/**
+ * Form validation handler for search_api_admin_index_workflow().
+ *
+ * @see search_api_admin_index_workflow_submit()
+ */
+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);
+ }
+ }
+}
+
+/**
+ * Form submission handler for search_api_admin_index_workflow().
+ *
+ * @see search_api_admin_index_workflow_validate()
+ */
+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'];
+ $index_path = 'admin/config/search/search_api/index/' . $index->machine_name;
+
+ $options = empty($index->options) ? array() : $index->options;
+
+ // 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'])) {
+ $callbacks_changed = TRUE;
+ 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 alteration.
+ 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,
+ );
+ }
+ }
+ }
+ }
+ }
+
+ 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');
+
+ // Re-calculate the fields, since they might have changed in hard-to-predict
+ // ways.
+ search_api_index_recalculate_fields(array($index));
+
+ $index->save();
+ $index->reindex();
+ $vars = array('@url' => url($index_path));
+ backdrop_set_message(t('The indexing workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect.', $vars));
+ }
+ else {
+ backdrop_set_message(t('No values were changed.'));
+ }
+
+ $form_state['redirect'] = $index_path . '/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;
+}
+
+/**
+ * Form constructor for setting the indexed fields.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_fields_submit()
+ */
+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());
+ $entity_types = entity_get_info();
+ $boosts = backdrop_map_assoc(array('0.0', '0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0'));
+
+ $fulltext_types = array(0 => array('text'));
+ // Add all custom data types with fallback "text" to fulltext types as well.
+ foreach (search_api_get_data_type_info() as $id => $type) {
+ if ($type['fallback'] != 'text') {
+ continue;
+ }
+ $fulltext_types[0][] = $id;
+ }
+
+ $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. Fields indexed with type "Fulltext" and multi-valued fields (marked with 1) cannot be used for sorting. ' .
+ '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 . '/edit'))) . '
';
+ }
+ foreach ($fields as $key => $info) {
+ $form['fields'][$key]['title']['#markup'] = check_plain($info['name']);
+ if (search_api_is_list_type($info['type'])) {
+ $form['fields'][$key]['title']['#markup'] .= ' 1';
+ $multi_valued_field_present = TRUE;
+ }
+ $form['fields'][$key]['machine_name']['#markup'] = check_plain($key);
+ 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 (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;
+ }
+ foreach ($fulltext_types[0] as $type) {
+ $fulltext_types[$level][] = $type_prefix . $type . $type_suffix;
+ }
+ }
+ $css_key = '#edit-fields-' . backdrop_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),
+ ),
+ ),
+ );
+ // Only add the multiple visible states if the VERSION string is >= 7.14.
+ // See https://drupal.org/node/1464758.
+ if (version_compare(BACKDROP_VERSION, '1.17.0', '>=')) {
+ foreach ($fulltext_types[$level] as $type) {
+ $form['fields'][$key]['boost']['#states']['visible'][$css_key . '-type'][] = array('value' => $type);
+ }
+ }
+ else {
+ $form['fields'][$key]['boost']['#states']['visible'][$css_key . '-type'] = array('value' => reset($fulltext_types[$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;
+ }
+ }
+
+ if (!empty($multi_valued_field_present)) {
+ $form['note']['#markup'] = '1 ' . t('Multi-valued field') . '
';
+ }
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save changes'),
+ );
+
+ if ($additional) {
+ asort($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.
+ *
+ * @return string
+ * The HTML for a field list form.
+ */
+function theme_search_api_admin_fields_table($variables) {
+ $form = $variables['element'];
+ $header = array(t('Field'), t('Machine name'), 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[] = _search_api_deep_copy($row);
+ }
+ else {
+ $rows[] = array(
+ 'data' => $row,
+ 'title' => strip_tags($form['fields'][$name]['description']['#value']),
+ );
+ }
+ }
+
+ $note = isset($form['note']) ? $form['note'] : '';
+ $submit = $form['submit'];
+ $additional = isset($form['additional']) ? $form['additional'] : FALSE;
+ unset($form['note'], $form['submit'], $form['additional']);
+ $output = backdrop_render_children($form);
+ $output .= theme('table', array('header' => $header, 'rows' => $rows));
+ $output .= render($note);
+ $output .= render($submit);
+ if ($additional) {
+ $output .= render($additional);
+ }
+
+ return $output;
+}
+
+/**
+ * Form submission handler 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();
+ $index_path = 'admin/config/search/search_api/index/' . $index->machine_name;
+ 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) {
+ $vars = array('@url' => $index_path);
+ backdrop_set_message(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.', $vars));
+ }
+ else {
+ backdrop_set_message(t('No values were changed.'));
+ }
+ if (isset($index->options['data_alter_callbacks']) || isset($index->options['processors'])) {
+ $form_state['redirect'] = $index_path . '/fields';
+ }
+ else {
+ backdrop_set_message(t('Please set up the indexing workflow.'));
+ $form_state['redirect'] = $index_path . '/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) {
+ backdrop_set_message(t('The available fields were successfully changed.'));
+ }
+ else {
+ backdrop_set_message(t('No values were changed.'));
+ }
+ $form_state['redirect'] = $index_path . '/fields';
+}
+
+/**
+ * Form constructor for a generic confirmation form.
+ *
+ * @param $type
+ * The type of entity (not the real "entity type"). Either "server" or
+ * "index".
+ * @param $action
+ * The action that would be executed for this entity after confirming. One of
+ * "reindex" ("index" type only), "clear", "disable" or "delete".
+ * @param Entity $entity
+ * The entity for which the action would be performed. Must have a "name"
+ * property.
+ *
+ * @return array|false
+ * 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 'clear':
+ $text = array(
+ t('Clear server @name', array('@name' => $entity->name)),
+ t('Do you really want to clear all indexed data from this server?'),
+ t('This will permanently remove all data currently indexed on this server. Before the data is reindexed, searches on the indexes associated with this server will not return any results. This action cannot be undone. Use with caution!'),
+ t("The server's indexed data was successfully cleared."),
+ );
+ break;
+
+ case 'disable':
+ $text = array(
+ t('Disable server @name', array('@name' => $entity->name)),
+ t('Do you really want to disable this server?'),
+ t('This will disconnect all indexes from this server and disable them. Searches on these indexes will not be available until they are added to another server and re-enabled. All indexed data (except for read-only indexes) on this server will be cleared.'),
+ t('The server and its indexes were successfully disabled.'),
+ );
+ break;
+
+ case 'delete':
+ if ($entity->hasStatus(ENTITY_PLUS_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 'reindex':
+ $text = array(
+ t('Re-index index @name', array('@name' => $entity->name)),
+ t('Do you really want to queue all items on this index for re-indexing?'),
+ t('This will mark all items for this index to be marked as needing to be indexed. Searches on this index will continue to yield results while the items are being re-indexed. This action cannot be undone.'),
+ t('The index was successfully marked for re-indexing.'),
+ );
+ break;
+
+ case 'clear':
+ $text = array(
+ t('Clear index @name', array('@name' => $entity->name)),
+ t('Do you really want to clear the indexed data of this index?'),
+ t('This will remove all data currently indexed for this index. Before the data is reindexed, searches on the index will not return any results. This action cannot be undone.'),
+ t('The index was successfully cleared.'),
+ );
+ break;
+
+ 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_PLUS_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'];
+
+ $success = FALSE;
+ $function = "search_api_{$type}_{$action}";
+ try {
+ // Some actions, like disabling, can actually throw an exception.
+ $success = $function($id);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ if ($success) {
+ backdrop_set_message($values['message']);
+ }
+ else {
+ backdrop_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/www/modules/contrib/search_api/search_api.api.php b/www/modules/contrib/search_api/search_api.api.php
new file mode 100644
index 000000000..94357b491
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.api.php
@@ -0,0 +1,611 @@
+ 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 services. All properties that are available in
+ * hook_search_api_service_info() can be altered here, with the addition of the
+ * "module" key specifying the module that originally defined the service class.
+ *
+ * @param array $service_info
+ * The Search API service info array, keyed by service id.
+ *
+ * @see hook_search_api_service_info()
+ */
+function hook_search_api_service_info_alter(array &$service_info) {
+ foreach ($service_info as $id => $info) {
+ $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.
+ * - entity_type: (optional) If the type represents entities, the entity type.
+ * This is used by SearchApiAbstractDataSourceController for determining the
+ * entity type of items. Other datasource controllers might ignore this.
+ * Other, datasource-specific settings might also be placed here. These should
+ * be specified with the data source controller in question.
+ *
+ * @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_plus_get_property_info() as $type => $property_info) {
+ if ($info = entity_get_info($type)) {
+ $types[$type] = array(
+ 'name' => $info['label'],
+ 'datasource controller' => 'SearchApiEntityDataSourceController',
+ 'entity_type' => $type,
+ );
+ }
+ }
+
+ 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, with the addition of
+ * the "module" key specifying the module that originally defined the type.
+ *
+ * @param array $infos
+ * The item type info array, keyed by type identifier.
+ *
+ * @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';
+}
+
+/**
+ * Define available data alterations.
+ *
+ * Registers one or more callbacks that can be called at index time to add
+ * additional data to the indexed items (e.g. comments or attachments to nodes),
+ * alter the data in other forms or remove items from the array.
+ *
+ * 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;
+}
+
+/**
+ * Alter the available data alterations.
+ *
+ * @param array $callbacks
+ * The callback information to be altered, keyed by callback IDs.
+ *
+ * @see hook_search_api_alter_callback_info()
+ */
+function hook_search_api_alter_callback_info_alter(array &$callbacks) {
+ if (!empty($callbacks['example_random_alter'])) {
+ $callbacks['example_random_alter']['name'] = t('Even more random alteration');
+ $callbacks['example_random_alter']['class'] = 'ExampleUltraRandomAlter';
+ }
+}
+
+/**
+ * Registers one or more processors. These are classes implementing the
+ * SearchApiProcessorInterface interface which can be used at index and search
+ * 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;
+}
+
+/**
+ * Alter the available processors.
+ *
+ * @param array $processors
+ * The processor information to be altered, keyed by processor IDs.
+ *
+ * @see hook_search_api_processor_info()
+ */
+function hook_search_api_processor_info_alter(array &$processors) {
+ if (!empty($processors['example_processor'])) {
+ $processors['example_processor']['weight'] = -20;
+ }
+}
+
+/**
+ * Allows you to log or alter the items that are indexed.
+ *
+ * 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));
+}
+
+/**
+ * Allows modules to react after items were indexed.
+ *
+ * @param SearchApiIndex $index
+ * The used index.
+ * @param array $item_ids
+ * An array containing the indexed items' IDs.
+ */
+function hook_search_api_items_indexed(SearchApiIndex $index, array $item_ids) {
+ if ($index->getEntityType() == 'node') {
+ // Flush page cache of the search page.
+ cache_clear_all(url('search'), 'cache_page');
+ }
+}
+
+/**
+ * Lets modules alter a search query before executing it.
+ *
+ * @param SearchApiQueryInterface $query
+ * The search query being executed.
+ */
+function hook_search_api_query_alter(SearchApiQueryInterface $query) {
+ // Exclude entities with ID 0. (Assume the ID field is always indexed.)
+ if ($query->getIndex()->getEntityType()) {
+ $info = entity_get_info($query->getIndex()->getEntityType());
+ $query->condition($info['entity keys']['id'], 0, '<>');
+ }
+}
+
+/**
+ * Alter the search results before they are returned.
+ *
+ * @param array $results
+ * The results returned by the server, which may be altered. The data
+ * structure is the same as returned by SearchApiQueryInterface::execute().
+ * @param SearchApiQueryInterface $query
+ * The search query that was executed.
+ */
+function hook_search_api_results_alter(array &$results, SearchApiQueryInterface $query) {
+ if ($query->getOption('search id') == 'search_api_views:my_search_view:page') {
+ // Log the number of results.
+ $vars = array(
+ '@keys' => $query->getOriginalKeys(),
+ '@num' => $results['result count'],
+ );
+ watchdog('my_module', 'Search view with query "@keys" had @num results.', $vars, WATCHDOG_DEBUG);
+ }
+}
+
+/**
+ * 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 mixed $value
+ * The raw, single value, as extracted from an entity wrapper.
+ * @param string $original_type
+ * The original Entity API type of the value.
+ * @param string $type
+ * The custom data type to which the value should be converted. Can be ignored
+ * if the callback is only used for a single data type.
+ *
+ * @return mixed|null
+ * 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/www/modules/contrib/search_api/search_api.drush.inc b/www/modules/contrib/search_api/search_api.drush.inc
new file mode 100644
index 000000000..16d447149
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.drush.inc
@@ -0,0 +1,727 @@
+ '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-enable'] = array(
+ 'description' => 'Enable one or all disabled search_api indexes.',
+ 'examples' => array(
+ 'drush searchapi-enable' => dt('Enable all disabled indexes.'),
+ 'drush sapi-en' => dt('Alias to enable all disabled indexes.'),
+ 'drush sapi-en 1' => dt('Enable index with the ID !id.', array('!id' => 1)),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index to enable.'),
+ ),
+ 'aliases' => array('sapi-en'),
+ );
+
+ $items['search-api-disable'] = array(
+ 'description' => 'Disable one or all enabled search_api indexes.',
+ 'examples' => array(
+ 'drush searchapi-disable' => dt('Disable all enabled indexes.'),
+ 'drush sapi-dis' => dt('Alias to disable all enabled indexes.'),
+ 'drush sapi-dis 1' => dt('Disable index with the ID !id.', array('!id' => 1)),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index to disable.'),
+ ),
+ 'aliases' => array('sapi-dis'),
+ );
+
+ $items['search-api-status'] = array(
+ 'description' => 'Show the status of one or all search indexes.',
+ 'examples' => array(
+ '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)),
+ 'drush sapi-i 0 0 100' => dt("Index all items of all indexes with !batch_size items per batch run.", array('!batch_size' => 100)),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index. Set to 0 to index all indexes. Defaults to 0 (index all).'),
+ 'limit' => dt("The number of items to index (index's cron batch size items per run). Set to 0 to index all items. Defaults to 0 (index all)."),
+ 'batch_size' => dt("The number of items to index per batch run. Set to 0 to index all items at once. Defaults to the index's cron batch size."),
+ ),
+ '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-reindex-items'] = array(
+ 'description' => 'Force re-indexing of one or more specific items.',
+ 'examples' => array(
+ 'drush search-api-reindex-items node 12,34,56' => dt('Schedule the nodes with ID 12, 34 and 56 for re-indexing.'),
+ ),
+ 'arguments' => array(
+ 'entity_type' => dt('The entity type whose items should be re-indexed.'),
+ 'entities' => dt('The entities of the given entity type to be re-indexed.'),
+ ),
+ 'aliases' => array('sapi-ri'),
+ );
+
+ $items['search-api-clear'] = array(
+ 'description' => 'Clear one or all search indexes and mark them for re-indexing.',
+ 'examples' => array(
+ 'drush searchapi-clear' => dt('Clear all search indexes.'),
+ 'drush sapi-c' => dt('Alias to clear all search indexes.'),
+ 'drush sapi-c 1' => dt('Clear the search index with the ID !id.', array('!id' => 1)),
+ 'drush sapi-c default_node_index' => dt('Clear the search index with the machine name !name.', array('!name' => 'default_node_index')),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index.'),
+ ),
+ 'aliases' => array('sapi-c'),
+ );
+
+ $items['search-api-execute-tasks'] = array(
+ 'description' => 'Execute all pending tasks or all for a given server.',
+ 'examples' => array(
+ 'drush search-api-execute-tasks my_solr_server' => dt('Execute all pending tasks on !server', array('!server' => 'my_solr_server')),
+ 'drush sapi-et my_solr_server' => dt('Execute all pending tasks on !server', array('!server' => 'my_solr_server')),
+ 'drush sapi-et' => dt('Execute all pending tasks on all servers.'),
+ ),
+ 'arguments' => array(
+ 'server_id' => dt('The numeric ID or machine name of a server to execute tasks on.'),
+ ),
+ 'aliases' => array('sapi-et'),
+ );
+
+ $items['search-api-set-index-server'] = array(
+ 'description' => 'Set the search server used by a given index.',
+ 'examples' => array(
+ 'drush search-api-set-index-server default_node_index my_solr_server' => dt('Set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+ 'drush sapi-sis default_node_index my_solr_server' => dt('Alias to set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index.'),
+ 'server_id' => dt('The numeric ID or machine name of a server to set on the index.'),
+ ),
+ 'aliases' => array('sapi-sis'),
+ );
+
+ $items['search-api-server-list'] = array(
+ 'description' => 'List all search servers.',
+ 'examples' => array(
+ 'drush search-api-server-list' => dt('List all search servers.'),
+ 'drush sapi-sl' => dt('Alias to list all search servers.'),
+ ),
+ 'aliases' => array('sapi-sl'),
+ );
+
+ $items['search-api-server-enable'] = array(
+ 'description' => 'Enable a search server.',
+ 'examples' => array(
+ 'drush search-api-server-e my_solr_server' => dt('Enable the !server search server.', array('!server' => 'my_solr_server')),
+ 'drush sapi-se my_solr_server' => dt('Alias to enable the !server search server.', array('!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'server_id' => dt('The numeric ID or machine name of a search server to enable.'),
+ ),
+ 'aliases' => array('sapi-se'),
+ );
+
+ $items['search-api-server-disable'] = array(
+ 'description' => 'Disable a search server.',
+ 'examples' => array(
+ 'drush search-api-server-disable' => dt('Disable the !server search server.', array('!server' => 'my_solr_server')),
+ 'drush sapi-sd' => dt('Alias to disable the !server search server.', array('!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'server_id' => dt('The numeric ID or machine name of a search server to disable.'),
+ ),
+ 'aliases' => array('sapi-sd'),
+ );
+
+ return $items;
+}
+
+
+/**
+ * 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;
+ try {
+ $server = $index->server();
+ $server = $server ? $server->name : '(' . dt('none') . ')';
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ $server = '(' . dt('unknown: !server', array('server' => $index->server)) . ')';
+ }
+ $row = array(
+ $index->id,
+ $index->name,
+ $index->machine_name,
+ $server,
+ $type,
+ $index->enabled ? dt('enabled') : dt('disabled'),
+ $index->options['cron_limit'],
+ );
+ $rows[] = $row;
+ }
+ drush_print_table($rows);
+}
+
+/**
+ * Enable index(es).
+ *
+ * @param string|integer $index_id
+ * The index name or id which should be enabled.
+ */
+function drush_search_api_enable($index_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $indexes = search_api_drush_get_index($index_id);
+ if (empty($indexes)) {
+ return;
+ }
+ foreach ($indexes as $index) {
+ $vars = array('!index' => $index->name);
+ if (!$index->enabled) {
+ drush_log(dt("Enabling index !index and queueing items for indexing.", $vars), 'notice');
+ $success = FALSE;
+ try {
+ if ($success = search_api_index_enable($index->id)) {
+ drush_log(dt("The index !index was successfully enabled.", $vars), 'ok');
+ }
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if (!$success) {
+ drush_log(dt("Error enabling index !index.", $vars), 'error');
+ }
+ }
+ else {
+ drush_log(dt("The index !index is already enabled.", $vars), 'error');
+ }
+ }
+}
+
+/**
+ * Disable index(es).
+ *
+ * @param string|integer $index_id
+ * The index name or id which should be disabled.
+ */
+function drush_search_api_disable($index_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $indexes = search_api_drush_get_index($index_id);
+ if (empty($indexes)) {
+ return;
+ }
+ foreach ($indexes as $index) {
+ $vars = array('!index' => $index->name);
+ if ($index->enabled) {
+ $success = FALSE;
+ try {
+ if ($success = search_api_index_disable($index->id)) {
+ drush_log(dt("The index !index was successfully disabled.", $vars), 'ok');
+ }
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if (!$success) {
+ drush_log(dt("Error disabling index !index.", $vars), 'error');
+ }
+ }
+ else {
+ drush_log(dt("The index !index is already disabled.", $vars), 'error');
+ }
+ }
+}
+
+/**
+ * Display index status.
+ */
+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;
+ }
+ $index_id = !empty($index_id) ? $index_id : NULL;
+ $indexes = search_api_drush_get_index($index_id);
+ if (empty($indexes)) {
+ return;
+ }
+
+ $process_batch = FALSE;
+ foreach ($indexes as $index) {
+ if (_drush_search_api_batch_indexing_create($index, $limit, $batch_size)) {
+ $process_batch = TRUE;
+ }
+ }
+
+ if ($process_batch) {
+ drush_backend_batch_process();
+ }
+}
+
+/**
+ * Creates and sets a batch for indexing items for a particular index.
+ *
+ * @param SearchApiIndex $index
+ * The index for which items should be indexed.
+ * @param int $limit
+ * (optional) The maximum number of items to index, or NULL to index all
+ * items.
+ * @param int $batch_size
+ * (optional) The number of items to index per batch, or NULL to index all
+ * items at once.
+ *
+ * @return bool
+ * TRUE if batch was created, FALSE otherwise.
+ */
+function _drush_search_api_batch_indexing_create(SearchApiIndex $index, $limit = NULL, $batch_size = NULL) {
+ // Get the number of remaining items to index.
+ try {
+ $datasource = $index->datasource();
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ return FALSE;
+ }
+ $index_status = $datasource->getIndexStatus($index);
+ $remaining = $index_status['total'] - $index_status['indexed'];
+ if ($remaining <= 0) {
+ drush_log(dt("The index !index is up to date.", array('!index' => $index->name)), 'ok');
+ return FALSE;
+ }
+
+ // Get the number of items to index per batch run.
+ if (!isset($batch_size)) {
+ $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
+ }
+ elseif ($batch_size <= 0) {
+ $batch_size = $remaining;
+ }
+
+ // Get the total number of items to index.
+ if (!isset($limit) || !is_int($limit += 0) || $limit <= 0) {
+ $limit = $remaining;
+ }
+
+ drush_log(dt("Indexing a maximum number of !limit items (!batch_size items per batch run) for the index !index.", array('!index' => $index->name, '!limit' => $limit, '!batch_size' => $batch_size)), 'ok');
+
+ // Create the batch.
+ if (!_search_api_batch_indexing_create($index, $batch_size, $limit, $remaining, TRUE)) {
+ drush_log(dt("Couldn't create a batch, please check the batch size and limit parameters."), 'error');
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * 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');
+ }
+}
+
+/**
+ * Marks the given entities as needing to be re-indexed.
+ */
+function drush_search_api_reindex_items($entity_type, $entities) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+
+ // Validate list of entity ids.
+ if (!empty($entities) && !preg_match('#^[0-9]*(,[0-9]*)*$#', $entities)) {
+ drush_log(dt('Entities should be a single numeric entity ID or a list with the numeric entity IDs separated by comma.'), 'error');
+ return;
+ }
+
+ $ids = explode(',', $entities);
+
+ if (!empty($ids)) {
+ search_api_track_item_change($entity_type, $ids);
+
+ $combined_ids = array();
+ foreach ($ids as $id) {
+ $combined_ids[] = $entity_type . '/' . $id;
+ }
+ search_api_track_item_change('multiple', $combined_ids);
+ }
+}
+
+/**
+ * Clear an index.
+ */
+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');
+ }
+}
+
+/**
+ * Execute all pending tasks or all for a given server.
+ */
+function drush_search_api_execute_tasks($server_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+
+ // Attempt to load the associated server.
+ $server = NULL;
+ if ($server_id) {
+ $servers = search_api_drush_get_server($server_id);
+ if (!$servers) {
+ return;
+ }
+ $server = reset($servers);
+ }
+
+ // Process batch op with drush.
+ try {
+ search_api_execute_pending_tasks($server);
+ if ($server) {
+ drush_log(dt('!server tasks have been successfully executed.', array('!server' => $server->machine_name ?: 'All')), 'ok');
+ }
+ else {
+ drush_log(dt('All tasks have been successfully executed.'), 'ok');
+ }
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+}
+
+/**
+ * Set the server for a given index.
+ */
+function drush_search_api_set_index_server($index_id = NULL, $server_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ // Make sure we have parameters to work with.
+ if (empty($index_id) || empty($server_id)) {
+ drush_log(dt('You must specify both an index and server.'), 'error');
+ return;
+ }
+ // Fetch current index and server data.
+ $indexes = search_api_drush_get_index($index_id);
+ $servers = search_api_drush_get_server($server_id);
+ if (empty($indexes) || empty($servers)) {
+ // If the specified index or server can't be found, just return. An
+ // appropriate error message should have been printed already.
+ return;
+ }
+ // Set the new server on the index.
+ $success = FALSE;
+ $index = reset($indexes);
+ $server = reset($servers);
+ try {
+ $success = $index->update(array('server' => $server->machine_name));
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if ($success === FALSE) {
+ drush_log(dt('There was an error setting index !index to use server !server.', array('!index' => $index->name, '!server' => $server->name)), 'error');
+ }
+ elseif (!$success) {
+ drush_log(dt('Index !index was already using server !server.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+ }
+ else {
+ drush_log(dt('Index !index has been set to use server !server and items have been queued for indexing.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+ }
+}
+
+/**
+ * Returns an index or all indexes as an array.
+ *
+ * @param string|int|null $index_id
+ * (optional) The ID or machine name of the index to load. Defaults to
+ * loading all available indexes.
+ *
+ * @return SearchApiIndex[]
+ * 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;
+}
+
+/**
+ * Returns a server or all servers as an array.
+ *
+ * @param string|int|null $server_id
+ * (optional) The ID or machine name of the server to load. Defaults to
+ * loading all available servers.
+ *
+ * @return SearchApiServer[]
+ * An array of servers.
+ */
+function search_api_drush_get_server($server_id = NULL) {
+ $ids = isset($server_id) ? array($server_id) : FALSE;
+ $servers = search_api_server_load_multiple($ids);
+ if (empty($servers)) {
+ drush_set_error(dt('Invalid server_id or no servers present.'));
+ drush_print();
+ drush_search_api_server_list();
+ }
+ return $servers;
+}
+
+/**
+ * Does a static lookup to prevent Drush 4 from running twice.
+ *
+ * @param string $function
+ * The Drush function being called.
+ *
+ * @return bool
+ * TRUE if the function was already called in this Drush execution, FALSE
+ * otherwise.
+ *
+ * @see http://drupal.org/node/704848
+ */
+function search_api_drush_static($function) {
+ static $index = array();
+ if (isset($index[$function])) {
+ return TRUE;
+ }
+ $index[$function] = TRUE;
+ return FALSE;
+}
+
+/**
+ * Lists all search servers.
+ */
+function drush_search_api_server_list() {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $servers = search_api_server_load_multiple(FALSE);
+ if (empty($servers)) {
+ drush_print(dt('There are no servers present.'));
+ return;
+ }
+ $rows[] = array(
+ dt('Machine name'),
+ dt('Name'),
+ dt('Status'),
+ );
+ foreach ($servers as $server) {
+ $row = array(
+ $server->machine_name,
+ $server->name,
+ $server->enabled ? dt('enabled') : dt('disabled'),
+ );
+ $rows[] = $row;
+ }
+ drush_print_table($rows);
+}
+
+/**
+ * Enables a search server.
+ *
+ * @param int|string $server_id
+ * The numeric ID or machine name of the server to enable.
+ */
+function drush_search_api_server_enable($server_id = NULL) {
+ if (!isset($server_id)) {
+ drush_print(dt('Please provide a valid server id.'));
+ return;
+ }
+ $server = search_api_server_load($server_id);
+ if (empty($server)) {
+ drush_print(dt('The server was not able to load.'));
+ return;
+ }
+ else {
+ $server->update(array('enabled' => 1));
+ drush_print(dt('The server was enabled successfully.'));
+ }
+}
+
+/**
+ * Disables a search server.
+ *
+ * @param int|string $server_id
+ * The numeric ID or machine name of the server to disable.
+ */
+function drush_search_api_server_disable($server_id = NULL) {
+ if (!isset($server_id)) {
+ drush_print(dt('Please provide a valid server id.'));
+ return;
+ }
+ $server = search_api_server_load($server_id);
+ if (empty($server)) {
+ drush_print(dt('The server was not able to load.'));
+ }
+ else {
+ $server->update(array('enabled' => 0));
+ drush_print(dt('The server was disabled successfully.'));
+ }
+}
diff --git a/www/modules/contrib/search_api/search_api.info b/www/modules/contrib/search_api/search_api.info
new file mode 100644
index 000000000..c97f66c59
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.info
@@ -0,0 +1,15 @@
+name = Search API
+description = "Provides a generic API for modules offering search capabilities."
+backdrop = 1.x
+type = module
+package = Search
+tags[] = Utility
+
+dependencies[] = entity_plus (>=1.x-1.0.10)
+
+configure = admin/config/search/search_api
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/search_api.install b/www/modules/contrib/search_api/search_api.install
new file mode 100644
index 000000000..4169510af
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.install
@@ -0,0 +1,480 @@
+ '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'),
+ );
+
+ $schema['search_api_item_string_id'] = array(
+ 'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+ 'fields' => array(
+ 'item_id' => array(
+ 'description' => "The item's ID.",
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.id this item belongs to.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'changed' => array(
+ 'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+ 'type' => 'int',
+ 'size' => 'big',
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ ),
+ 'indexes' => array(
+ 'indexing' => array('index_id', 'changed'),
+ ),
+ 'primary key' => array('item_id', 'index_id'),
+ );
+
+ $schema['search_api_task'] = array(
+ 'description' => 'Stores pending tasks for servers.',
+ 'fields' => array(
+ 'id' => array(
+ 'description' => 'An integer identifying this task.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'server_id' => array(
+ 'description' => 'The {search_api_server}.machine_name for which this task should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'type' => array(
+ 'description' => 'A keyword identifying the type of task that should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.machine_name to which this task pertains, if applicable for this type.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => FALSE,
+ ),
+ 'data' => array(
+ 'description' => 'Some data needed for the task, might be optional depending on the type.',
+ 'type' => 'text',
+ 'size' => 'medium',
+ 'serialize' => TRUE,
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'server' => array('server_id'),
+ ),
+ 'primary key' => array('id'),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_requirements().
+ */
+function search_api_requirements($phase) {
+ $requirements = array();
+
+ if ($phase == 'runtime') {
+ // Check whether at least one server has pending tasks.
+ if (search_api_server_tasks_count()) {
+ $items = array();
+
+ $conditions = array('enabled' => TRUE);
+ foreach (search_api_server_load_multiple(FALSE, $conditions) as $server) {
+ $count = search_api_server_tasks_count($server);
+ if ($count) {
+ $args = array(
+ '@name' => $server->name,
+ );
+ $text = format_plural($count, '@name has @count pending task.', '@name has @count pending tasks.', $args);
+ $items[] = l($text, "admin/config/search/search_api/server/{$server->machine_name}/execute-tasks");
+ }
+ }
+
+ if ($items) {
+ $text = t('There are pending tasks for the following servers:');
+ $text .= theme('item_list', array(
+ 'type' => 'ul',
+ 'items' => $items,
+ ));
+ if (count($items) > 1) {
+ $label = t('Execute pending tasks on all servers');
+ $text .= l($label, 'admin/config/search/search_api/execute-tasks');
+ }
+ $requirements['search_api_pending_tasks'] = array(
+ 'title' => t('Search API'),
+ 'value' => $text,
+ 'severity' => REQUIREMENT_WARNING,
+ );
+ }
+ }
+ }
+
+ return $requirements;
+}
+
+/**
+ * Implements hook_install().
+ *
+ * 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]+/', '_', backdrop_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(
+ 'index_directly' => 1,
+ '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',
+ ),
+ ),
+ ),
+ );
+ entity_info_cache_clear();
+ search_api_index_insert($values);
+ backdrop_set_message(t('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) {
+ try {
+ $controller = search_api_get_datasource_controller($type);
+ $controller->startTracking($indexes);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ }
+}
+
+/**
+ * Implements hook_disable().
+ */
+function search_api_disable() {
+ entity_info_cache_clear();
+ $types = array();
+ foreach (search_api_index_load_multiple(FALSE) as $index) {
+ $types[$index->item_type][] = $index;
+ }
+ foreach ($types as $type => $indexes) {
+ try {
+ $controller = search_api_get_datasource_controller($type);
+ $controller->stopTracking($indexes);
+ }
+ catch (SearchApiException $e) {
+ // Modules defining entity or item types might have been disabled. Ignore.
+ }
+ }
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function search_api_uninstall() {
+ config_clear('search_api.settings', 'search_api_index_worker_callback_runtime');
+}
+
+/**
+ * Implements hook_update_last_removed().
+ */
+function search_api_update_last_removed() {
+ return 7118;
+}
+
+/**
+ * Implements hook_update_N().
+ */
+function search_api_update_1000() {
+ $config = config('search_api.settings');
+ $config->set('search_api_index_worker_callback_runtime', update_variable_get('search_api_index_worker_callback_runtime', '15'));
+ $config->save();
+ update_variable_del('search_api_tasks');
+ update_variable_del('search_api_index_worker_callback_runtime');
+}
diff --git a/www/modules/contrib/search_api/search_api.module b/www/modules/contrib/search_api/search_api.module
new file mode 100644
index 000000000..1ca678283
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.module
@@ -0,0 +1,3597 @@
+ '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' => 'backdrop_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' => 'backdrop_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',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items[$pre . '/server/%search_api_server/edit'] = array(
+ 'title' => 'Edit',
+ 'description' => 'Edit server details.',
+ 'page callback' => 'backdrop_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,
+ 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ );
+ $items[$pre . '/server/%search_api_server/execute-tasks'] = array(
+ 'title' => 'Execute pending tasks',
+ 'description' => 'Attempt to process pending tasks for a given server.',
+ 'page callback' => 'search_api_execute_pending_tasks',
+ 'page arguments' => array(5),
+ 'access callback' => 'search_api_access_execute_tasks_batch',
+ 'access arguments' => array(5),
+ 'type' => MENU_CALLBACK,
+ );
+ $items[$pre . '/server/%search_api_server/disable'] = array(
+ 'title' => 'Disable',
+ 'description' => 'Disable index.',
+ 'page callback' => 'search_api_admin_server_view',
+ 'page arguments' => array(5, 6),
+ 'access callback' => 'search_api_access_disable_page',
+ 'access arguments' => array(5),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 8,
+ );
+ $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' => 'backdrop_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,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 10,
+ );
+ $items[$pre . '/execute-tasks'] = array(
+ 'title' => 'Execute pending tasks',
+ 'description' => 'Attempt to process pending server tasks.',
+ 'page callback' => 'search_api_execute_pending_tasks',
+ 'access callback' => 'search_api_access_execute_tasks_batch',
+ 'type' => MENU_LOCAL_ACTION,
+ );
+ $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/edit'] = array(
+ 'title' => 'Edit',
+ 'description' => 'Edit index settings.',
+ 'page callback' => 'backdrop_get_form',
+ 'page arguments' => array('search_api_admin_index_edit', 5),
+ 'access arguments' => array('administer search_api'),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -6,
+ );
+ $items[$pre . '/index/%search_api_index/fields'] = array(
+ 'title' => 'Fields',
+ 'description' => 'Select indexed fields.',
+ 'page callback' => 'backdrop_get_form',
+ 'page arguments' => array('search_api_admin_index_fields', 5),
+ 'access arguments' => array('administer search_api'),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -4,
+ );
+ $items[$pre . '/index/%search_api_index/workflow'] = array(
+ 'title' => 'Filters',
+ 'description' => 'Edit indexing workflow.',
+ 'page callback' => 'backdrop_get_form',
+ 'page arguments' => array('search_api_admin_index_workflow', 5),
+ 'access arguments' => array('administer search_api'),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -2,
+ );
+ $items[$pre . '/index/%search_api_index/disable'] = array(
+ 'title' => 'Disable',
+ 'description' => 'Disable index.',
+ 'page callback' => 'search_api_admin_index_view',
+ 'page arguments' => array(5, 6),
+ 'access callback' => 'search_api_access_disable_page',
+ 'access arguments' => array(5),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 8,
+ );
+ $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' => 'backdrop_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,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 10,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_hook_info().
+ */
+function search_api_hook_info() {
+ // We use the same group for all hooks, so save code lines.
+ $hook_info = array(
+ 'group' => 'search_api',
+ );
+ return array(
+ 'search_api_service_info' => $hook_info,
+ 'search_api_service_info_alter' => $hook_info,
+ 'search_api_item_type_info' => $hook_info,
+ 'search_api_item_type_info_alter' => $hook_info,
+ 'search_api_data_type_info' => $hook_info,
+ 'search_api_data_type_info_alter' => $hook_info,
+ 'search_api_alter_callback_info' => $hook_info,
+ 'search_api_alter_callback_info_alter' => $hook_info,
+ 'search_api_processor_info' => $hook_info,
+ 'search_api_processor_info_alter' => $hook_info,
+ 'search_api_index_items_alter' => $hook_info,
+ 'search_api_items_indexed' => $hook_info,
+ 'search_api_query_alter' => $hook_info,
+ 'search_api_results_alter' => $hook_info,
+ 'search_api_server_load' => $hook_info,
+ 'search_api_server_insert' => $hook_info,
+ 'search_api_server_update' => $hook_info,
+ 'search_api_server_delete' => $hook_info,
+ 'default_search_api_server' => $hook_info,
+ 'default_search_api_server_alter' => $hook_info,
+ 'search_api_index_load' => $hook_info,
+ 'search_api_index_insert' => $hook_info,
+ 'search_api_index_update' => $hook_info,
+ 'search_api_index_reindex' => $hook_info,
+ 'search_api_index_delete' => $hook_info,
+ 'default_search_api_index' => $hook_info,
+ 'default_search_api_index_alter' => $hook_info,
+ );
+}
+
+/**
+ * Implements hook_theme().
+ */
+function search_api_theme() {
+ $themes['search_api_server'] = array(
+ 'variables' => array(
+ 'id' => NULL,
+ 'name' => '',
+ 'machine_name' => '',
+ 'description' => NULL,
+ 'enabled' => NULL,
+ 'class_id' => NULL,
+ 'class_name' => NULL,
+ 'class_description' => NULL,
+ 'indexes' => array(),
+ 'options' => array(),
+ 'extra' => array(),
+ 'status' => NULL,
+ ),
+ 'file' => 'search_api.admin.inc',
+ );
+ $themes['search_api_index'] = array(
+ 'variables' => array(
+ 'id' => NULL,
+ 'name' => '',
+ 'machine_name' => '',
+ 'description' => NULL,
+ 'item_type' => NULL,
+ 'datasource_config' => NULL,
+ 'enabled' => NULL,
+ 'server' => NULL,
+ 'options' => array(),
+ 'fields' => array(),
+ 'indexed_items' => 0,
+ 'on_server' => NULL,
+ 'total_items' => 0,
+ 'read_only' => 0,
+ 'status' => NULL,
+ ),
+ '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_config_info().
+ */
+function search_api_config_info() {
+ $prefixes['search_api.settings'] = array(
+ 'label' => t('Search API settings'),
+ 'group' => t('Configuration'),
+ );
+ return $prefixes;
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * This will first execute any pending server tasks. After that, items will
+ * be indexed on all enabled indexes with a non-zero cron limit. Indexing will
+ * run for the time set in the search_api_index_worker_callback_runtime variable
+ * (defaulting to 15 seconds), but will at least index one batch of items on
+ * each index.
+ *
+ * @see search_api_server_tasks_check()
+ */
+function search_api_cron() {
+ // Execute pending server tasks.
+ search_api_server_tasks_check();
+
+ // Load all enabled, not read-only indexes.
+ $conditions = array(
+ 'enabled' => TRUE,
+ 'read_only' => 0,
+ );
+ $indexes = search_api_index_load_multiple(FALSE, $conditions);
+ if (!$indexes) {
+ return;
+ }
+ // Remember servers which threw an exception.
+ $ignored_servers = array();
+ // Continue indexing, one batch from each index, until the time is up, but at
+ // least index one batch per index.
+ $end = time() + config_get('search_api.settings', 'search_api_index_worker_callback_runtime');
+ $first_pass = TRUE;
+ while (TRUE) {
+ if (!$indexes) {
+ break;
+ }
+ foreach ($indexes as $id => $index) {
+ if (!$first_pass && time() >= $end) {
+ break 2;
+ }
+ if (!empty($ignored_servers[$index->server])) {
+ continue;
+ }
+
+ $limit = isset($index->options['cron_limit']) ? $index->options['cron_limit'] : SEARCH_API_DEFAULT_CRON_LIMIT;
+ $num = 0;
+ if ($limit) {
+ try {
+ $num = search_api_index_items($index, $limit);
+ if ($num) {
+ $variables = array(
+ '@num' => $num,
+ '%name' => $index->name,
+ );
+ watchdog('search_api', 'Indexed @num items for index %name.', $variables, WATCHDOG_INFO);
+ }
+ }
+ catch (SearchApiException $e) {
+ // Exceptions will probably be caused by the server in most cases.
+ // Therefore, don't index for any index on this server.
+ $ignored_servers[$index->server] = TRUE;
+ $vars['%index'] = $index->name;
+ watchdog_exception('search_api', $e, '%type while trying to index items on %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+ if (!$num) {
+ // Couldn't index any items => stop indexing for this index in this
+ // cron run.
+ unset($indexes[$id]);
+ }
+ }
+ $first_pass = FALSE;
+ }
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function search_api_entity_info() {
+ $info['search_api_server'] = array(
+ 'label' => t('Search server'),
+ 'controller class' => 'EntityPlusControllerExportable',
+ 'metadata controller class' => FALSE,
+ 'entity class' => 'SearchApiServer',
+ 'base table' => 'search_api_server',
+ 'uri callback' => 'search_api_server_url',
+ 'access callback' => 'search_api_entity_access',
+ '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' => 'EntityPlusControllerExportable',
+ 'metadata controller class' => FALSE,
+ 'entity class' => 'SearchApiIndex',
+ 'base table' => 'search_api_index',
+ 'uri callback' => 'search_api_index_url',
+ 'access callback' => 'search_api_entity_access',
+ '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',
+ ),
+ 'status' => array(
+ 'label' => t('Status'),
+ 'type' => 'integer',
+ 'description' => t('Search API server status property'),
+ 'schema field' => 'status',
+ 'options list' => 'search_api_status_options_list',
+ ),
+ 'module' => array(
+ 'label' => t('Module'),
+ 'type' => 'text',
+ 'description' => t('The name of the module from which this server originates.'),
+ 'schema field' => 'module',
+ ),
+ );
+ $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',
+ ),
+ 'status' => array(
+ 'label' => t('Status'),
+ 'type' => 'integer',
+ 'description' => t('Search API index status property'),
+ 'schema field' => 'status',
+ 'options list' => 'search_api_status_options_list',
+ ),
+ 'module' => array(
+ 'label' => t('Module'),
+ 'type' => 'text',
+ 'description' => t('The name of the module from which this index originates.'),
+ 'schema field' => 'module',
+ ),
+ );
+
+ return $info;
+}
+
+/**
+ * Implements hook_search_api_server_insert().
+ *
+ * Calls the postCreate() method for the server.
+ */
+function search_api_search_api_server_insert(SearchApiServer $server) {
+ // Check whether this is actually part of a revert.
+ $reverts = &backdrop_static('search_api_search_api_server_delete', array());
+ if (isset($reverts[$server->machine_name])) {
+ $server->original = $reverts[$server->machine_name];
+ unset($reverts[$server->machine_name]);
+ search_api_search_api_server_update($server);
+ unset($server->original);
+ return;
+ }
+ $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 (!empty($server->original) && $server->enabled != $server->original->enabled) {
+ if ($server->enabled) {
+ search_api_server_tasks_check($server);
+ }
+ else {
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ $index->update(array('enabled' => 0, 'server' => NULL));
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_search_api_server_delete().
+ *
+ * Calls the preDelete() method for the server.
+ */
+function search_api_search_api_server_delete(SearchApiServer $server) {
+ // Only react on real delete, not revert.
+ if ($server->hasStatus(ENTITY_PLUS_IN_CODE)) {
+ $reverts = &backdrop_static(__FUNCTION__, array());
+ $reverts[$server->machine_name] = $server;
+ return;
+ }
+
+ $server->preDelete();
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ $index->update(array('server' => NULL, 'enabled' => FALSE));
+ }
+
+ search_api_server_tasks_delete(NULL, $server);
+}
+
+/**
+ * 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) {
+ // Check whether this is actually part of a revert.
+ $reverts = &backdrop_static('search_api_search_api_index_delete', array());
+ if (isset($reverts[$index->machine_name])) {
+ $index->original = $reverts[$index->machine_name];
+ unset($reverts[$index->machine_name]);
+ search_api_search_api_index_update($index);
+ unset($index->original);
+ return;
+ }
+
+ $index->postCreate();
+}
+
+/**
+ * Implements hook_search_api_index_update().
+ */
+function search_api_search_api_index_update(SearchApiIndex $index) {
+ // Call the datasource update function with the tables this module provides.
+ search_api_index_update_datasource($index, 'search_api_item');
+ search_api_index_update_datasource($index, 'search_api_item_string_id');
+
+ // 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) {
+ $old_server->removeIndex($index);
+ }
+ }
+
+ if ($index->server) {
+ try {
+ $new_server = $index->server(TRUE);
+ // If the server is enabled, we call addIndex(); otherwise, we save the task.
+ $new_server->addIndex($index);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ // If the new server doesn't exist, we remove the index from all
+ // servers. Note that saving an entity in its own update hook is usually
+ // a recipe for disaster, but since we are only doing this if a server
+ // is set and remove the server here before saving, it should be safe
+ // enough.
+ $index->server = NULL;
+ $index->save();
+ }
+ }
+
+ // 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);
+ }
+ }
+
+ // 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();
+ }
+ }
+}
+
+/**
+ * Implements hook_search_api_index_delete().
+ *
+ * Removes all data for indexes not available any more.
+ */
+function search_api_search_api_index_delete(SearchApiIndex $index) {
+ // Only react on real delete, not revert.
+ if ($index->hasStatus(ENTITY_PLUS_IN_CODE)) {
+ $reverts = &backdrop_static(__FUNCTION__, array());
+ $reverts[$index->machine_name] = $index;
+ return;
+ }
+ cache_clear_all($index->getCacheId(''), 'cache', TRUE);
+ $index->postDelete();
+}
+
+/**
+ * Implements hook_features_export_alter().
+ *
+ * Adds dependency information for exported servers.
+ */
+function search_api_features_export_alter(&$export) {
+ 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_system_info_alter().
+ *
+ * Checks if the module provides any search item types or service classes. If it
+ * does, and there are search indexes using those item types, respectively
+ * servers using those service classes, the module is set to "required".
+ *
+ * Heavily borrowed from field_system_info_alter().
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_system_info_alter(&$info, $file, $type) {
+ if ($type != 'module' || $file->name == 'search_api' || !module_exists($file->name)) {
+ return;
+ }
+ // Check for defined item types.
+ if (module_hook($file->name, 'search_api_item_type_info')) {
+ $types = array();
+ foreach (search_api_get_item_type_info() as $type => $type_info) {
+ if ($type_info['module'] == $file->name) {
+ $types[] = $type;
+ }
+ }
+ if ($types) {
+ $sql = 'SELECT machine_name, name FROM {search_api_index} WHERE item_type IN (:types)';
+ $indexes = db_query($sql, array(':types' => $types))->fetchAllKeyed();
+ if ($indexes) {
+ $info['disabled'] = TRUE;
+
+ $links = array();
+ foreach ($indexes as $id => $name) {
+ $url = url("admin/config/search/search_api/index/$id");
+ $links[] = '' . check_plain($name) . '';
+ }
+
+ $args = array('!indexes' => implode(', ', $links));
+ $info['explanation'] = format_plural(count($indexes), 'Item type in use by the following index: !indexes.', 'Item type(s) in use by the following indexes: !indexes.', $args);
+ }
+ }
+ }
+ // Check for defined service classes.
+ if (module_hook($file->name, 'search_api_service_info')) {
+ $classes = array();
+ foreach (search_api_get_service_info() as $class => $class_info) {
+ if ($class_info['module'] == $file->name) {
+ $classes[] = $class;
+ }
+ }
+ if ($classes) {
+ $sql = 'SELECT machine_name, name FROM {search_api_server} WHERE class IN (:classes)';
+ $servers = db_query($sql, array(':classes' => $classes))->fetchAllKeyed();
+ if ($servers) {
+ $info['disabled'] = TRUE;
+
+ $links = array();
+ foreach ($servers as $id => $name) {
+ $url = url("admin/config/search/search_api/server/$id");
+ $links[] = '' . check_plain($name) . '';
+ }
+
+ $args = array('!servers' => implode(', ', $links));
+ $explanation = format_plural(count($servers), 'Service class in use by the following server: !servers.', 'Service class(es) in use by the following servers: !servers.', $args);
+ $info['explanation'] = (!empty($info['explanation']) ? $info['explanation'] . ' ' : '') . $explanation;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_entity_insert().
+ *
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_insert() for the
+ * inserted items.
+ *
+ * @see search_api_search_api_item_type_info()
+ */
+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_plus_get_property_info($type)) {
+ return;
+ }
+ list($id) = entity_extract_ids($type, $entity);
+ if (isset($id)) {
+ search_api_track_item_insert($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_insert('multiple', array($combined_id));
+ }
+}
+
+/**
+ * Implements hook_entity_update().
+ *
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_change() for the
+ * updated items.
+ *
+ * It also checks whether the entity's bundle changed and acts accordingly.
+ *
+ * @see search_api_search_api_item_type_info()
+ */
+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_plus_get_property_info($type)) {
+ return;
+ }
+ list($id, , $new_bundle) = entity_extract_ids($type, $entity);
+
+ // Check if the entity's bundle changed.
+ if (!empty($entity->original)) {
+ list(, , $old_bundle) = entity_extract_ids($type, $entity->original);
+ if ($new_bundle != $old_bundle) {
+ _search_api_entity_datasource_bundle_change($type, $id, $old_bundle, $new_bundle);
+ }
+ }
+
+ if (isset($id)) {
+ search_api_track_item_change($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_change('multiple', array($combined_id));
+ }
+}
+
+/**
+ * Implements hook_entity_delete().
+ *
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_delete() for the
+ * deleted items.
+ *
+ * @see search_api_search_api_item_type_info()
+ */
+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_plus_get_property_info($type)) {
+ return;
+ }
+ list($id) = entity_extract_ids($type, $entity);
+ if (isset($id)) {
+ search_api_track_item_delete($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_delete('multiple', array($combined_id));
+ }
+}
+
+/**
+ * Implements hook_node_access_records_alter().
+ *
+ * Marks the node as "changed" in indexes that use the "Node access" data
+ * alteration. Also marks the node's comments as changed in indexes that use the
+ * "Comment access" data alteration.
+ */
+function search_api_node_access_records_alter(&$grants, $node) {
+ $conditions = array(
+ 'enabled' => 1,
+ 'read_only' => 0,
+ );
+ $indexes = search_api_index_load_multiple(FALSE, $conditions);
+ foreach ($indexes as $index) {
+ $item_ids = array();
+ if (!empty($index->options['data_alter_callbacks']['search_api_alter_node_access']['status'])) {
+ $item_id = $index->datasource()->getItemId($node);
+ if ($item_id !== NULL) {
+ $item_ids = array($item_id);
+ }
+ }
+ elseif (!empty($index->options['data_alter_callbacks']['search_api_alter_comment_access']['status'])) {
+ if (!isset($comments)) {
+ $comments = comment_load_multiple(FALSE, array('nid' => $node->nid));
+ }
+ foreach ($comments as $comment) {
+ $item_ids[] = $index->datasource()->getItemId($comment);
+ }
+ }
+
+ if ($item_ids) {
+ search_api_track_item_change_for_indexes(
+ $index->item_type,
+ $item_ids,
+ array($index->machine_name => $index)
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_field_attach_rename_bundle().
+ *
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller, to update any bundle settings that contain the changed
+ * bundle.
+ */
+function search_api_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ foreach (search_api_index_load_multiple(FALSE, array('item_type' => $entity_type)) as $index) {
+ $bundles = &$index->options['datasource']['bundles'];
+ if (isset($bundles) && ($pos = array_search($bundle_old, $bundles)) !== FALSE) {
+ $bundles[$pos] = $bundle_new;
+ $index->save();
+ // Clear all caches that could contain the bundle information.
+ $index->resetCaches();
+ backdrop_static_reset('search_api_get_datasource_controller');
+ }
+ }
+}
+
+/**
+ * Implements hook_field_update_field().
+ *
+ * Recalculates fields settings if the cardinality of the field has changed from
+ * or to 1.
+ */
+function search_api_field_update_field($field, $prior_field) {
+ $before = $prior_field['cardinality'];
+ $after = $field['cardinality'];
+ if ($before != $after && ($before == 1 || $after == 1)) {
+ // Unfortunately, we cannot call this right away since the field information
+ // is only stored after the hook is called.
+ backdrop_register_shutdown_function('search_api_index_recalculate_fields');
+ }
+}
+
+/**
+ * Implements hook_flush_caches().
+ *
+ * Recalculates fields settings in case the schema (in most cases: the
+ * multiplicity) of a property has changed.
+ */
+function search_api_flush_caches() {
+ search_api_index_recalculate_fields();
+}
+
+/**
+ * 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 (search_api_entity_type_options_list() as $type => $label) {
+ $types[$type] = array(
+ 'name' => $label,
+ 'datasource controller' => 'SearchApiEntityDataSourceController',
+ 'entity_type' => $type,
+ );
+ }
+
+ $types['multiple'] = array(
+ 'name' => t('Multiple types'),
+ 'datasource controller' => 'SearchApiCombinedEntityDataSourceController',
+ );
+
+ return $types;
+}
+
+/**
+ * Implements hook_module_implements_alter().
+ *
+ * Ensures the item type and service class static caches are invalidated at the
+ * right time.
+ */
+function search_api_module_implements_alter(array &$implementations, $hook) {
+ switch ($hook) {
+ case 'modules_enabled':
+ $group = $implementations['search_api'];
+ unset($implementations['search_api']);
+ $implementations = array('search_api' => $group) + $implementations;
+ break;
+
+ case 'modules_disabled':
+ $group = $implementations['search_api'];
+ unset($implementations['search_api']);
+ $implementations['search_api'] = $group;
+ break;
+ }
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function search_api_modules_enabled() {
+ // New modules might offer additional item types or service classes,
+ // invalidating the cached information.
+ backdrop_static_reset('search_api_get_item_type_info');
+ backdrop_static_reset('search_api_get_service_info');
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function search_api_modules_disabled() {
+ // The disabled modules might have offered item types or service classes,
+ // invalidating the cached information.
+ backdrop_static_reset('search_api_get_item_type_info');
+ backdrop_static_reset('search_api_get_service_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_role_filter'] = array(
+ 'name' => t('Role filter'),
+ 'description' => t('Exclude users from indexing based on their role.'),
+ 'class' => 'SearchApiAlterRoleFilter',
+ // 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_file_entity_public'] = array(
+ 'name' => t('Exclude private files'),
+ 'description' => t('Exclude file entities in the private files folder from being indexed. Caution: This only affects the indexed file entities themselves. If an indexed entity has references to file entities in the private folder, those will still be indexed (or displayed) normally.'),
+ 'class' => 'SearchApiAlterFileEntityPublic',
+ );
+ $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. Caution: This only affects the indexed nodes themselves, not any node reference fields that are indexed with them, or displayed in search results.'),
+ 'class' => 'SearchApiAlterNodeAccess',
+ );
+ $callbacks['search_api_alter_comment_access'] = array(
+ 'name' => t('Access check'),
+ 'description' => t('Add node access information to the index. Caution: This only affects the indexed nodes themselves, not any node reference fields that are indexed with them, or displayed in search results.'),
+ 'class' => 'SearchApiAlterCommentAccess',
+ );
+ $callbacks['search_api_alter_node_status'] = array(
+ 'name' => t('Exclude unpublished nodes'),
+ 'description' => t('Exclude unpublished nodes from the index. Caution: This only affects the indexed nodes themselves. If an enabled node has references to disabled nodes, those will still be indexed (or displayed) normally.'),
+ 'class' => 'SearchApiAlterNodeStatus',
+ );
+ $callbacks['search_api_alter_user_content'] = array(
+ 'name' => t('Add user content'),
+ 'description' => t('Allows indexing of nodes (and their fields) created by the indexed user. (Caution: This might lead to performance problems, or even errors during indexing, on larger sites.)'),
+ 'class' => 'SearchApiAlterAddUserContent',
+ );
+ $callbacks['search_api_alter_user_status'] = array(
+ 'name' => t('Exclude blocked users'),
+ 'description' => t('Exclude blocked users from the index. Caution: This only affects the indexed users themselves. If an active user account includes a reference to a disabled user, that reference will still be indexed (or displayed) normally.'),
+ 'class' => 'SearchApiAlterUserStatus',
+ );
+
+ 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 fulltext or 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,
+ );
+ if (function_exists('transliteration_get')) {
+ $processors['search_api_transliteration'] = array(
+ 'name' => t('Transliteration'),
+ 'description' => t('This processor will make searches insensitive to accents and other non-ASCII characters.'),
+ 'class' => 'SearchApiTransliteration',
+ 'weight' => 15,
+ );
+ }
+ $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,
+ );
+ $processors['search_api_porter_stemmer'] = array(
+ 'name' => t('Stem words'),
+ 'description' => t('This processor reduces words to a stem (e.g., "talking" to "talk"). For best results, it should only be executed after tokenizing.'),
+ 'class' => 'SearchApiPorterStemmer',
+ 'weight' => 35,
+ );
+ $processors['search_api_highlighting'] = array(
+ 'name' => t('Highlighting'),
+ 'description' => t('Adds highlighting for search results.'),
+ 'class' => 'SearchApiHighlight',
+ 'weight' => 40,
+ );
+
+ return $processors;
+}
+
+/**
+ * Inserts new unindexed items for all indexes on the specified type.
+ *
+ * @param string $type
+ * The item type of the new items.
+ * @param array $item_ids
+ * 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;
+ }
+
+ try {
+ $returned_indexes = search_api_get_datasource_controller($type)->trackItemInsert($item_ids, $indexes);
+ if (isset($returned_indexes)) {
+ $indexes = $returned_indexes;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while inserting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ return;
+ }
+
+ foreach ($indexes as $index) {
+ if (!empty($index->options['index_directly'])) {
+ 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_track_item_change_for_indexes($type, $item_ids, $indexes);
+}
+
+/**
+ * Marks the items with the specified IDs as "dirty" for the given indexes.
+ *
+ * @param string $type
+ * The item type of the items.
+ * @param array $item_ids
+ * The item IDs.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which to mark the items as "dirty".
+ */
+function search_api_track_item_change_for_indexes($type, array $item_ids, $indexes) {
+ try {
+ $returned_indexes = search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
+ if (isset($returned_indexes)) {
+ $indexes = $returned_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);
+ }
+ }
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while updating items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
+}
+
+/**
+ * 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.
+ *
+ * @deprecated
+ * As of Search API 1.10, the cron queue is not used for indexing anymore,
+ * therefore this function has become useless. It will, along with
+ * SearchApiDataSourceControllerInterface::trackItemQueued(), be removed in
+ * the Backdrop 8 version of this module.
+ */
+function search_api_track_item_queued(SearchApiIndex $index, array $item_ids) {
+ try {
+ $index->datasource()->trackItemQueued($item_ids, $index);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+}
+
+/**
+ * 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);
+ module_invoke_all('search_api_items_indexed', $index, $item_ids);
+}
+
+/**
+ * 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) {
+ try {
+ $changed_indexes = search_api_get_datasource_controller($type)->trackItemDelete($item_ids, $indexes);
+ if (isset($changed_indexes)) {
+ $indexes = $changed_indexes;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while deleting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
+ }
+
+ // Then, delete it from all servers. Servers of disabled indexes have to be
+ // considered, too!
+ $conditions['enabled'] = 0;
+ $indexes = array_merge($indexes, search_api_index_load_multiple(FALSE, $conditions));
+ foreach ($indexes as $index) {
+ try {
+ if ($server = $index->server()) {
+ $server->deleteItems($item_ids, $index);
+ }
+ }
+ catch (Exception $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while deleting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
+ }
+}
+
+/**
+ * Checks for pending tasks on one or all enabled search servers.
+ *
+ * @param SearchApiServer|null $server
+ * (optional) The server whose tasks should be checked. If not given, the
+ * tasks for all enabled servers are checked.
+ *
+ * @return bool
+ * TRUE if all tasks (for the specific server, if $server was given) were
+ * executed successfully, or if there were no tasks. FALSE if there are still
+ * pending tasks.
+ */
+function search_api_server_tasks_check(?SearchApiServer $server = NULL) {
+ $select = db_select('search_api_task', 't')
+ ->fields('t')
+ // Only retrieve tasks we can handle.
+ ->condition('t.type', array('addIndex', 'fieldsUpdated', 'removeIndex', 'deleteItems'));
+ if ($server) {
+ $select->condition('t.server_id', $server->machine_name);
+ }
+ else {
+ $select->innerJoin('search_api_server', 's', 't.server_id = s.machine_name AND s.enabled = 1');
+ // By ordering by the server, we can later just load them when we reach them
+ // while looping through the tasks. It is very unlikely there will be tasks
+ // for more than one or two servers, so a *_load_multiple() probably
+ // wouldn't bring any significant advantages, but complicate the code.
+ $select->orderBy('t.server_id');
+ }
+ // Store a count query for later checking whether all tasks were processed
+ // successfully.
+ $count_query = $select->countQuery();
+
+ // Sometimes the order of tasks might be important, so make sure to order by
+ // the task ID (which should be in order of insertion).
+ $select->orderBy('t.id');
+ // Only retrieve and execute 100 tasks at once, to avoid running out of memory
+ // or time. We just can't do anything else until all tasks have been resolved,
+ // but at least we shouldn't crash sites, or keep piling up tasks, that way.
+ $select->range(0, 100);
+ $tasks = $select->execute();
+
+ $executed_tasks = array();
+ foreach ($tasks as $task) {
+ if (!$server || $server->machine_name != $task->server_id) {
+ $server = search_api_server_load($task->server_id);
+ if (!$server) {
+ continue;
+ }
+ }
+ switch ($task->type) {
+ case 'addIndex':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ $server->addIndex($index);
+ }
+ break;
+
+ case 'fieldsUpdated':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ if ($task->data) {
+ $index->original = unserialize($task->data);
+ }
+ $server->fieldsUpdated($index);
+ }
+ break;
+
+ case 'removeIndex':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ $server->removeIndex($index ? $index : $task->index_id);
+ }
+ break;
+
+ case 'deleteItems':
+ $ids = $task->data ? unserialize($task->data) : 'all';
+ $index = $task->index_id ? search_api_index_load($task->index_id) : NULL;
+ // Since a failed load returns (for stupid menu handler reasons) FALSE,
+ // not NULL, we have to make doubly sure here not to pass an invalid
+ // value (and cause a fatal error).
+ $index = $index ? $index : NULL;
+ $server->deleteItems($ids, $index);
+ break;
+
+ default:
+ // This should never happen.
+ continue 2;
+ }
+ $executed_tasks[] = $task->id;
+ }
+
+ // If there were no tasks (we recognized), return TRUE.
+ if (!$executed_tasks) {
+ return TRUE;
+ }
+ // Otherwise, delete the executed tasks and check if new tasks were created
+ // (or if we didn't even fetch all due to the 100 tasks limit).
+ search_api_server_tasks_delete($executed_tasks);
+ return $count_query->execute()->fetchField() === 0;
+}
+
+/**
+ * Provides a batch wrapper for search_api_server_tasks_check().
+ *
+ * @param SearchApiServer|null $server
+ * (optional) The server whose tasks should be executed, or NULL to execute
+ * tasks for all servers.
+ */
+function search_api_execute_pending_tasks(?SearchApiServer $server = NULL) {
+ batch_set(array(
+ 'title' => t('Processing pending tasks'),
+ 'operations' => array(
+ array(
+ 'search_api_execute_pending_tasks_batch',
+ array(
+ $server,
+ ),
+ ),
+ ),
+ 'finished' => 'search_api_execute_pending_tasks_finished',
+ ));
+ if ($server) {
+ $path = 'admin/config/search/search_api/server/' . $server->machine_name;
+ }
+ else {
+ $path = 'admin/config/search/search_api';
+ }
+
+ if (function_exists('drush_backend_batch_process')) {
+ drush_backend_batch_process();
+ }
+ else {
+ batch_process($path);
+ }
+}
+
+/**
+ * Executes pending server tasks as part of a batch operation.
+ */
+function search_api_execute_pending_tasks_batch(?SearchApiServer $server = NULL, &$context = array()) {
+ if (!isset($context['results']['total'])) {
+ $context['results']['total'] = search_api_server_tasks_count($server);
+ }
+ $total = $context['results']['total'];
+
+ search_api_server_tasks_check($server);
+
+ $remaining = search_api_server_tasks_count($server);
+ $executed = max($total - $remaining, 0);
+
+ $args['@remaining'] = $remaining;
+ $context['message'] = format_plural($executed, 'Successfully executed @count task, @remaining remaining.', 'Successfully executed @count tasks, @remaining remaining.', $args);
+ $context['finished'] = $total > 0 ? $executed / $total : 1.0;
+}
+
+/**
+ * Batch finish callback for pending server tasks.
+ */
+function search_api_execute_pending_tasks_finished($success, $results, $operations) {
+ if ($success) {
+ // Clear the previous warning.
+ backdrop_get_messages('warning');
+
+ // Alert user to the number of tasks executed.
+ backdrop_set_message(format_plural($results['total'], 'Successfully executed @count task.', 'Successfully executed @count tasks.'));
+ }
+}
+
+/**
+ * Return the number of pending tasks.
+ *
+ * @param SearchApiServer|null $server
+ * (optional) The server for which tasks should be counted, or NULL to count
+ * for all enabled servers.
+ *
+ * @return int
+ * The number of pending tasks for the server, or in total.
+ */
+function search_api_server_tasks_count(?SearchApiServer $server = NULL) {
+ $query = db_select('search_api_task', 't')
+ ->fields('t');
+
+ if ($server) {
+ $query->condition('server_id', $server->machine_name);
+ }
+ else {
+ $query->join('search_api_server', 's', 's.machine_name = t.server_id');
+ $query->condition('s.enabled', 1);
+ }
+
+ return $query->countQuery()->execute()->fetchField();
+}
+
+/**
+ * Access callback: Checks whether a user can execute pending tasks.
+ *
+ * @param SearchApiServer|null $server
+ * (optional) The server for which tasks would be executed.
+ */
+function search_api_access_execute_tasks_batch(?SearchApiServer $server = NULL) {
+ return user_access('administer search_api')
+ && search_api_server_tasks_count($server)
+ && (!$server || $server->enabled);
+}
+
+/**
+ * Adds an entry into a server's list of pending tasks.
+ *
+ * @param SearchApiServer $server
+ * The server for which a task should be remembered.
+ * @param $type
+ * The type of task to perform.
+ * @param SearchApiIndex|string|null $index
+ * (optional) If applicable, the index to which the task pertains (or its
+ * machine name).
+ * @param mixed $data
+ * (optional) If applicable, some further data necessary for the task.
+ */
+function search_api_server_tasks_add(SearchApiServer $server, $type, $index = NULL, $data = NULL) {
+ db_insert('search_api_task')
+ ->fields(array(
+ 'server_id' => $server->machine_name,
+ 'type' => $type,
+ 'index_id' => $index ? (is_object($index) ? $index->machine_name : $index) : NULL,
+ 'data' => isset($data) ? serialize($data) : NULL,
+ ))
+ ->execute();
+}
+
+/**
+ * Removes pending server tasks from the list.
+ *
+ * @param array|null $ids
+ * (optional) The IDs of the pending server tasks to delete. Set to NULL
+ * to not filter by IDs.
+ * @param SearchApiServer|null $server
+ * (optional) A server for which the tasks should be deleted. Set to NULL to
+ * delete tasks from all servers.
+ * @param SearchApiIndex|string|null $index
+ * (optional) An index (or its machine name) for which the tasks should be
+ * deleted. Set to NULL to delete tasks for all indexes.
+ */
+function search_api_server_tasks_delete(?array $ids = NULL, ?SearchApiServer $server = NULL, $index = NULL) {
+ $delete = db_delete('search_api_task');
+ if ($ids) {
+ $delete->condition('id', $ids);
+ }
+ if ($server) {
+ $delete->condition('server_id', $server->machine_name);
+ }
+ if ($index) {
+ $delete->condition('index_id', $index->machine_name);
+ }
+ $delete->execute();
+}
+
+/**
+ * Recalculates the saved fields of an index.
+ *
+ * This is mostly necessary when the multiplicity of the underlying properties
+ * change. The method will re-examine the data structure of the entities in each
+ * index and, if a discrepancy is spotted, re-save that index with updated
+ * fields options (thus, of course, also triggering a re-indexing operation).
+ *
+ * @param SearchApiIndex[]|false $indexes
+ * An array of SearchApiIndex objects on which to perform the operation, or
+ * FALSE to perform it on all indexes.
+ */
+function search_api_index_recalculate_fields($indexes = FALSE) {
+ if (!is_array($indexes)) {
+ $indexes = search_api_index_load_multiple(FALSE);
+ }
+ $stored_keys = backdrop_map_assoc(array('type', 'entity_type', 'real_type', 'boost'));
+ foreach ($indexes as $index) {
+ if (empty($index->options['fields'])) {
+ continue;
+ }
+ // We have to clear the cache, both static and stored, before using
+ // getFields(). Otherwise, we'd just use the stale data which the fields
+ // options are probably already based on.
+ cache_clear_all($index->getCacheId() . '-1-0', 'cache');
+ $index->resetCaches();
+ // getFields() automatically uses the actual data types to correct possible
+ // stale data.
+ $fields = $index->getFields();
+ foreach ($fields as $key => $field) {
+ $fields[$key] = array_intersect_key($field, $stored_keys);
+ if (isset($fields[$key]['boost']) && $fields[$key]['boost'] == '1.0') {
+ unset($fields[$key]['boost']);
+ }
+ }
+ // Use a more accurate method of determining if the fields settings are
+ // equal to avoid needlessly re-indexing the whole index.
+ if ($fields != $index->options['fields']) {
+ $options = $index->options;
+ $options['fields'] = $fields;
+ $index->update(array('options' => $options));
+ }
+ }
+}
+
+/**
+ * Test two setting arrays (or individual settings) for equality.
+ *
+ * @param mixed $setting1
+ * The first setting (array).
+ * @param mixed $setting2
+ * The second setting (array).
+ *
+ * @return bool
+ * TRUE if both settings are identical, FALSE otherwise.
+ *
+ * @deprecated The simple "==" operator will achieve the same.
+ */
+function _search_api_settings_equals($setting1, $setting2) {
+ if (!is_array($setting1) || !is_array($setting2)) {
+ return $setting1 == $setting2;
+ }
+ foreach ($setting1 as $key => $value) {
+ if (!array_key_exists($key, $setting2)) {
+ return FALSE;
+ }
+ if (!_search_api_settings_equals($value, $setting2[$key])) {
+ return FALSE;
+ }
+ unset($setting2[$key]);
+ }
+ // If any keys weren't unset previously, they are not present in $setting1 and
+ // the two are different.
+ return !$setting2;
+}
+
+/**
+ * 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 int $limit
+ * (optional) The number of items which should be indexed at most. Defaults to
+ * -1, which means that all changed items should be indexed.
+ *
+ * @return int
+ * Number of successfully indexed items.
+ *
+ * @throws SearchApiException
+ * If any error occurs during indexing.
+ */
+function search_api_index_items(SearchApiIndex $index, $limit = -1) {
+ // Don't try to index on read-only indexes.
+ if ($index->read_only) {
+ return 0;
+ }
+
+ $ids = search_api_get_items_to_index($index, $limit);
+ return $ids ? count(search_api_index_specific_items($index, $ids)) : 0;
+}
+
+/**
+ * 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.
+ *
+ * @return array
+ * The IDs of all successfully indexed items.
+ *
+ * @throws SearchApiException
+ * If any error occurs during indexing.
+ */
+function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
+ // Before doing anything else, check whether there are pending tasks that need
+ // to be executed on the server. It might be important that they are executed
+ // before any indexing occurs.
+ if (!search_api_server_tasks_check($index->server())) {
+ throw new SearchApiException(t('Could not index items since important pending server tasks could not be performed.'));
+ }
+
+ $items = $index->loadItems($ids);
+ // Clone items because data alterations may alter them.
+ $cloned_items = array();
+ foreach ($items as $id => $item) {
+ if (is_object($item)) {
+ $cloned_items[$id] = clone $item;
+ }
+ else {
+ // Normally, items that can't be loaded shouldn't be returned by
+ // entity_load_multiple (and other loadItems() implementations). Therefore, this is
+ // an extremely rare case, which seems to happen during installation for
+ // some specific setups.
+ $type = search_api_get_item_type_info($index->item_type);
+ $type = $type ? $type['name'] : $index->item_type;
+ watchdog('search_api', "Error during indexing: invalid item loaded for @type with ID @id.", array('@id' => $id, '@type' => $type), WATCHDOG_WARNING);
+ }
+ }
+ $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));
+ }
+ }
+
+ // When indexing via Drush, multiple iterations of a batch will happen in the
+ // same PHP process, so the static cache will quickly fill up. To prevent
+ // this, clear it after each batch of items gets indexed.
+ if (function_exists('drush_backend_batch_process') && batch_get()) {
+ foreach (array_keys(entity_get_info()) as $entity_type) {
+ entity_get_controller($entity_type)->resetCache();
+ }
+ }
+
+ 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 backdrop_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)) {
+ // Instead of registering the shutdown function directly, do it in a way
+ // that ensures it will run last. This helps avoid issues with other
+ // modules' shutdown functions, especially workbench_moderation_store().
+ backdrop_register_shutdown_function('backdrop_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] += backdrop_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 to be passed to
+ * SearchApiQueryInterface::__construct().
+ *
+ * @return SearchApiQueryInterface
+ * An object for searching on the specified index.
+ *
+ * @throws SearchApiException
+ * If the index is unknown or disabled, or some other error was encountered.
+ */
+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);
+}
+
+/**
+ * Stores or retrieves a search executed in this page request.
+ *
+ * Static storage for the searches executed during the current page request. Can
+ * 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 = &backdrop_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 = &backdrop_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';
+ }
+ }
+ backdrop_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 string|null $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. Service class information is formatted as specified by
+ * hook_search_api_service_info(), with the addition of a "module" key
+ * specifying the module that adds a certain class.
+ */
+function search_api_get_service_info($id = NULL) {
+ $services = &backdrop_static(__FUNCTION__);
+
+ if (!isset($services)) {
+ // Inlined version of module_invoke_all() to add "module" keys.
+ $services = array();
+ foreach (module_implements('search_api_service_info') as $module) {
+ $function = $module . '_search_api_service_info';
+ if (function_exists($function)) {
+ $new_services = $function();
+ if (isset($new_services) && is_array($new_services)) {
+ foreach ($new_services as $service => $info) {
+ $new_services[$service] += array('module' => $module);
+ }
+ }
+ $services += $new_services;
+ }
+ }
+
+ // Same for backdrop_alter().
+ foreach (module_implements('search_api_service_info_alter') as $module) {
+ $function = $module . '_search_api_service_info_alter';
+ if (function_exists($function)) {
+ $old = $services;
+ $function($services);
+ if ($new_services = array_diff_key($services, $old)) {
+ foreach ($new_services as $service => $info) {
+ $services[$service] += array('module' => $module);
+ }
+ }
+ }
+ }
+ }
+
+ if (isset($id)) {
+ return isset($services[$id]) ? $services[$id] : NULL;
+ }
+ return $services;
+}
+
+/**
+ * Returns information for either all item types, or a specific one.
+ *
+ * @param string|null $type
+ * If set, the item type whose information should be returned.
+ *
+ * @return array|null
+ * 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(), with the
+ * addition of a "module" key specifying the module that adds a certain type.
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_get_item_type_info($type = NULL) {
+ $types = &backdrop_static(__FUNCTION__);
+
+ if (!isset($types)) {
+ // Inlined version of module_invoke_all() to add "module" keys.
+ $types = array();
+ foreach (module_implements('search_api_item_type_info') as $module) {
+ $function = $module . '_search_api_item_type_info';
+ if (function_exists($function)) {
+ $new_types = $function();
+ if (isset($new_types) && is_array($new_types)) {
+ foreach ($new_types as $id => $info) {
+ $new_types[$id] += array('module' => $module);
+ }
+ }
+ $types += $new_types;
+ }
+ }
+
+ // Same for backdrop_alter().
+ foreach (module_implements('search_api_item_type_info_alter') as $module) {
+ $function = $module . '_search_api_item_type_info_alter';
+ if (function_exists($function)) {
+ $old = $types;
+ $function($types);
+ if ($new_types = array_diff_key($types, $old)) {
+ foreach ($new_types as $id => $info) {
+ $types[$id] += array('module' => $module);
+ }
+ }
+ }
+ }
+ }
+
+ 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 = &backdrop_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 = &backdrop_static(__FUNCTION__);
+
+ if (!isset($callbacks)) {
+ $callbacks = module_invoke_all('search_api_alter_callback_info');
+
+ // Fill optional settings with default values.
+ foreach ($callbacks as $id => $callback) {
+ $callbacks[$id] += array('weight' => 0);
+ }
+
+ // Invoke alter hook.
+ backdrop_alter('search_api_alter_callback_info', $callbacks);
+ }
+
+ 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 = &backdrop_static(__FUNCTION__);
+
+ if (!isset($processors)) {
+ $processors = module_invoke_all('search_api_processor_info');
+
+ // Fill optional settings with default values.
+ foreach ($processors as $id => $processor) {
+ $processors[$id] += array('weight' => 0);
+ }
+
+ // Invoke alter hook.
+ backdrop_alter('search_api_processor_info', $processors);
+ }
+
+ 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) {
+ global $user;
+ $index = $query->getIndex();
+ // Only add node access if the necessary fields are indexed in the index, and
+ // unless disabled explicitly by the query.
+ $type = $index->getEntityType();
+ if (!empty($index->options['data_alter_callbacks']["search_api_alter_{$type}_access"]['status']) && !$query->getOption('search_api_bypass_access')) {
+ $account = $query->getOption('search_api_access_account', $user);
+ if (is_numeric($account)) {
+ $account = user_load($account);
+ }
+ if (is_object($account)) {
+ try {
+ _search_api_query_add_node_access($account, $query, $type);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ }
+ else {
+ $account = $query->getOption('search_api_access_account', '(' . t('none') . ')');
+ if (is_object($account)) {
+ $account = $account->uid;
+ }
+ if (!is_scalar($account)) {
+ $account = var_export($account, TRUE);
+ }
+ watchdog('search_api', 'An illegal user UID was given for node access: @uid.', array('@uid' => $account), WATCHDOG_WARNING);
+ }
+ }
+}
+
+/**
+ * Adds a node access filter to a search query, if applicable.
+ *
+ * @param object $account
+ * The user object, who searches.
+ * @param SearchApiQueryInterface $query
+ * The query to which a node access filter should be added, if applicable.
+ * @param string $type
+ * (optional) The type of search – either "node" or "comment". Defaults to
+ * "node".
+ *
+ * @throws SearchApiException
+ * If not all necessary fields are indexed on the index.
+ */
+function _search_api_query_add_node_access($account, SearchApiQueryInterface $query, $type = 'node') {
+ // Don't do anything if the user can access all content.
+ if (user_access('bypass node access', $account)) {
+ return;
+ }
+
+ $is_comment = ($type == 'comment');
+
+ // Check whether the necessary fields are indexed.
+ $fields = $query->getIndex()->options['fields'];
+ $required = array('search_api_access_node', 'status');
+ if (!$is_comment) {
+ $required[] = 'author';
+ }
+ foreach ($required as $field) {
+ if (empty($fields[$field])) {
+ $vars['@field'] = $field;
+ $vars['@index'] = $query->getIndex()->name;
+ throw new SearchApiException(t('Required field @field not indexed on index @index. Could not perform access checks.', $vars));
+ }
+ }
+
+ // If the user cannot access content/comments at all, return no results.
+ if (!user_access('access content', $account) || ($is_comment && !user_access('access comments', $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;
+ }
+
+ // Filter by the "published" status.
+ $published = $is_comment ? COMMENT_PUBLISHED : NODE_PUBLISHED;
+ if (!$is_comment && user_access('view own unpublished content')) {
+ $filter = $query->createFilter('OR');
+ $filter->condition('status', $published);
+ $filter->condition('author', $account->uid);
+ $query->filter($filter);
+ }
+ else {
+ $query->condition('status', $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);
+}
+
+/**
+ * Determines whether a field of the given type contains text data.
+ *
+ * Can also be used to find other types.
+ *
+ * @param string $type
+ * The type for which to check.
+ * @param array $allowed
+ * Optionally, an array of allowed types.
+ *
+ * @return bool
+ * TRUE if $type is either one of the specified types or a list of such
+ * values. FALSE otherwise.
+ *
+ * @see search_api_extract_inner_type()
+ */
+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 bool
+ * 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 int
+ * 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 string
+ * 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 string
+ * 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;
+}
+
+/**
+ * Helper function for reacting to index updates with regards to the datasource.
+ *
+ * When an overridden index is reverted, its numerical ID will sometimes change.
+ * Since the default datasource implementation uses that for referencing
+ * indexes, the index ID in the items table must be updated accordingly. This is
+ * implemented in this function.
+ *
+ * Modules implementing other datasource controllers, that use a table other
+ * than {search_api_item}, can use this function, too. It should be called
+ * unconditionally in a hook_search_api_index_update() implementation. If this
+ * function isn't used, similar code should be added there.
+ *
+ * However, note that this is only necessary (and this function should only be
+ * called) if the indexes are referenced by numerical ID in the items table.
+ *
+ * @param SearchApiIndex $index
+ * The index that was changed.
+ * @param string $table
+ * The table containing items information, analogous to {search_api_item}.
+ * @param string $column
+ * The column in $table that holds the index's numerical ID.
+ */
+function search_api_index_update_datasource(SearchApiIndex $index, $table, $column = 'index_id') {
+ if ($index->id != $index->original->id) {
+ db_update($table)
+ ->fields(array($column => $index->id))
+ ->condition($column, $index->original->id)
+ ->execute();
+ }
+}
+
+/**
+ * Extracts specific field values 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 at least a "type" key present.
+ * @param array $value_options
+ * An array of options that should be passed to the
+ * EntityMetadataWrapper::value() method (see there).
+ *
+ * @return array
+ * The $fields array with additional "value" and "original_type" keys set.
+ */
+function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields, array $value_options = array()) {
+ $value_options += array(
+ 'identifier' => TRUE,
+ );
+ // 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 &$info) {
+ $info['value'] = array();
+ $info['original_type'] = $info['type'];
+ }
+ unset($info);
+ try {
+ foreach ($wrapper as $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'];
+ if (isset($wrapper->$field)) {
+ try {
+ // Set the original type according to the field wrapper's info.
+ $property_info = $wrapper->$field->info();
+ $info['original_type'] = $property_info['type'];
+
+ // Extract the basic value from the field wrapper.
+ $info['value'] = $wrapper->$field->value($value_options);
+
+ // For entities, we need to take care to differentiate between
+ // entities with ID 0 and empty fields. In the latter case, the
+ // wrapper's value() method returns, when called with "identifier =
+ // TRUE", FALSE instead of the (more logical) NULL.
+ $is_entity = isset($entity_infos[search_api_extract_inner_type($property_info['type'])]);
+ if ($is_entity && $info['value'] === FALSE) {
+ $info['value'] = NULL;
+ }
+
+ // If we index the field as fulltext, we also include the entity label
+ // or option list label, if applicable.
+ if (search_api_is_text_type($info['type']) && isset($info['value'])) {
+ if ($wrapper->$field->optionsList('view')) {
+ _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
+ }
+ elseif ($is_entity) {
+ $info['value'] = _search_api_extract_entity_value($wrapper->$field, TRUE);
+ }
+ }
+ }
+ 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 &$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_multiple()
+ *
+ * @param array|false $ids
+ * An array of server IDs or machine names, or FALSE to load all servers.
+ * @param array $conditions
+ * An array of conditions on the {search_api_server} table in the form
+ * 'field' => $value.
+ * @param bool $reset
+ * Whether to reset the internal entity_load_multiple cache.
+ *
+ * @return SearchApiServer[]
+ * An array of server objects keyed by machine name.
+ */
+function search_api_server_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
+ $servers = entity_load_multiple('search_api_server', $ids, $conditions, $reset);
+ return entity_plus_key_array_by_property($servers, 'machine_name');
+}
+
+/**
+ * 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 viewing or editing a server or index.
+ */
+function search_api_admin_item_title($object) {
+ return $object->name;
+}
+
+/**
+ * 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_plus_has_status($entity->entityType(), $entity, ENTITY_PLUS_OVERRIDDEN) ? t('Revert') : t('Delete');
+}
+
+/**
+ * Determines whether the current user can disable a server or index.
+ *
+ * @param Entity $entity
+ * The server or index for which the access to the "disable" page is checked.
+ *
+ * @return bool
+ * TRUE if the "disable" page can be accessed by the user, FALSE otherwise.
+ */
+function search_api_access_disable_page(Entity $entity) {
+ return user_access('administer search_api') && !empty($entity->enabled);
+}
+
+/**
+ * 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 bool
+ * 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_plus_has_status($entity->entityType(), $entity, ENTITY_PLUS_CUSTOM);
+}
+
+/**
+ * Determines whether a user can access a certain search server or index.
+ *
+ * Used as an access callback in search_api_entity_info().
+ */
+function search_api_entity_access() {
+ return user_access('administer search_api');
+}
+
+/**
+ * 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 string|int $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 int|false
+ * 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 string|int $id
+ * The ID or machine name of the server to enable.
+ *
+ * @return int|false
+ * 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.
+ *
+ * Will also disable all associated indexes and remove them from the server.
+ *
+ * @param string|int $id
+ * The ID or machine name of the server to disable.
+ *
+ * @return int|false
+ * 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;
+}
+
+/**
+ * Clears a search server.
+ *
+ * Will delete all items stored on the server and mark all associated indexes
+ * for re-indexing.
+ *
+ * @param int|string $id
+ * The ID or machine name of the server to clear.
+ *
+ * @return bool
+ * TRUE on success, FALSE on failure.
+ */
+function search_api_server_clear($id) {
+ $server = search_api_server_load($id);
+ $success = TRUE;
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ $success &= $index->reindex();
+ }
+ if ($success) {
+ $server->deleteItems();
+ }
+ return $success;
+}
+
+/**
+ * Deletes a search server and disables all associated indexes.
+ *
+ * @param $id
+ * The ID or machine name of the server to delete.
+ *
+ * @return int|false
+ * 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|false
+ * A completely loaded index object, or FALSE if no such index exists.
+ */
+function search_api_index_load($id, $reset = FALSE) {
+ $ret = search_api_index_load_multiple(array($id), array(), $reset);
+ return reset($ret);
+}
+
+/**
+ * Load multiple indexes at once, determined by IDs or machine names, or by
+ * other conditions.
+ *
+ * @see entity_load_multiple()
+ *
+ * @param array|false $ids
+ * An array of index IDs or machine names, or FALSE to load all indexes.
+ * @param array $conditions
+ * An array of conditions on the {search_api_index} table in the form
+ * 'field' => $value.
+ * @param bool $reset
+ * Whether to reset the internal entity_load_multiple cache.
+ *
+ * @return SearchApiIndex[]
+ * 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();
+ $indexes = entity_load_multiple('search_api_index', $ids, $conditions, $reset);
+ return entity_plus_key_array_by_property($indexes, 'machine_name');
+}
+
+/**
+ * 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(),
+ );
+}
+
+/**
+ * Returns an index's server.
+ *
+ * Used as a property getter callback for the index's "server_entity" prioperty
+ * in search_api_entity_property_info().
+ *
+ * @param SearchApiIndex $index
+ * The index whose server should be returned.
+ *
+ * @return SearchApiServer|null
+ * The server this index currently resides on, or NULL if the index is
+ * currently unassigned.
+ */
+function search_api_index_get_server(SearchApiIndex $index) {
+ try {
+ return $index->server();
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return NULL;
+ }
+}
+
+/**
+ * Returns an options list for the "status" property.
+ *
+ * Used as an options list callback in search_api_entity_property_info().
+ *
+ * @return array
+ * An array of options, as defined by hook_options_list().
+ */
+function search_api_status_options_list() {
+ return array(
+ ENTITY_PLUS_CUSTOM => t('Custom'),
+ ENTITY_PLUS_IN_CODE => t('Default'),
+ ENTITY_PLUS_OVERRIDDEN => t('Overridden'),
+ ENTITY_PLUS_FIXED => t('Fixed'),
+ );
+}
+
+/**
+ * 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 int|string $id
+ * The edited index' ID or machine name.
+ * @param array $fields
+ * The new field values to set.
+ *
+ * @return int|false
+ * 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 int|string $id
+ * The ID or machine name of the index whose fields should be changed.
+ * @param array $fields
+ * The new indexed field settings.
+ *
+ * @return int|false
+ * 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 string|int $id
+ * The ID or machine name of the index to enable.
+ *
+ * @return int|false
+ * 1 on success, 0 or FALSE on failure.
+ *
+ * @throws SearchApiException
+ * If the index's server doesn't exist.
+ */
+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 string|int $id
+ * The ID or machine name of the index to disable.
+ *
+ * @return int|false
+ * 1 on success, 0 or FALSE on failure.
+ *
+ * @throws SearchApiException
+ * If the index's server doesn't exist.
+ */
+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 bool
+ * 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);
+}
+
+/**
+ * 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 bool
+ * 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 bool
+ * 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;
+}
+
+/**
+ * Sanitizes field values returned from the server.
+ *
+ * @param array $values
+ * The field values, as returned from the server. See
+ * SearchApiQueryInterface::execute() for documentation on the structure.
+ *
+ * @return array
+ * An associative array of field IDs mapped to their sanitized values (scalar
+ * or array-valued).
+ */
+function search_api_get_sanitized_field_values(array $values) {
+ // Sanitize the field values returned from the server. Usually we use
+ // check_plain(), but this can be overridden by setting the field value to
+ // an array with "#value" and "#sanitize_callback" keys.
+ foreach ($values as $field_id => $field_value) {
+ if (is_array($field_value)
+ && isset($field_value['#sanitize_callback'])
+ && ($field_value['#sanitize_callback'] === FALSE || is_callable($field_value['#sanitize_callback']))
+ && array_key_exists('#value', $field_value)
+ ) {
+ $sanitize_callback = $field_value['#sanitize_callback'];
+ $field_value = $field_value['#value'];
+ }
+ else {
+ $sanitize_callback = 'check_plain';
+ }
+ if ($sanitize_callback !== FALSE) {
+ $field_value = search_api_sanitize_field_value($field_value, $sanitize_callback);
+ }
+ $values[$field_id] = $field_value;
+ }
+ return $values;
+}
+
+/**
+ * Sanitizes the given field value(s).
+ *
+ * @param mixed $field_value
+ * A scalar field value, or an array of field values.
+ * @param callable $sanitize_callback
+ * (optional) The callback to use for sanitizing a scalar value.
+ *
+ * @return mixed
+ * The sanitized field value(s).
+ */
+function search_api_sanitize_field_value($field_value, $sanitize_callback = 'check_plain') {
+ if ($field_value === NULL) {
+ return $field_value;
+ }
+ if (is_scalar($field_value)) {
+ return call_user_func($sanitize_callback, $field_value);
+ }
+ foreach ($field_value as &$nested_value) {
+ $nested_value = search_api_sanitize_field_value($nested_value, $sanitize_callback);
+ }
+ return $field_value;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * Options list callback for entity types.
+ *
+ * Will only include entity types which specify entity property information.
+ *
+ * @return string[]
+ * An array of entity type machine names mapped to their human-readable
+ * names.
+ */
+function search_api_entity_type_options_list() {
+ $types = array();
+ foreach (array_keys(entity_plus_get_property_info()) as $type) {
+ $info = entity_get_info($type);
+ if ($info) {
+ $types[$type] = $info['label'];
+ }
+ }
+ return $types;
+}
+
+/**
+ * Options list callback for entity type bundles.
+ *
+ * Will include all bundles for all entity types which specify entity property
+ * information, in a format combining both entity type and bundle.
+ *
+ * @return string[]
+ * An array of bundle identifiers mapped to their human-readable names.
+ */
+function search_api_combined_bundle_options_list() {
+ $types = array();
+ foreach (array_keys(entity_plus_get_property_info()) as $type) {
+ $info = entity_get_info($type);
+ if (!empty($info['bundles'])) {
+ foreach ($info['bundles'] as $bundle => $bundle_info) {
+ $types["$type:$bundle"] = $bundle_info['label'];
+ }
+ }
+ }
+ return $types;
+}
+
+/**
+ * Retrieves a human-readable label for a multi-type index item.
+ *
+ * Provided as a non-object alternative to
+ * SearchApiCombinedEntityDataSourceController::getItemLabel() so it can be used
+ * as a getter callback.
+ *
+ * @param object $item
+ * An item of the "multiple" item type.
+ *
+ * @return string|null
+ * Either a human-readable label for the item, or NULL if none is available.
+ */
+function search_api_get_multi_type_item_label($item) {
+ $label = entity_label($item->item_type, $item->{$item->item_type});
+ return $label ? $label : NULL;
+}
+
+/**
+ * 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) {
+ if ($index->enabled && !$index->read_only) {
+ 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_plus_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;
+}
+
+/**
+ * Determines the number of items indexed on a server for a certain index.
+ *
+ * Used as a helper function in search_api_admin_index_view().
+ *
+ * @param SearchApiIndex $index
+ * The index.
+ *
+ * @return int
+ * The number of items found on the server for this index, if the latter is
+ * enabled. 0 otherwise.
+ *
+ * @throws SearchApiException
+ * If an error prevented the search from completing.
+ */
+function _search_api_get_items_on_server(SearchApiIndex $index) {
+ if (!$index->enabled) {
+ return 0;
+ }
+ // We want the raw count, without facets or other filters. Therefore we don't
+ // use the query's execute() method but pass it straight to the server for
+ // evaluation. Since this circumvents the normal preprocessing, which sets the
+ // fields (on which some service classes might even rely when there are no
+ // keywords), we set them manually here.
+ $query = $index->query()
+ ->fields(array())
+ ->range(0, 0);
+ $response = $index->server()->search($query);
+ return $response['result count'];
+}
+
+/**
+ * Returns a deep copy of the input array.
+ *
+ * The behavior of PHP regarding arrays with references pointing to it is rather
+ * weird. Therefore, we use this helper function in theme_search_api_index() to
+ * create safe copies of such arrays.
+ *
+ * @param array $array
+ * The array to copy.
+ *
+ * @return array
+ * A deep copy of the array.
+ */
+function _search_api_deep_copy(array $array) {
+ $copy = array();
+ foreach ($array as $k => $v) {
+ if (is_array($v)) {
+ $copy[$k] = _search_api_deep_copy($v);
+ }
+ elseif (is_object($v)) {
+ $copy[$k] = clone $v;
+ }
+ elseif ($v) {
+ $copy[$k] = $v;
+ }
+ }
+ return $copy;
+}
+
+/**
+ * Reacts to a change in the bundle of an entity.
+ *
+ * Used as a helper function in search_api_entity_update().
+ *
+ * @param $type
+ * The entity's type.
+ * @param $id
+ * The entity's ID.
+ * @param $old_bundle
+ * The entity's previous bundle.
+ * @param $new_bundle
+ * The entity's new bundle.
+ */
+function _search_api_entity_datasource_bundle_change($type, $id, $old_bundle, $new_bundle) {
+ $controller = search_api_get_datasource_controller($type);
+ $conditions = array(
+ 'enabled' => 1,
+ 'item_type' => $type,
+ 'read_only' => 0,
+ );
+ foreach (search_api_index_load_multiple(FALSE, $conditions) as $index) {
+ if (!empty($index->options['datasource']['bundles'])) {
+ $bundles = backdrop_map_assoc($index->options['datasource']['bundles']);
+ if (empty($bundles[$new_bundle]) != empty($bundles[$old_bundle])) {
+ if (empty($bundles[$new_bundle])) {
+ $controller->trackItemDelete(array($id), array($index));
+ }
+ else {
+ $controller->trackItemInsert(array($id), array($index));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Creates and sets a batch for indexing items.
+ *
+ * @param SearchApiIndex $index
+ * The index for which items should be indexed.
+ * @param int $batch_size
+ * Number of items to index per batch.
+ * @param int $limit
+ * Maximum number of items to index. Negative values mean "no limit".
+ * @param int $remaining
+ * Remaining items to index.
+ * @param bool $drush
+ * Boolean specifying whether this was called from drush or not.
+ *
+ * @return bool
+ * Whether the batch was created and set successfully.
+ */
+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' => backdrop_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 $context
+ * An array (or object implementing ArrayAccess) containing the batch context.
+ */
+function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size, $limit, $drush, &$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'] = isset($drush) ? $drush : FALSE;
+ }
+
+ // Number of items to index for this run.
+ $to_index = min($context['sandbox']['limit'] - $context['sandbox']['progress'], $context['sandbox']['batch_size']);
+
+ // Index the items.
+ try {
+ $indexed = search_api_index_items($index, $to_index);
+ $context['results']['indexed'] += $indexed;
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ $vars['@message'] = $e->getMessage();
+ $context['message'] = t('An error occurred during indexing: @message.', $vars);
+ $context['finished'] = 1;
+ $context['results']['not indexed'] += $context['sandbox']['limit'] - $context['sandbox']['progress'];
+ return;
+ }
+
+ // 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
+ * Whether the batch finished successfully.
+ * @param array $results
+ * Detailed information about the result.
+ */
+function _search_api_batch_indexing_finished($success, $results) {
+ // Check if called from drush.
+ if (!empty($results['drush'])) {
+ $backdrop_set_message = 'drush_log';
+ $format_plural = '_search_api_drush_format_plural';
+ $t = 'dt';
+ $success_message = 'success';
+ }
+ else {
+ $backdrop_set_message = 'backdrop_set_message';
+ $format_plural = 'format_plural';
+ $t = 't';
+ $success_message = 'status';
+ }
+
+ // Display result messages.
+ if ($success) {
+ if (!empty($results['indexed'])) {
+ $backdrop_set_message($format_plural($results['indexed'], 'Successfully indexed 1 item.', 'Successfully indexed @count items.'), $success_message);
+
+ if (!empty($results['not indexed'])) {
+ $backdrop_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 {
+ $backdrop_set_message($t("Couldn't index items. Check the logs for details."), 'error');
+ }
+ }
+ else {
+ $backdrop_set_message($t("An error occurred while trying to index items. Check the logs for details."), 'error');
+ }
+
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function search_api_autoload_info() {
+ return array(
+ 'SearchApiAlterCallbackInterface' => 'includes/callback.inc',
+ 'SearchApiAbstractAlterCallback' => 'includes/callback.inc',
+ 'SearchApiAlterAddAggregation' => 'includes/callback_add_aggregation.inc',
+ 'SearchApiAlterAddHierarchy' => 'includes/callback_add_hierarchy.inc',
+ 'SearchApiAlterAddUrl' => 'includes/callback_add_url.inc',
+ 'SearchApiAlterAddViewedEntity' => 'includes/callback_add_viewed_entity.inc',
+ 'SearchApiAlterBundleFilter' => 'includes/callback_bundle_filter.inc',
+ 'SearchApiAlterCommentAccess' => 'includes/callback_comment_access.inc',
+ 'SearchApiAlterLanguageControl' => 'includes/callback_language_control.inc',
+ 'SearchApiAlterNodeAccess' => 'includes/callback_node_access.inc',
+ 'SearchApiAlterNodeStatus' => 'includes/callback_node_status.inc',
+ 'SearchApiAlterRoleFilter' => 'includes/callback_role_filter.inc',
+ 'SearchApiAlterAddUserContent' => 'includes/callback_user_content.inc',
+ 'SearchApiAlterUserStatus' => 'includes/callback_user_status.inc',
+ 'SearchApiDataSourceControllerInterface' => 'includes/datasource.inc',
+ 'SearchApiAbstractDataSourceController' => 'includes/datasource.inc',
+ 'SearchApiEntityDataSourceController' => 'includes/datasource_entity.inc',
+ 'SearchApiExternalDataSourceController' => 'includes/datasource_external.inc',
+ 'SearchApiCombinedEntityDataSourceController' => 'includes/datasource_multiple.inc',
+ 'SearchApiException' => 'includes/exception.inc',
+ 'SearchApiDataSourceException' => 'includes/exception.inc',
+ 'SearchApiIndex' => 'includes/index_entity.inc',
+ 'SearchApiProcessorInterface' => 'includes/processor.inc',
+ 'SearchApiAbstractProcessor' => 'includes/processor.inc',
+ 'SearchApiHighlight' => 'includes/processor_highlight.inc',
+ 'SearchApiHtmlFilter' => 'includes/processor_html_filter.inc',
+ 'SearchApiIgnoreCase' => 'includes/processor_ignore_case.inc',
+ 'SearchApiPorterStemmer' => 'includes/processor_stemmer.inc',
+ 'SearchApiPorter2' => 'includes/processor_stemmer.inc',
+ 'SearchApiStopWords' => 'includes/processor_stopwords.inc',
+ 'SearchApiTokenizer' => 'includes/processor_tokenizer.inc',
+ 'SearchApiTransliteration' => 'includes/processor_transliteration.inc',
+ 'SearchApiQueryInterface' => 'includes/query.inc',
+ 'SearchApiQuery' => 'includes/query.inc',
+ 'SearchApiQueryFilterInterface' => 'includes/query.inc',
+ 'SearchApiQueryFilter' => 'includes/query.inc',
+ 'SearchApiServer' => 'includes/server_entity.inc',
+ 'SearchApiServiceInterface' => 'includes/service.inc',
+ 'SearchApiAbstractService' => 'includes/service.inc',
+ 'SearchApiAlterFileEntityPublic' => 'includes/callback_file_entity_public.inc',
+ );
+}
+
+/**
+ * Swaps the current theme for the given one.
+ *
+ * Can be used when needing the theme layer during indexing, to produce
+ * predictable results.
+ *
+ * Based on the mailsystem module's mailsystem_theme_swap_theme().
+ *
+ * @param string $new_theme
+ * The new theme to set.
+ *
+ * @return string|false
+ * The old theme on success, FALSE if the new theme doesn't exist.
+ *
+ * @see mailsystem_theme_swap_theme()
+ */
+function _search_api_swap_theme($new_theme) {
+ // Make sure the theme exists.
+ $themes = list_themes();
+ if (empty($themes[$new_theme])) {
+ return FALSE;
+ }
+
+ // Both theme/theme_key are set to the new theme.
+ global $theme, $theme_key;
+
+ // Nothing to do here.
+ if ($theme == $new_theme) {
+ return $theme;
+ }
+
+ $old_theme = $theme;
+ $theme = $theme_key = $new_theme;
+
+ // Create the base_theme_info.
+ global $base_theme_info;
+ $base_theme = array();
+ $ancestor = $theme;
+ while ($ancestor && isset($themes[$ancestor]->base_theme)) {
+ $ancestor = $themes[$ancestor]->base_theme;
+ $base_theme[] = $themes[$ancestor];
+ }
+ $base_theme_info = array_reverse($base_theme);
+
+ // Some other theme related globals.
+ global $theme_engine, $theme_info, $theme_path;
+ $theme_engine = $themes[$theme]->engine;
+ $theme_info = $themes[$theme];
+ $theme_path = dirname($themes[$theme]->filename);
+
+ // We need to reset the backdrop_alter and module_implements statics.
+ backdrop_static_reset('backdrop_alter');
+ backdrop_static_reset('module_implements');
+
+ return $old_theme;
+}
diff --git a/www/modules/contrib/search_api/search_api.rules.inc b/www/modules/contrib/search_api/search_api.rules.inc
new file mode 100644
index 000000000..7f288a862
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.rules.inc
@@ -0,0 +1,88 @@
+ 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(EntityBackdropWrapper $wrapper, ?SearchApiIndex $index = NULL, $index_immediately = TRUE) {
+ // If we do not have an index, we need to guess the item type to use.
+ // @todo Since this can only be used with entities anyways, we can just loop
+ // over the item type information and use all types with that entity type.
+ $type = $wrapper->type();
+ $item_ids = array($wrapper->getIdentifier());
+
+ if (empty($index) && !$index_immediately) {
+ search_api_track_item_change($type, $item_ids);
+ return;
+ }
+
+ if ($index) {
+ $type = $index->item_type;
+ $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) {
+ search_api_index_specific_items_delayed($index, $item_ids);
+ }
+ }
+ else {
+ search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
+ }
+}
diff --git a/www/modules/contrib/search_api/search_api.test b/www/modules/contrib/search_api/search_api.test
new file mode 100644
index 000000000..ee3a100e4
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.test
@@ -0,0 +1,1205 @@
+assertResponse(200, 'HTTP code 200 returned.');
+ return $ret;
+ }
+
+ /**
+ * Overrides BackdropWebTestCase::backdropPost().
+ *
+ * Additionally asserts that the HTTP request returned a 200 status code.
+ */
+ protected function backdropPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
+ $ret = parent::backdropPost($path, $edit, $submit, $options, $headers, $form_html_id, $extra_post);
+ $this->assertResponse(200, 'HTTP code 200 returned.');
+ return $ret;
+ }
+
+ /**
+ * Returns information about this test case.
+ *
+ * @return array
+ * An array with information about this test case.
+ */
+ 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',
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setUp() {
+ parent::setUp('entity', 'search_api', 'search_api_test', 'entity_plus');
+ }
+
+ /**
+ * Tests correct admin UI, indexing and search behavior.
+ *
+ * We only use a single test method to avoid wasting ressources on setting up
+ * the test environment multiple times. This will be the only method called
+ * by the Simpletest framework (since the method name starts with "test"). It
+ * in turn calls other methdos that set up the environment in a certain way
+ * and then run tests on it.
+ */
+ public function testFramework() {
+ module_enable(array('search_api_test_2'));
+ $this->backdropLogin($this->backdropCreateUser(array('administer search_api')));
+ $this->insertItems();
+ $this->createIndex();
+ $this->insertItems();
+ $this->createServer();
+ $this->checkOverview();
+ $this->enableIndex();
+ $this->searchNoResults();
+ $this->indexItems();
+ $this->searchSuccess();
+ $this->checkIndexingOrder();
+ $this->editServer();
+ $this->clearIndex();
+ $this->searchNoResults();
+ $this->deleteServer();
+ $this->disableModules();
+ }
+
+ /**
+ * Returns the test server in use by this test case.
+ *
+ * @return SearchApiServer
+ * The test server.
+ */
+ protected function server() {
+ return search_api_server_load($this->server_id, TRUE);
+ }
+
+ /**
+ * Returns the test index in use by this test case.
+ *
+ * @return SearchApiIndex
+ * The test index.
+ */
+ protected function index() {
+ return search_api_index_load($this->index_id, TRUE);
+ }
+
+ /**
+ * Inserts some test items into the database, via the test module.
+ *
+ * @param int $number
+ * The number of items to insert.
+ *
+ * @see insertItem()
+ */
+ protected function insertItems($number = 5) {
+ $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField();
+ for ($i = 1; $i <= $number; ++$i) {
+ $id = $count + $i;
+ $this->insertItem(array(
+ 'id' => $id,
+ 'title' => "Title $id",
+ 'body' => "Body text $id.",
+ 'type' => 'Item',
+ ));
+ }
+ $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count;
+ $this->assertEqual($count, $number, "$number items successfully inserted.");
+ }
+
+ /**
+ * Helper function for inserting a single test item.
+ *
+ * @param array $values
+ * The property values of the test item.
+ *
+ * @see search_api_test_insert_item()
+ */
+ protected function insertItem(array $values) {
+ $this->backdropPost('search_api_test/insert', $values, t('Save'));
+ }
+
+ /**
+ * Creates a test index via the UI and tests whether this works correctly.
+ */
+ protected function createIndex() {
+ $values = array(
+ 'name' => '',
+ 'item_type' => '',
+ 'enabled' => 1,
+ 'description' => 'An index used for testing.',
+ 'server' => '',
+ 'options[cron_limit]' => 5,
+ );
+ $this->backdropPost('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->backdropPost(NULL, $values, t('Create index'));
+
+ $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), 'The index was successfully created.');
+ $found = strpos($this->getUrl(), 'admin/config/search/search_api/index/' . $id) !== FALSE;
+ $this->assertTrue($found, 'Correct redirect.');
+ $index = $this->index();
+ $this->assertEqual($index->name, $values['name'], 'Name correctly inserted.');
+ $this->assertEqual($index->item_type, $values['item_type'], 'Index item type correctly inserted.');
+ $this->assertFalse($index->enabled, 'Status correctly inserted.');
+ $this->assertEqual($index->description, $values['description'], 'Description correctly inserted.');
+ $this->assertNull($index->server, 'Index server correctly inserted.');
+ $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], 'Cron batch size correctly inserted.');
+
+ $values = array(
+ 'additional[field]' => 'parent',
+ );
+ $this->backdropPost("admin/config/search/search_api/index/$id/fields", $values, t('Add fields'));
+ $this->assertText(t('The available fields were successfully changed.'), 'Successfully added fields.');
+ $this->assertText('Parent » ID', 'Added fields are displayed.');
+
+ $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->backdropPost(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.'), '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->backdropPost(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->backdropPost(NULL, $values, t('Save configuration'));
+ $this->assertText(t("The indexing workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), 'Workflow successfully edited.');
+
+ $this->backdropGet("admin/config/search/search_api/index/$id");
+ $this->assertTitle('Search API test index | Backdrop', 'Correct title when viewing index.');
+ $this->assertText('An index used for testing.', 'Description displayed.');
+ $this->assertText('Search API test entity', 'Item type displayed.');
+ $this->assertText(t('disabled'), '"Disabled" status displayed.');
+ }
+
+ /**
+ * Creates a test server via the UI and tests whether this works correctly.
+ */
+ protected function createServer() {
+ $values = array(
+ 'name' => '',
+ 'enabled' => 1,
+ 'description' => 'A server used for testing.',
+ 'class' => '',
+ );
+ $this->backdropPost('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->backdropPost(NULL, $values, t('Create server'));
+
+ $values2 = array(
+ 'options[form][test]' => 'search_api_test foo bar',
+ );
+ $this->backdropPost(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, 'Correct redirect.');
+ $server = $this->server();
+ $this->assertEqual($server->name, $values['name'], 'Name correctly inserted.');
+ $this->assertTrue($server->enabled, 'Status correctly inserted.');
+ $this->assertEqual($server->description, $values['description'], 'Description correctly inserted.');
+ $this->assertEqual($server->class, $values['class'], 'Service class correctly inserted.');
+ $this->assertEqual($server->options['test'], $values2['options[form][test]'], 'Service options correctly inserted.');
+ $this->assertTitle('Search API test server | Backdrop', 'Correct title when viewing server.');
+ $this->assertText('A server used for testing.', 'Description displayed.');
+ $this->assertText('search_api_test_service', 'Service name displayed.');
+ $this->assertText('search_api_test foo bar', 'Service options displayed.');
+ }
+
+ /**
+ * Checks whether the server and index are correctly listed in the overview.
+ */
+ protected function checkOverview() {
+ $this->backdropGet('admin/config/search/search_api');
+ $this->assertText('Search API test server', 'Server displayed.');
+ $this->assertText('Search API test index', 'Index displayed.');
+ $this->assertNoText(t('There are no search servers or indexes defined yet.'), '"No servers" message not displayed.');
+ }
+
+ /**
+ * Moves the index onto the server and enables it.
+ */
+ protected function enableIndex() {
+ $values = array(
+ 'server' => $this->server_id,
+ );
+ $this->backdropPost("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', 'Server displayed.');
+
+ $this->clickLink(t('enable'));
+ $this->assertText(t('The index was successfully enabled.'));
+ }
+
+ /**
+ * Asserts that a search on the index works but yields no results.
+ *
+ * This is the case since no items should have been indexed yet.
+ */
+ protected function searchNoResults() {
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 0, 'No search results returned without indexing.');
+ $this->assertEqual(array_keys($results['results']), array(), 'No search results returned without indexing.');
+ }
+
+ /**
+ * Executes a search on the test index.
+ *
+ * Helper method used for testing search results.
+ *
+ * @param int|null $offset
+ * (optional) The offset for the returned results.
+ * @param int|null $limit
+ * (optional) The limit for the returned results.
+ *
+ * @return array
+ * Search results as specified by SearchApiQueryInterface::execute().
+ */
+ protected function doSearch($offset = NULL, $limit = NULL) {
+ // Since we change server and index settings via the UI (and, therefore, in
+ // different page requests), the static cache in this page request
+ // (executing the tests) will get stale. Therefore, we clear it before
+ // executing the search.
+ $this->index();
+ $this->server();
+
+ $query = search_api_query($this->index_id);
+ if ($offset || $limit) {
+ $query->range($offset, $limit);
+ }
+ return $query->execute();
+ }
+
+ /**
+ * Tests indexing via the UI "Index now" functionality.
+ *
+ * Asserts that errors during indexing are handled properly and that the
+ * status readings work.
+ */
+ protected function indexItems() {
+ $this->checkIndexStatus();
+
+ // Here we test the indexing + the warning message when some items
+ // cannot be indexed.
+ // The server refuses (for test purpose) to index the item that has the same
+ // ID as the "search_api_test_indexing_break" variable (default: 8).
+ // Therefore, if we try to index 8 items, only the first seven will be
+ // successfully indexed and a warning should be displayed.
+ $values = array(
+ 'limit' => 8,
+ );
+ $this->backdropPost(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.'), 'Index errors warning is displayed.');
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(7);
+
+ // Here we're testing the error message when no item could be indexed.
+ // The item with ID 8 is still not indexed, but it will be the first to be
+ // indexed now. Therefore, if we try to index a single items, only item 8
+ // will be passed to the server, which will reject it and no items will be
+ // indexed. Since normally this signifies a more serious error than when
+ // only some items couldn't be indexed, this is handled differently.
+ $values = array(
+ 'limit' => 1,
+ );
+ $this->backdropPost(NULL, $values, t('Index now'));
+ $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', 'No items could be indexed.');
+ $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), "Index errors warning isn't displayed.");
+ $this->assertText(t("Couldn't index items. Check the logs for details."), 'Index error is displayed.');
+
+ // Now we set the "search_api_test_indexing_break" variable to 0, so all
+ // items will be indexed. The remaining items (8, 9, 10) should therefore
+ // be successfully indexed and no warning should show.
+ config_set('search_api_test.settings', 'search_api_test_indexing_break', 0);
+ $values = array(
+ 'limit' => -1,
+ );
+ $this->backdropPost(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."), "Index errors warning isn't displayed.");
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(10);
+
+ // Reset the static cache for the server.
+ $this->server();
+ }
+
+ /**
+ * Checks whether the index's "Status" tab shows the correct values.
+ *
+ * Helper method used by indexItems() and others.
+ *
+ * The internal browser will point to the index's "Status" tab after this
+ * method is called.
+ *
+ * @param int $indexed
+ * (optional) The number of items that should be indexed at the moment.
+ * Defaults to 0.
+ * @param int $total
+ * (optional) The (correct) total number of items. Defaults to 10.
+ * @param bool $check_buttons
+ * (optional) Whether to check for the correct presence/absence of buttons.
+ * Defaults to TRUE.
+ * @param int|null $on_server
+ * (optional) The number of items actually on the server. Defaults to
+ * $indexed.
+ */
+ protected function checkIndexStatus($indexed = 0, $total = 10, $check_buttons = TRUE, $on_server = NULL) {
+ $url = "admin/config/search/search_api/index/{$this->index_id}";
+ if (strpos($this->url, $url) === FALSE) {
+ $this->backdropGet($url);
+ }
+
+ $index_status = t('@indexed/@total indexed', array('@indexed' => $indexed, '@total' => $total));
+ $this->assertText($index_status, 'Correct index status displayed.');
+
+ if (!isset($on_server)) {
+ $on_server = $indexed;
+ }
+ $info = format_plural($on_server, 'There is 1 item indexed on the server for this index.', 'There are @count items indexed on the server for this index.');
+ $this->assertText(t('Server index status'), 'Server index status displayed.');
+ $this->assertText($info, 'Correct server index status displayed.');
+
+ if (!$check_buttons) {
+ return;
+ }
+
+ $this->assertText(t('enabled'), '"Enabled" status displayed.');
+ if ($indexed == $total) {
+ $this->assertRaw('disabled="disabled"', '"Index now" form disabled.');
+ }
+ else {
+ $this->assertNoRaw('disabled="disabled"', '"Index now" form enabled.');
+ }
+ }
+
+ /**
+ * Tests whether searches yield the right results after indexing.
+ *
+ * The test server only implements range functionality, no kind of fulltext
+ * search capabilities, so we can only test for that.
+ */
+ protected function searchSuccess() {
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 10, 'Correct search result count returned after indexing.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), 'Correct search results returned after indexing.');
+
+ $results = $this->doSearch(2, 4);
+ $this->assertEqual($results['result count'], 10, 'Correct search result count with ranged query.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4, 5, 6), 'Correct search results with ranged query.');
+ }
+
+ /**
+ * Tests whether items are indexed in the right order.
+ *
+ * The indexing order should always be that new items are indexed before
+ * changed ones, and only then the changed items in the order of their change.
+ *
+ * This method also assures that this behavior is even observed when indexing
+ * temporarily fails.
+ *
+ * @see https://drupal.org/node/2115127
+ */
+ protected function checkIndexingOrder() {
+ // Set cron batch size to 1 so not all items will get indexed right away.
+ // This also ensures that later, when indexing of a single item will be
+ // rejected by using the "search_api_test_indexing_break" variable, this
+ // will have the effect of rejecting "all" items of a batch (since that
+ // batch only consists of a single item).
+ $values = array(
+ 'options[cron_limit]' => 1,
+ );
+ $this->backdropPost("admin/config/search/search_api/index/{$this->index_id}/edit", $values, t('Save settings'));
+ $this->assertText(t('The search index was successfully edited.'));
+
+ // Manually clear the server's item storage – that way, the items will still
+ // count as indexed for the Search API, but won't be returned in searches.
+ // We do this so we have finer-grained control over the order in which items
+ // are indexed.
+ $this->server()->deleteItems();
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 0, 'Indexed items were successfully deleted from the server.');
+ $this->assertEqual(array_keys($results['results']), array(), 'Indexed items were successfully deleted from the server.');
+
+ // Now insert some new items, and mark others as changed. Make sure that
+ // each action has a unique timestamp, so the order will be correct.
+ $this->backdropGet('search_api_test/touch/8');
+ $this->insertItems(1);// Item 11.
+ sleep(1);
+ $this->backdropGet('search_api_test/touch/2');
+ $this->insertItems(1);// Item 12.
+ sleep(1);
+ $this->backdropGet('search_api_test/touch/5');
+ $this->insertItems(1);// Item 13.
+ sleep(1);
+ $this->backdropGet('search_api_test/touch/8');
+ $this->insertItems(1); // Item 14.
+
+ // Check whether the status display is right.
+ $this->checkIndexStatus(7, 14, FALSE, 0);
+
+ // Indexing order should now be: 11, 12, 13, 14, 8, 2, 4. Let's try it out!
+ // First manually index one item, and see if it's 11.
+ $values = array(
+ 'limit' => 1,
+ );
+ $this->backdropPost(NULL, $values, t('Index now'));
+ $this->assertText(t('Successfully indexed @count item.', array('@count' => 1)));
+ $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), "Index errors warning isn't displayed.");
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(8, 14, FALSE, 1);
+
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 1, 'Indexing order test 1: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(11), 'Indexing order test 1: correct results.');
+
+ // Now index with a cron run, but stop at item 8.
+ config_set('search_api_test.settings', 'search_api_test_indexing_break', 8);
+ $this->cronRun();
+ // Now just the four new items should have been indexed.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 4, 'Indexing order test 2: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(11, 12, 13, 14), 'Indexing order test 2: correct results.');
+
+ // This time stop at item 5 (should be the last one).
+ config_set('search_api_test.settings', 'search_api_test_indexing_break', 5);
+ $this->cronRun();
+ // Now all new and changed items should have been indexed, except item 5.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 6, 'Indexing order test 3: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(2, 8, 11, 12, 13, 14), 'Indexing order test 3: correct results.');
+
+ // Index the remaining item.
+ config_set('search_api_test.settings', 'search_api_test_indexing_break', 0);
+ $this->cronRun();
+ // Now all new and changed items should have been indexed.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 7, 'Indexing order test 4: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 8, 11, 12, 13, 14), 'Indexing order test 4: correct results.');
+ }
+
+ /**
+ * Tests whether the server tasks system works correctly.
+ *
+ * Uses the "search_api_test_error_state" variable to trigger exceptions in
+ * the test service class and asserts that the Search API reacts correctly and
+ * re-attempts the operation on the next cron run.
+ */
+ protected function checkServerTasks() {
+ // Make sure none of the previous operations added any tasks.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'No server tasks were previously saved.');
+
+ // Set error state for test service, so all operations will fail.
+ config_set('search_api_test.settings', 'search_api_test_error_state', TRUE);
+
+ // Delete some items.
+ $this->backdropGet('search_api_test/delete/8');
+ $this->backdropGet('search_api_test/delete/12');
+
+ // Assert that the indexed items haven't changed yet.
+ $results = $this->doSearch();
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 8, 11, 12, 13, 14), 'During error state, no indexed items were deleted.');
+
+ // Check that tasks were correctly inserted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 2, 'Server tasks for deleted items were saved.');
+
+ // Now reset the error state variable and run cron to delete the items.
+ config_set('search_api_test.settings', 'search_api_test_error_state', FALSE);
+ $this->cronRun();
+
+ // Assert that the indexed items were indeed deleted from the server.
+ $results = $this->doSearch();
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 11, 13, 14), 'Pending "delete item" server tasks were correctly executed during the cron run.');
+
+ // Check that the tasks were correctly deleted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Server tasks were correctly deleted after being executed.');
+
+ // Now we first delete more items, then disable the server (thereby removing
+ // the index from it) – all while in error state.
+ config_set('search_api_test.settings', 'search_api_test_error_state', TRUE);
+ $this->backdropGet('search_api_test/delete/14');
+ $this->backdropGet('search_api_test/delete/2');
+ $settings['enabled'] = 0;
+ $this->backdropPost("admin/config/search/search_api/server/{$this->server_id}/edit", $settings, t('Save settings'));
+
+ // Check whether the index was correctly removed from the server.
+ $this->assertEqual($this->index()->server(), NULL, 'The index was successfully set to have no server.');
+ $exception = FALSE;
+ try {
+ $this->doSearch();
+ }
+ catch (SearchApiException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, 'Searching on the index failed with an exception.');
+
+ // Check that only one task – to remove the index from the server – is now
+ // present in the tasks table.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 1, 'Only the "remove index" task is present in the server tasks.');
+
+ // Reset the error state variable, re-enable the server.
+ config_set('search_api_test.settings', 'search_api_test_error_state', FALSE);
+ $settings['enabled'] = 1;
+ $this->backdropPost("admin/config/search/search_api/server/{$this->server_id}/edit", $settings, t('Save settings'));
+
+ // Check whether the index was really removed from the server now.
+ $server = $this->server();
+ $this->assertTrue(empty($server->options['indexes'][$this->index_id]), 'The index was removed from the server after cron ran.');
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Server tasks were correctly deleted after being executed.');
+
+ // Put the index back on the server and index some items for the next tests.
+ $settings = array('server' => $this->server_id);
+ $this->backdropPost("admin/config/search/search_api/index/{$this->index_id}/edit", $settings, t('Save settings'));
+ $this->cronRun();
+ }
+
+ /**
+ * Tests whether editing the server works correctly.
+ */
+ protected function editServer() {
+ $values = array(
+ 'name' => 'test-name-foo',
+ 'description' => 'test-description-bar',
+ 'options[form][test]' => 'test-test-baz',
+ );
+ $this->backdropPost("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', 'Name changed.');
+ $this->assertText('test-description-bar', 'Description changed.');
+ $this->assertText('test-test-baz', 'Service options changed.');
+ }
+
+ /**
+ * Tests whether clearing the index works correctly.
+ */
+ protected function clearIndex() {
+ $this->backdropPost("admin/config/search/search_api/index/{$this->index_id}", array(), t('Clear all indexed data'));
+ $this->backdropPost(NULL, array(), t('Confirm'));
+ $this->assertText(t('The index was successfully cleared.'));
+ $this->assertText(t('@indexed/@total indexed', array('@indexed' => 0, '@total' => 14)), 'Correct index status displayed.');
+ }
+
+ /**
+ * Tests whether deleting the server works correctly.
+ *
+ * The index still lying on the server should be disabled and removed from it.
+ * Also, any tasks with that server's ID should be deleted.
+ */
+ protected function deleteServer() {
+ // Insert some dummy tasks to check for.
+ $server = $this->server();
+ search_api_server_tasks_add($server, 'foo');
+ search_api_server_tasks_add($server, 'bar', $this->index());
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 2, 'Dummy tasks were added.');
+
+ // Delete the server.
+ $this->backdropPost("admin/config/search/search_api/server/{$this->server_id}/delete", array(), t('Confirm'));
+ $this->assertNoText('test-name-foo', 'Server no longer listed.');
+ $this->backdropGet("admin/config/search/search_api/index/{$this->index_id}");
+ $this->assertNoText(t('Server'), 'The index was removed from the server.');
+ $this->assertText(t('disabled'), 'The index was disabled.');
+
+ // Check whether the tasks were correctly deleted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Remaining server tasks were correctly deleted.');
+ }
+
+ /**
+ * Tests whether disabling and uninstalling the modules works correctly.
+ *
+ * This will disable and uninstall both the test module and the Search API. It
+ * asserts that this works correctly (since the server has been deleted in
+ * deleteServer()) and that all associated tables and variables are removed.
+ */
+ protected function disableModules() {
+ module_disable(array('search_api_test_2'), FALSE);
+ $this->assertFalse(module_exists('search_api_test_2'), 'Second test module was successfully disabled.');
+ module_disable(array('search_api_test'), FALSE);
+ $this->assertFalse(module_exists('search_api_test'), 'First test module was successfully disabled.');
+ module_disable(array('search_api'), FALSE);
+ $this->assertFalse(module_exists('search_api'), 'Search API module was successfully disabled.');
+
+ backdrop_uninstall_modules(array('search_api_test_2'), FALSE);
+ $this->assertEqual(backdrop_get_installed_schema_version('search_api_test_2', TRUE), SCHEMA_UNINSTALLED, 'Second test module was successfully uninstalled.');
+ backdrop_uninstall_modules(array('search_api_test'), FALSE);
+ $this->assertEqual(backdrop_get_installed_schema_version('search_api_test', TRUE), SCHEMA_UNINSTALLED, 'First test module was successfully uninstalled.');
+ $this->assertFalse(db_table_exists('search_api_test'), 'Test module table was successfully removed.');
+ backdrop_uninstall_modules(array('search_api'), FALSE);
+ $this->assertEqual(backdrop_get_installed_schema_version('search_api', TRUE), SCHEMA_UNINSTALLED, 'Search API module was successfully uninstalled.');
+ $this->assertFalse(db_table_exists('search_api_server'), 'Search server table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_index'), 'Search index table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_item'), 'Index items table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_task'), 'Server tasks table was successfully removed.');
+ $this->assertNull(config_set('search_api.settings', 'search_api_index_worker_callback_runtime', 0), 'Worker runtime variable was correctly removed.');
+ }
+
+}
+
+/**
+ * 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 BackdropWebTestCase.
+ */
+class SearchApiUnitTest extends BackdropWebTestCase {
+
+ /**
+ * The index used by these tests.
+ *
+ * @var SearchApIindex
+ */
+ protected $index;
+
+ /**
+ * Overrides BackdropTestCase::assertEqual().
+ *
+ * For arrays, checks whether all array keys are mapped the same in both
+ * arrays recursively, while ignoring their order.
+ */
+ 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);
+ }
+ }
+
+ /**
+ * Tests whether two values are equal.
+ *
+ * For arrays, this is done by comparing the key/value pairs recursively
+ * instead of checking for simple equality.
+ *
+ * @param mixed $first
+ * The first value.
+ * @param mixed $second
+ * The second value.
+ *
+ * @return bool
+ * TRUE if the two values are equal, FALSE otherwise.
+ */
+ 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);
+ }
+
+ /**
+ * Returns information about this test case.
+ *
+ * @return array
+ * An array with information about this test case.
+ */
+ 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',
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setUp() {
+ parent::setUp('entity', 'search_api', 'entity_plus');
+ $this->index = entity_create('search_api_index', array(
+ 'id' => 1,
+ 'name' => 'test',
+ 'machine_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',
+ ),
+ ),
+ ),
+ ));
+ }
+
+ /**
+ * Tests the functionality of several components of the module.
+ *
+ * This is the single test method called by the Simpletest framework. It in
+ * turn calls other helper methods to test specific functionality.
+ */
+ public function testUnits() {
+ $this->checkQueryParseKeys();
+ $this->checkIgnoreCaseProcessor();
+ $this->checkTokenizer();
+ $this->checkHtmlFilter();
+ $this->checkEntityDatasource();
+ }
+
+ /**
+ * Checks whether the keys are parsed correctly by the query class.
+ */
+ protected function checkQueryParseKeys() {
+ $options['parse mode'] = 'direct';
+ $mode = &$options['parse mode'];
+ $query = new SearchApiQuery($this->index, $options);
+
+ $query->keys('foo');
+ $this->assertEqual($query->getKeys(), 'foo', '"Direct query" parse mode, test 1.');
+ $query->keys('foo bar');
+ $this->assertEqual($query->getKeys(), 'foo bar', '"Direct query" parse mode, test 2.');
+ $query->keys('(foo bar) OR "bar baz"');
+ $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', '"Direct query" parse mode, test 3.');
+
+ $mode = 'single';
+ $query = new SearchApiQuery($this->index, $options);
+
+ $query->keys('foo');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Single term" parse mode, test 1.');
+ $query->keys('foo bar');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), '"Single term" parse mode, test 2.');
+ $query->keys('(foo bar) OR "bar baz"');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), '"Single term" parse mode, test 3.');
+
+ $mode = 'terms';
+ $query = new SearchApiQuery($this->index, $options);
+
+ $query->keys('foo');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Multiple terms" parse mode, test 1.');
+ $query->keys('foo bar');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), '"Multiple terms" parse mode, test 2.');
+ $query->keys('(foo bar) OR "bar baz"');
+ $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), '"Multiple terms" parse mode, test 3.');
+ // http://drupal.org/node/1468678
+ $query->keys('"Münster"');
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), '"Multiple terms" parse mode, test 4.');
+ }
+
+ /**
+ * Tests the functionality of the "Ignore case" processor.
+ */
+ protected function checkIgnoreCaseProcessor() {
+ $orig = 'Foo bar BaZ, ÄÖÜÀÁ<>»«.';
+ $processed = backdrop_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, 'Name field was processed.');
+ $this->assertEqual($tmp[1]['mail']['value'], $orig, "Mail field wasn't processed.");
+
+ $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, 'Search keys were processed correctly.');
+ $this->assertEqual($query->getFilter()->getFilters(), $filters1, '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, 'Name field was processed.');
+ $this->assertEqual($tmp[1]['mail']['value'], $processed, 'Mail field was processed.');
+
+ $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, 'Search keys were processed correctly.');
+ $this->assertEqual($query->getFilter()->getFilters(), $filters2, 'Filters were processed correctly.');
+ }
+
+ /**
+ * Tests the functionality of the "Tokenizer" processor.
+ */
+ protected 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, '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', '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, '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', 'Search keys were processed correctly.');
+ }
+
+ /**
+ * Tests the functionality of the "HTML filter" processor.
+ */
+ protected function checkHtmlFilter() {
+ $orig = <<a test.Header
+How to write links to other sites: <a href="URL" title="MOUSEOVER TEXT">TEXT</a>.
+< signs can be escaped with "<".
+
+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' => 'Header', 'score' => 3),
+ 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, 'Text was correctly processed.');
+ }
+
+ /**
+ * Tests the entity datasource controller and its bundle setting.
+ */
+ protected function checkEntityDatasource() {
+ // First, create the necessary content types.
+ $type = (object) array(
+ 'type' => 'article',
+ 'base' => 'article',
+ );
+ node_type_save($type);
+ $type->type = $type->base = 'page';
+ node_type_save($type);
+
+ // Now, create some nodes.
+ $node = entity_create('node', array(
+ 'title' => 'Foo',
+ 'type' => 'article',
+ ));
+ node_save($node);
+ $nid1 = $node->nid;
+ $node = entity_create('node', array(
+ 'title' => 'Bar',
+ 'type' => 'article',
+ ));
+ node_save($node);
+ $node = (object) array(
+ 'title' => 'Baz',
+ 'type' => 'page',
+ );
+ node_save($node);
+
+ // We can't use $this->index here, since users don't have bundles.
+ $index = entity_create('search_api_index', array(
+ 'id' => 2,
+ 'name' => 'test2',
+ 'machine_name' => 'test2',
+ 'enabled' => 1,
+ 'item_type' => 'node',
+ 'options' => array(
+ 'fields' => array(
+ 'nid' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ ));
+
+ // Now start tracking and check whether the index status is correct.
+ $datasource = search_api_get_datasource_controller('node');
+ $datasource->startTracking(array($index));
+ $status = $datasource->getIndexStatus($index);
+ $this->assertEqual($status['total'], 3, 'Correct number of items marked for indexing on not bundle-specific index.');
+ $datasource->stopTracking(array($index));
+
+ // Once again, but with only indexing articles.
+ $index->options['datasource']['bundles'] = array('article');
+ backdrop_static_reset('search_api_get_datasource_controller');
+ $datasource = search_api_get_datasource_controller('node');
+ $datasource->startTracking(array($index));
+ $status = $datasource->getIndexStatus($index);
+ $this->assertEqual($status['total'], 2, 'Correct number of items marked for indexing on bundle-specific index.');
+ $datasource->stopTracking(array($index));
+
+ // Now test that bundle renaming works.
+ $index->save();
+ field_attach_rename_bundle('node', 'article', 'foo');
+ $index = search_api_index_load('test2', TRUE);
+ $this->assertEqual($index->options['datasource']['bundles'], array('foo'), 'Bundle was correctly renamed in index settings.');
+ $index->delete();
+ }
+
+}
diff --git a/www/modules/contrib/search_api/search_api.tests.info b/www/modules/contrib/search_api/search_api.tests.info
new file mode 100644
index 000000000..1f1aba9a9
--- /dev/null
+++ b/www/modules/contrib/search_api/search_api.tests.info
@@ -0,0 +1,17 @@
+[SearchApiWebTest]
+name = Test search API framework
+description = Tests basic functions of the Search API, like creating, editing and deleting servers and indexes.
+group = Search API
+file = search_api.test
+
+[SearchApiUnitTest]
+name = Test search API components
+description = Tests some independent components of the Search API, like the processors.
+group = Search API
+file = search_api.test
+
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/tests/config/search_api_test.settings.json b/www/modules/contrib/search_api/tests/config/search_api_test.settings.json
new file mode 100644
index 000000000..9f8b60cfc
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/config/search_api_test.settings.json
@@ -0,0 +1,5 @@
+{
+ "_config_name": "search_api_test.settings",
+ "search_api_test_indexing_break": "",
+ "search_api_test_error_state": "FALSE"
+}
\ No newline at end of file
diff --git a/www/modules/contrib/search_api/tests/search_api_test.info b/www/modules/contrib/search_api/tests/search_api_test.info
new file mode 100644
index 000000000..8cda4a243
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test.info
@@ -0,0 +1,14 @@
+name = Search API Test
+description = "Some dummy implementations for testing the Search API."
+backdrop = 1.x
+type = module
+package = Search
+
+dependencies[] = search_api:search_api
+
+hidden = TRUE
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/tests/search_api_test.install b/www/modules/contrib/search_api/tests/search_api_test.install
new file mode 100644
index 000000000..29d3763e5
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test.install
@@ -0,0 +1,73 @@
+ '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' => FALSE,
+ ),
+ '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' => FALSE,
+ ),
+ 'keywords' => array(
+ 'description' => 'A comma separated list of keywords.',
+ 'type' => 'varchar',
+ 'length' => 200,
+ 'not null' => FALSE,
+ ),
+ 'prices' => array(
+ 'description' => 'A comma separated list of prices.',
+ 'type' => 'varchar',
+ 'length' => 200,
+ 'not null' => FALSE,
+ ),
+ ),
+ 'primary key' => array('id'),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_update_N().
+ */
+function search_api_test_update_1000() {
+ $config = config('search_api_test.settings');
+ $config->set('search_api_test_indexing_break', update_variable_get('search_api_test_indexing_break', '8'));
+ $config->set('search_api_test_error_state', update_variable_get('search_api_test_error_state', 'FALSE'));
+ update_variable_del('search_api_test_indexing_break');
+ update_variable_del('search_api_test_error_state');
+}
+
+/**
+ * Implements hook_install().
+ */
+function search_api_test_install() {
+ // Dynamically generated variable data was detected.
+}
diff --git a/www/modules/contrib/search_api/tests/search_api_test.module b/www/modules/contrib/search_api/tests/search_api_test.module
new file mode 100644
index 000000000..e9a8fcc82
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test.module
@@ -0,0 +1,400 @@
+ array(
+ 'title' => 'Insert item',
+ 'page callback' => 'backdrop_get_form',
+ 'page arguments' => array('search_api_test_insert_item'),
+ 'access callback' => TRUE,
+ ),
+ 'search_api_test/view/%search_api_test' => array(
+ 'title' => 'View item',
+ 'page callback' => 'search_api_test_view',
+ 'page arguments' => array(2),
+ 'access callback' => TRUE,
+ ),
+ 'search_api_test/touch/%search_api_test' => array(
+ 'title' => 'Mark item as changed',
+ 'page callback' => 'search_api_test_touch',
+ 'page arguments' => array(2),
+ 'access callback' => TRUE,
+ ),
+ 'search_api_test/delete/%search_api_test' => array(
+ 'title' => 'Delete items',
+ 'page callback' => 'search_api_test_delete',
+ 'page arguments' => array(2),
+ 'access callback' => TRUE,
+ ),
+ );
+}
+
+/**
+ * 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',
+ ),
+ 'keywords' => array(
+ '#type' => 'textfield',
+ ),
+ 'prices' => 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(array_filter($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_multiple('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 nl2br(check_plain(print_r($entity, TRUE)));
+}
+
+/**
+ * Menu callback for marking a "search_api_test" entity as changed.
+ */
+function search_api_test_touch($entity) {
+ module_invoke_all('entity_update', $entity, 'search_api_test');
+}
+
+/**
+ * Menu callback for marking a "search_api_test" entity as changed.
+ */
+function search_api_test_delete($entity) {
+ db_delete('search_api_test')->condition('id', $entity->id)->execute();
+ module_invoke_all('entity_delete', $entity, 'search_api_test');
+}
+
+/**
+ * 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',
+ ),
+ 'keywords' => array(
+ 'label' => 'Keywords',
+ 'type' => 'list',
+ 'description' => 'An optional collection of keywords describing the item.',
+ 'getter callback' => 'search_api_test_list_callback',
+ ),
+ 'prices' => array(
+ 'label' => 'Prices',
+ 'type' => 'list',
+ 'description' => 'An optional list of prices.',
+ 'getter callback' => 'search_api_test_list_callback',
+ ),
+ );
+
+ 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);
+}
+
+/**
+ * List callback.
+ */
+function search_api_test_list_callback($data, array $options, $name) {
+ if (is_array($data)) {
+ $res = is_array($data[$name]) ? $data[$name] : explode(',', $data[$name]);
+ }
+ else {
+ $res = is_array($data->$name) ? $data->$name : explode(',', $data->$name);
+ }
+ if ($name == 'prices') {
+ foreach ($res as &$x) {
+ $x = (float) $x;
+ }
+ }
+ return array_filter($res);
+}
+
+/**
+ * Implements hook_search_api_service_info().
+ */
+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 {
+
+ /**
+ * Overrides SearchApiAbstractService::configurationForm().
+ *
+ * Returns a single text field for testing purposes.
+ */
+ 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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addIndex(SearchApiIndex $index) {
+ $this->checkErrorState();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ $this->checkErrorState();
+ return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeIndex($index) {
+ $this->checkErrorState();
+ parent::removeIndex($index);
+ }
+
+ /**
+ * Implements hook_config_info().
+ */
+ function search_api_test_config_info() {
+ $prefixes['search_api_test.settings'] = array(
+ 'label' => t('Search API Test settings'),
+ 'group' => t('Configuration'),
+ );
+ return $prefixes;
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::indexItems().
+ *
+ * Indexes items by storing their IDs in the server's options.
+ *
+ * If the "search_api_test_indexing_break" variable is set, the item with
+ * that ID will not be indexed.
+ */
+ public function indexItems(SearchApiIndex $index, array $items) {
+ $this->checkErrorState();
+ // Refuse to index the item with the same ID as the
+ // "search_api_test_indexing_break" variable, if it is set.
+ $exclude = config_get('search_api_test.settings', 'search_api_test_indexing_break');
+ foreach ($items as $id => $item) {
+ if ($id == $exclude) {
+ unset($items[$id]);
+ }
+ }
+ $ids = array_keys($items);
+
+ $this->options += array('indexes' => array());
+ $this->options['indexes'] += array($index->machine_name => array());
+ $this->options['indexes'][$index->machine_name] += backdrop_map_assoc($ids);
+ asort($this->options['indexes'][$index->machine_name]);
+ $this->server->save();
+
+ return $ids;
+ }
+
+ /**
+ * Overrides SearchApiAbstractService::preDelete().
+ *
+ * Overridden so deleteItems() isn't called which would otherwise lead to the
+ * server being updated and, eventually, to a notice because there is no
+ * server to be updated anymore.
+ */
+ public function preDelete() { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteItems($ids = 'all', ?SearchApiIndex $index = NULL) {
+ $this->checkErrorState();
+ if ($ids == 'all') {
+ if ($index) {
+ $this->options['indexes'][$index->machine_name] = array();
+ }
+ else {
+ $this->options['indexes'] = array();
+ }
+ }
+ else {
+ foreach ($ids as $id) {
+ unset($this->options['indexes'][$index->machine_name][$id]);
+ }
+ }
+ $this->server->save();
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::indexItems().
+ *
+ * Will ignore all query settings except the range, as only the item IDs are
+ * indexed.
+ */
+ public function search(SearchApiQueryInterface $query) {
+ $options = $query->getOptions();
+ $ret = array();
+ $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;
+ }
+
+ /**
+ * Throws an exception if the "search_api_test_error_state" variable is set.
+ *
+ * @throws SearchApiException
+ * If the "search_api_test_error_state" variable is set.
+ */
+ protected function checkErrorState() {
+ if (config_get('search_api_test.settings', 'search_api_test_error_state')) {
+ throw new SearchApiException();
+ }
+ }
+
+ /**
+ * Implements hook_autoload_info().
+ */
+ function search_api_test_autoload_info() {
+ return array(
+ 'SearchApiTestService' => 'search_api_test.module',
+ 'SearchApiDummyService' => 'search_api_test_2.module',
+ );
+ }
+
+}
diff --git a/www/modules/contrib/search_api/tests/search_api_test.tests.info b/www/modules/contrib/search_api/tests/search_api_test.tests.info
new file mode 100644
index 000000000..34d6985a6
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test.tests.info
@@ -0,0 +1,5 @@
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/tests/search_api_test_2.info b/www/modules/contrib/search_api/tests/search_api_test_2.info
new file mode 100644
index 000000000..790040c91
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test_2.info
@@ -0,0 +1,14 @@
+name = Search API Test Service 2
+description = "A module providing a second test search service."
+backdrop = 1.x
+type = module
+package = Search
+
+dependencies[] = search_api:search_api
+
+hidden = TRUE
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api/tests/search_api_test_2.module b/www/modules/contrib/search_api/tests/search_api_test_2.module
new file mode 100644
index 000000000..70f8c5993
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test_2.module
@@ -0,0 +1,146 @@
+ $name,
+ 'description' => 'search_api_test_service_2 description',
+ 'class' => 'SearchApiDummyService',
+ );
+ return $services;
+}
+
+/**
+ * Implements hook_default_search_api_server().
+ */
+function search_api_test_2_default_search_api_server() {
+ $id = 'test_server_2';
+ $items[$id] = entity_create('search_api_server', array(
+ 'name' => 'Search API test server 2',
+ 'machine_name' => $id,
+ 'enabled' => 1,
+ 'description' => 'A server used for testing.',
+ 'class' => 'search_api_test_service_2',
+ ));
+ return $items;
+}
+
+/**
+ * Dummy service for testing.
+ */
+class SearchApiDummyService implements SearchApiServiceInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(SearchApiServer $server) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsFeature($feature) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function viewSettings() {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postCreate() { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postUpdate() {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preDelete() { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addIndex(SearchApiIndex $index) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeIndex($index) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function indexItems(SearchApiIndex $index, array $items) {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteItems($ids = 'all', ?SearchApiIndex $index = NULL) { }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query(SearchApiIndex $index, $options = array()) {
+ throw new SearchApiException("The dummy service doesn't support queries");
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function search(SearchApiQueryInterface $query) {
+ return array();
+ }
+
+ /**
+ * Implements hook_autoload_info().
+ */
+ function search_api_test_2_autoload_info() {
+ return array(
+ 'SearchApiTestService' => 'search_api_test.module',
+ 'SearchApiDummyService' => 'search_api_test_2.module',
+ );
+ }
+}
diff --git a/www/modules/contrib/search_api/tests/search_api_test_2.tests.info b/www/modules/contrib/search_api/tests/search_api_test_2.tests.info
new file mode 100644
index 000000000..34d6985a6
--- /dev/null
+++ b/www/modules/contrib/search_api/tests/search_api_test_2.tests.info
@@ -0,0 +1,5 @@
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api
+version = 1.x-1.29.5
+timestamp = 1770395528
diff --git a/www/modules/contrib/search_api_db/LICENSE.txt b/www/modules/contrib/search_api_db/LICENSE.txt
new file mode 100644
index 000000000..89e08fb00
--- /dev/null
+++ b/www/modules/contrib/search_api_db/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/www/modules/contrib/search_api_db/README.md b/www/modules/contrib/search_api_db/README.md
new file mode 100644
index 000000000..10a29001a
--- /dev/null
+++ b/www/modules/contrib/search_api_db/README.md
@@ -0,0 +1,71 @@
+# Search API Database Search
+
+This module provides a database based implementation of the Search API. The
+database and target to use for storing and accessing the indexes can be selected
+when creating a new server.
+
+All Search API datatypes are supported by using appropriate SQL datatypes for
+their respective columns (with "String"/"URI", and "Integer"/"Duration" being
+equivalent).
+
+The "direct" parse mode for queries will result in a simple splitting of the
+query string into keys. Additionally, search keys containing whitespace will be
+split, as searching for phrases is currently not supported.
+
+## Installation
+
+ - Install this module and its dependencies using the official
+ [Backdrop CMS instructions](https://backdropcms.org/guide/modules)
+
+## Configuration and Usage
+
+### Hidden configuration variables
+
+- `autocomplete_max_occurrences` (default: 0.9)
+ By default, keywords that occur in more than 90% of results are ignored for
+ autocomplete suggestions. This setting lets you modify that behaviour by
+ providing your own ratio. Use 1 or greater to use all suggestions.
+
+### Supported optional features
+
+- `search_api_autocomplete`
+ Introduced by module: search_api_autocomplete
+ Lets you add autocompletion capabilities to search forms on the site. (See
+ also "Hidden variables" below for backend-specific customization.)
+ NOTE: Due to internal database restrictions, this will perform significantly
+ better if only a single field is used for autocompletion.
+- `search_api_facets`
+ Introduced by module: search_api_facetapi
+ Allows you to create facetted searches for dynamically filtering search
+ results.
+
+If you feel some service option is missing, or have other ideas for improving
+this implementation, please file a feature request in the project's issue queue.
+
+### Known problems
+
+Using facets with a database server will only work if the database user
+Backdrop is using has the "CREATE TEMPORARY TABLES" permission.
+
+### Developer information
+
+Database queries for searches with this module are tagged with
+`search_api_db_search` to allow easy altering. As metadata, such database
+queries will have the Search API query object set as `search_api_query`, and the
+field settings of the server for the corresponding search index as
+`search_api_db_fields`.
+
+## Current maintainers
+
+- [Laryn Kragt Bakker](https://github.com/laryn)
+
+## Credits
+
+ - Ported to Backdrop by [docwilmot](https://github.com/docwilmot)
+ - Maintainer on Drupal [drunken monkey](https://www.drupal.org/u/drunken-monkey)
+
+## License
+
+This project is GPL v2 software. See the LICENSE.txt file in this directory
+for complete text.
+
diff --git a/www/modules/contrib/search_api_db/config/search_api_db.settings.json b/www/modules/contrib/search_api_db/config/search_api_db.settings.json
new file mode 100644
index 000000000..0a6129532
--- /dev/null
+++ b/www/modules/contrib/search_api_db/config/search_api_db.settings.json
@@ -0,0 +1,4 @@
+{
+ "_config_name": "search_api_db.settings",
+ "autocomplete_max_occurrences": 0.9
+}
\ No newline at end of file
diff --git a/www/modules/contrib/search_api_db/search_api_db.api.php b/www/modules/contrib/search_api_db/search_api_db.api.php
new file mode 100644
index 000000000..8541d6745
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.api.php
@@ -0,0 +1,35 @@
+getOption('custom_sql_conditions')) {
+ foreach ($custom as $condition) {
+ $db_query->condition($condition['field'], $condition['value'], $condition['operator']);
+ }
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/www/modules/contrib/search_api_db/search_api_db.info b/www/modules/contrib/search_api_db/search_api_db.info
new file mode 100644
index 000000000..53fc8ad02
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.info
@@ -0,0 +1,12 @@
+name = Database search
+description = "Offers an implementation of the Search API that uses database tables for indexing content."
+dependencies[] = search_api
+backdrop = 1.x
+type = module
+package = Search
+
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api_db
+version = 1.x-1.9.2
+timestamp = 1770378646
diff --git a/www/modules/contrib/search_api_db/search_api_db.install b/www/modules/contrib/search_api_db/search_api_db.install
new file mode 100644
index 000000000..ec1d6aa72
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.install
@@ -0,0 +1,24 @@
+set('autocomplete_max_occurrences', update_variable_get('search_api_db_autocomplete_max_occurrences', 0.9));
+ $config->save();
+ update_variable_del('search_api_db_autocomplete_max_occurrences');
+}
+
diff --git a/www/modules/contrib/search_api_db/search_api_db.module b/www/modules/contrib/search_api_db/search_api_db.module
new file mode 100644
index 000000000..4aca5604c
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.module
@@ -0,0 +1,37 @@
+ t('Database service'),
+ 'description' => t('Index items using multiple database tables, for simple searches.
' .
+ '' . '- All field types are supported and indexed in a special way, with URI/String and Integer/Duration being equivalent.
' .
+ '- The "direct" parse mode results in the keys being normally split around white-space, only preprocessing might differ.
' .
+ '- Currently, phrase queries are not supported.
' . '
'),
+ 'class' => 'SearchApiDbService',
+ );
+ return $services;
+}
+
+/**
+ * Implements hook_autoload_info().
+ */
+function search_api_db_autoload_info() {
+ return array(
+ 'SearchApiDbService' => 'service.inc',
+ );
+}
+
+/**
+ * Implements hook_config_info().
+ */
+function search_api_db_config_info() {
+ return array(
+ 'search_api_db.settings' => array(
+ 'label' => 'Search API Database settings',
+ 'group' => 'Configuration',
+ ),
+ );
+}
diff --git a/www/modules/contrib/search_api_db/search_api_db.test b/www/modules/contrib/search_api_db/search_api_db.test
new file mode 100644
index 000000000..1afb79366
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.test
@@ -0,0 +1,1050 @@
+assertResponse(200, t('HTTP code 200 returned.'));
+ return $ret;
+ }
+
+ protected function backdropPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
+ $ret = parent::backdropPost($path, $edit, $submit, $options, $headers, $form_html_id, $extra_post);
+ $this->assertResponse(200, t('HTTP code 200 returned.'));
+ return $ret;
+ }
+
+ public function setUp() {
+ parent::setUp('entity', 'search_api', 'search_api_db', 'search_api_test', 'entity_plus');
+ }
+
+ public function testFramework() {
+ if (Database::getConnection()->databaseType() == 'mysql') {
+ try {
+ db_query("SET SESSION sql_mode = 'ANSI,ONLY_FULL_GROUP_BY'");
+ }
+ catch (Exception $e) {
+ // It was worth a try, but if it fails just go on.
+ }
+ }
+ $this->backdropLogin($this->backdropCreateUser(array('administer search_api')));
+ $this->insertItems();
+ $this->createServer();
+ $this->createIndex();
+ $this->searchNoResults();
+ $this->indexItems();
+ $this->searchSuccess1();
+ $this->checkFacets();
+ $this->regressionTests();
+ $this->editServerPartial();
+ $this->searchSuccessPartial();
+ $this->editServer();
+ $this->searchSuccess2();
+ $this->clearIndex();
+ $this->searchNoResults();
+ $this->regressionTests2();
+ $this->uninstallModule();
+ }
+
+ protected function insertItems() {
+ $this->backdropGet('search_api_test/insert');
+ $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField();
+ $this->insertItem(array(
+ 'id' => 1,
+ 'title' => 'foo bar baz foobaz',
+ 'body' => 'test test',
+ 'type' => 'item',
+ 'keywords' => 'orange',
+ ));
+ $this->insertItem(array(
+ 'id' => 2,
+ 'title' => 'foo test foobuz',
+ 'body' => 'bar test',
+ 'type' => 'item',
+ 'keywords' => 'orange,apple,grape',
+ ));
+ $this->insertItem(array(
+ 'id' => 3,
+ 'title' => 'bar',
+ 'body' => 'test foobar',
+ ));
+ $this->insertItem(array(
+ 'id' => 4,
+ 'title' => 'foo baz',
+ 'body' => 'test test test',
+ 'type' => 'article',
+ 'keywords' => 'apple,strawberry,grape',
+ ));
+ $this->insertItem(array(
+ 'id' => 5,
+ 'title' => 'bar baz',
+ 'body' => 'foo',
+ 'type' => 'article',
+ 'keywords' => 'orange,strawberry,grape,banana,orange,Orange',
+ ));
+ $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count;
+ $this->assertEqual($count, 5, "$count items inserted.");
+ }
+
+ protected function insertItem($values) {
+ $this->backdropPost(NULL, $values, t('Save'));
+ }
+
+ protected function createServer() {
+ $this->server_id = 'database_search_server';
+ global $databases;
+ $database = 'default:default';
+ // Make sure to pick an available connection and to not rely on any
+ // defaults.
+ foreach ($databases as $key => $targets) {
+ foreach ($targets as $target => $info) {
+ $database = "$key:$target";
+ break;
+ }
+ }
+ $values = array(
+ 'name' => 'Database search server',
+ 'machine_name' => $this->server_id,
+ 'enabled' => 1,
+ 'description' => 'A server used for testing.',
+ 'class' => 'search_api_db_service',
+ 'options' => array(
+ 'min_chars' => 3,
+ 'database' => $database,
+ 'partial_matches' => FALSE,
+ ),
+ );
+ $success = (bool) entity_create('search_api_server', $values)->save();
+ $this->assertTrue($success, 'The server was successfully created.');
+ }
+
+ protected function createIndex() {
+ $this->index_id = 'test_index';
+ $values = array(
+ 'name' => 'Test index',
+ 'machine_name' => $this->index_id,
+ 'item_type' => 'search_api_test',
+ 'enabled' => 1,
+ 'description' => 'An index used for testing.',
+ 'server' => $this->server_id,
+ 'options' => array(
+ 'cron_limit' => -1,
+ 'index_directly' => TRUE,
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ ),
+ 'title' => array(
+ 'type' => 'text',
+ 'boost' => '5.0',
+ ),
+ 'body' => array(
+ 'type' => 'text',
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ ),
+ 'keywords' => array(
+ 'type' => 'list',
+ ),
+ 'search_api_language' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ );
+ $index = entity_create('search_api_index', $values);
+ $success = (bool) $index->save();
+ $this->assertTrue($success, 'The index was successfully created.');
+ $status = search_api_index_status($index);
+ $this->assertEqual($status['total'], 5, 'Correct item count.');
+ $this->assertEqual($status['indexed'], 0, 'All items still need to be indexed.');
+ }
+
+ protected function buildSearch($keys = NULL, array $filters = array(), array $fields = array()) {
+ $query = search_api_query($this->index_id);
+ if ($keys) {
+ $query->keys($keys);
+ if ($fields) {
+ $query->fields($fields);
+ }
+ }
+ foreach ($filters as $filter) {
+ list($field, $value) = explode(',', $filter, 2);
+ $query->condition($field, $value);
+ }
+ $query->range(0, 10);
+
+ return $query;
+ }
+
+ protected function searchNoResults() {
+ $results = $this->buildSearch('test')->execute();
+ $this->assertEqual($results['result count'], 0, 'No search results returned without indexing.');
+ $this->assertEqual(array_keys($results['results']), array(), 'No search results returned without indexing.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+ }
+
+ protected function indexItems() {
+ search_api_index_items(search_api_index_load($this->index_id));
+ }
+
+ protected function searchSuccess1() {
+ $results = $this->buildSearch('test')->range(1, 2)->execute();
+ $this->assertEqual($results['result count'], 4, 'Search for »test« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(4, 1), 'Search for »test« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('test foo')->execute();
+ $this->assertEqual($results['result count'], 3, 'Search for »test foo« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2, 4, 1), 'Search for »test foo« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('foo', array('type,item'))->sort('id', 'ASC')->execute();
+ $this->assertEqual($results['result count'], 2, 'Search for »foo« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2), 'Search for »foo« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'AND',
+ 'test',
+ array(
+ '#conjunction' => 'OR',
+ 'baz',
+ 'foobar',
+ ),
+ array(
+ '#conjunction' => 'OR',
+ '#negation' => TRUE,
+ 'bar',
+ 'fooblob',
+ ),
+ );
+ $results = $this->buildSearch($keys)->execute();
+ $this->assertEqual($results['result count'], 1, 'Complex search 1 returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(4), 'Complex search 1 returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch()->sort('id');
+ $filter = $query->createFilter('OR');
+ $filter->condition('title', 'bar');
+ $filter->condition('body', 'bar');
+ $query->filter($filter);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'Search with multi-field fulltext filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 5), 'Search with multi-field fulltext filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+ }
+
+ protected function checkFacets() {
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR', array('facet:type'));
+ $filter->condition('type', 'article');
+ $query->filter($filter);
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => TRUE,
+ 'operator' => 'or',
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 2, 'OR facets query returned correct number of results.');
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ array('count' => 1, 'filter' => '!'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct OR facets were returned');
+
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR', array('facet:type'));
+ $filter->condition('type', 'article');
+ $query->filter($filter);
+ $filter = $query->createFilter('AND');
+ $filter->condition('type', NULL, '<>');
+ $query->filter($filter);
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => TRUE,
+ 'operator' => 'or',
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 2, 'OR facets query returned correct number of results.');
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct OR facets were returned');
+
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR', array('facet:type'));
+ $filter->condition('type', 'article');
+ $query->filter($filter);
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 2,
+ 'missing' => TRUE,
+ 'operator' => 'or',
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct OR facets were returned with min_count');
+
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR', array('facet:type'));
+ $filter->condition('type', 'article');
+ $query->filter($filter);
+ $filter = $query->createFilter('AND');
+ $filter->condition('type', NULL, '<>');
+ $query->filter($filter);
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 2,
+ 'missing' => TRUE,
+ 'operator' => 'or',
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct OR facets were returned with min_count');
+ }
+
+ protected function editServer() {
+ $server = search_api_server_load($this->server_id, TRUE);
+ $server->options['min_chars'] = 4;
+ $server->options['partial_matches'] = FALSE;
+ $success = (bool) $server->save();
+ $this->assertTrue($success, 'The server was successfully edited.');
+
+ $this->clearIndex();
+ $this->indexItems();
+
+ // Reset the internal cache so the new values will be available.
+ search_api_index_load($this->index_id, TRUE);
+ }
+
+ protected function searchSuccess2() {
+ $results = $this->buildSearch('test')->range(1, 2)->execute();
+ $this->assertEqual($results['result count'], 4, 'Search for »test« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(4, 1), 'Search for »test« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch()->sort('id');
+ $filter = $query->createFilter('OR');
+ $filter->condition('title', 'test');
+ $filter->condition('body', 'test');
+ $query->filter($filter);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'Search with multi-field fulltext filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4), 'Search with multi-field fulltext filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch(NULL, array('body,test foobar'))->execute();
+ $this->assertEqual($results['result count'], 1, 'Search with multi-term fulltext filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'Search with multi-term fulltext filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('test foo')->execute();
+ $this->assertEqual($results['result count'], 4, 'Search for »test foo« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2, 4, 1, 3), 'Search for »test foo« returned correct result.');
+ $this->assertEqual($results['ignored'], array('foo'), 'Short key was ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('foo', array('type,item'))->execute();
+ $this->assertEqual($results['result count'], 2, 'Search for »foo« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2), 'Search for »foo« returned correct result.');
+ $this->assertEqual($results['ignored'], array('foo'), 'Short key was ignored.');
+ $this->assertEqual($results['warnings'], array(t('No valid search keys were present in the query.')), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'AND',
+ 'test',
+ array(
+ '#conjunction' => 'OR',
+ 'baz',
+ 'foobar',
+ ),
+ array(
+ '#conjunction' => 'OR',
+ '#negation' => TRUE,
+ 'bar',
+ 'fooblob',
+ ),
+ );
+ $results = $this->buildSearch($keys)->execute();
+ $this->assertEqual($results['result count'], 1, 'Complex search 1 returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'Complex search 1 returned correct result.');
+ $this->assertEqual($results['ignored'], array('baz', 'bar'), 'Correct keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'AND',
+ 'test',
+ array(
+ '#conjunction' => 'OR',
+ 'baz',
+ 'foobar',
+ ),
+ array(
+ '#conjunction' => 'OR',
+ '#negation' => TRUE,
+ 'bar',
+ 'fooblob',
+ ),
+ );
+ $results = $this->buildSearch($keys)->execute();
+ $this->assertEqual($results['result count'], 1, 'Complex search 2 returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'Complex search 2 returned correct result.');
+ $this->assertEqual($results['ignored'], array('baz', 'bar'), 'Correct keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch(NULL, array('keywords,orange'))->execute();
+ $this->assertEqual($results['result count'], 3, 'Filter query 1 on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 5), 'Filter query 1 on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'Warning displayed.');
+
+ $results = $this->buildSearch()->condition('keywords', 'orange', '<>')->execute();
+ $this->assertEqual($results['result count'], 2, 'Negated filter on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4), 'Negated filter on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'Warning displayed.');
+
+ $filters = array(
+ 'keywords,orange',
+ 'keywords,apple',
+ );
+ $results = $this->buildSearch(NULL, $filters)->execute();
+ $this->assertEqual($results['result count'], 1, 'Filter query 2 on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2), 'Filter query 2 on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch()->condition('keywords', NULL)->execute();
+ $this->assertEqual($results['result count'], 1, 'Query with NULL filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'Query with NULL filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch()->condition('keywords', NULL, '<>')->execute();
+ $this->assertEqual($results['result count'], 4, 'Query with NOT NULL filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'Query with NOT NULL filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+ }
+
+ /**
+ * Edits the server to enable partial matches.
+ *
+ * @param bool $enable
+ * (optional) Whether partial matching should be enabled or disabled.
+ */
+ protected function editServerPartial($enable = TRUE) {
+ $server = search_api_server_load($this->server_id, TRUE);
+ $server->options['partial_matches'] = $enable;
+ $success = (bool) $server->save();
+ $this->assertTrue($success, 'The server was successfully edited.');
+
+ // Reset the internal cache so the index won't use the old server.
+ search_api_index_load($this->index_id, TRUE);
+ }
+
+ /**
+ * Tests whether partial searches work.
+ */
+ protected function searchSuccessPartial() {
+ $results = $this->buildSearch('foobaz')->range(0, 1)->execute();
+ $this->assertEqual($results['result count'], 1, 'Partial search for »foobaz« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1), 'Partial search for »foobaz« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('foo')->sort('search_api_relevance', 'DESC')->sort('id', 'ASC')->execute();
+ $this->assertEqual($results['result count'], 5, 'Partial search for »foo« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 3, 5), 'Partial search for »foo« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch('foo', array('type,item'))->sort('id', 'DESC')->execute();
+ $this->assertEqual($results['result count'], 2, 'Partial search for »foo« of type »item« returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2, 1), 'Partial search for »foo« of type »item« returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch()->sort('id');
+ $filter = $query->createFilter('OR');
+ $filter->condition('title', 'test');
+ $filter->condition('body', 'test');
+ $query->filter($filter);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'Partial search with multi-field fulltext filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4), 'Partial search with multi-field fulltext filter returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+ }
+
+ /**
+ * Executes regression tests for issues that were already fixed.
+ */
+ protected function regressionTests() {
+ // Regression tests for #2007872.
+ $results = $this->buildSearch('test')->sort('id', 'ASC')->sort('type', 'ASC')->execute();
+ $this->assertEqual($results['result count'], 4, 'Sorting on field with NULLs returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4), 'Sorting on field with NULLs returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR');
+ $filter->condition('id', 3);
+ $filter->condition('type', 'article');
+ $query->filter($filter);
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 3, 'OR filter on field with NULLs returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4, 5), 'OR filter on field with NULLs returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #1863672.
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR');
+ $filter->condition('keywords', 'orange');
+ $filter->condition('keywords', 'apple');
+ $query->filter($filter);
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'OR filter on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'OR filter on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch();
+ $filter = $query->createFilter('OR');
+ $filter->condition('keywords', 'orange');
+ $filter->condition('keywords', 'strawberry');
+ $query->filter($filter);
+ $filter = $query->createFilter('OR');
+ $filter->condition('keywords', 'apple');
+ $filter->condition('keywords', 'grape');
+ $query->filter($filter);
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 3, 'Multiple OR filters on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2, 4, 5), 'Multiple OR filters on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch();
+ $filter1 = $query->createFilter('OR');
+ $filter = $query->createFilter('AND');
+ $filter->condition('keywords', 'orange');
+ $filter->condition('keywords', 'apple');
+ $filter1->filter($filter);
+ $filter = $query->createFilter('AND');
+ $filter->condition('keywords', 'strawberry');
+ $filter->condition('keywords', 'grape');
+ $filter1->filter($filter);
+ $query->filter($filter1);
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 3, 'Complex nested filters on multi-valued field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(2, 4, 5), 'Complex nested filters on multi-valued field returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #2040543.
+ $query = $this->buildSearch();
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => TRUE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ array('count' => 1, 'filter' => '!'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct facets were returned');
+
+ $query = $this->buildSearch();
+ $facets['type']['missing'] = FALSE;
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 2, 'filter' => '"item"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct facets were returned');
+
+ // Regression tests for #2111753.
+ $keys = array(
+ '#conjunction' => 'OR',
+ 'foo',
+ 'test',
+ );
+ $query = $this->buildSearch($keys, array(), array('title'));
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 3, 'OR keywords returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4), 'OR keywords returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch($keys, array(), array('title', 'body'));
+ $query->range(0, 0);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 5, 'Multi-field OR keywords returned correct number of results.');
+ $this->assertTrue(empty($results['results']), 'Multi-field OR keywords returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'OR',
+ 'foo',
+ 'test',
+ array(
+ '#conjunction' => 'AND',
+ 'bar',
+ 'baz',
+ ),
+ );
+ $query = $this->buildSearch($keys, array(), array('title'));
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'Nested OR keywords returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'Nested OR keywords returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'OR',
+ array(
+ '#conjunction' => 'AND',
+ 'foo',
+ 'test',
+ ),
+ array(
+ '#conjunction' => 'AND',
+ 'bar',
+ 'baz',
+ ),
+ );
+ $query = $this->buildSearch($keys, array(), array('title', 'body'));
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'Nested multi-field OR keywords returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'Nested multi-field OR keywords returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #2127001.
+ $keys = array(
+ '#conjunction' => 'AND',
+ '#negation' => TRUE,
+ 'foo',
+ 'bar',
+ );
+ $results = $this->buildSearch($keys)->sort('search_api_id', 'ASC')->execute();
+ $this->assertEqual($results['result count'], 2, 'Negated AND fulltext search returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4), 'Negated AND fulltext search returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'OR',
+ '#negation' => TRUE,
+ 'foo',
+ 'baz',
+ );
+ $results = $this->buildSearch($keys)->execute();
+ $this->assertEqual($results['result count'], 1, 'Negated OR fulltext search returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'Negated OR fulltext search returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $keys = array(
+ '#conjunction' => 'AND',
+ 'test',
+ array(
+ '#conjunction' => 'AND',
+ '#negation' => TRUE,
+ 'foo',
+ 'bar',
+ ),
+ );
+ $results = $this->buildSearch($keys)->sort('search_api_id', 'ASC')->execute();
+ $this->assertEqual($results['result count'], 2, 'Nested NOT AND fulltext search returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4), 'Nested NOT AND fulltext search returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #2136409
+ $query = $this->buildSearch();
+ $query->condition('type', NULL);
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 1, 'NULL filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3), 'NULL filter returned correct result.');
+
+ $query = $this->buildSearch();
+ $query->condition('type', NULL, '<>');
+ $query->sort('id', 'ASC');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 4, 'NOT NULL filter returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'NOT NULL filter returned correct result.');
+
+ // Regression tests for #1658964.
+ $query = $this->buildSearch();
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 0,
+ 'missing' => TRUE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->condition('type', 'article');
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"article"'),
+ array('count' => 0, 'filter' => '!'),
+ array('count' => 0, 'filter' => '"item"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct facets were returned');
+
+ // Regression tests for #1403916.
+ $query = $this->buildSearch('test foo');
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => TRUE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 2, 'filter' => '"item"'),
+ array('count' => 1, 'filter' => '"article"'),
+ );
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct facets were returned');
+
+ // Regression tests for #2305107.
+ $results = $this->buildSearch('test')->execute();
+ $expected = array(
+ 2 => 6,
+ 4 => 3,
+ 1 => 2,
+ 3 => 1,
+ );
+ $scores = array();
+ foreach ($results['results'] as $item_id => $result) {
+ $scores[$item_id] = $result['score'];
+ }
+ $this->assertIdentical($scores, $expected, 'Correct scores were computed.');
+
+ $this->editServerPartial();
+ $results = $this->buildSearch('test')->execute();
+ $this->editServerPartial(FALSE);
+ $scores = array();
+ foreach ($results['results'] as $item_id => $result) {
+ $scores[$item_id] = $result['score'];
+ }
+ $this->assertIdentical($scores, $expected, 'Correct scores were computed with partial matching.');
+
+ $results = $this->buildSearch('test baz')->execute();
+ $expected = array(
+ 4 => 8,
+ 1 => 7,
+ );
+ $scores = array();
+ foreach ($results['results'] as $item_id => $result) {
+ $scores[$item_id] = $result['score'];
+ }
+ $this->assertIdentical($scores, $expected, 'Correct scores were computed for two keywords.');
+
+ $this->editServerPartial();
+ $results = $this->buildSearch('test baz')->execute();
+ $expected = array(
+ 1 => 12,
+ 4 => 8,
+ );
+ $scores = array();
+ foreach ($results['results'] as $item_id => $result) {
+ $scores[$item_id] = $result['score'];
+ }
+ $this->assertIdentical($scores, $expected, 'Correct scores were computed for two keywords with partial matching.');
+
+ $results = $this->buildSearch('nonexistent baz')->execute();
+ $this->assertEqual($results['result count'], 0, 'No incorrect results returned with partial matching.');
+
+ $query = $this->buildSearch('test');
+ $facets['type'] = array(
+ 'field' => 'type',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => TRUE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $this->editServerPartial(FALSE);
+ $expected = array(
+ array('count' => 2, 'filter' => '"item"'),
+ array('count' => 1, 'filter' => '!'),
+ array('count' => 1, 'filter' => '"article"'),
+ );
+ usort($results['search_api_facets']['type'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['type'], $expected, 'Correct facets were returned with partial matching.');
+
+ // Regression tests for #2469547.
+ $query = $this->buildSearch();
+ $query->condition('id', 5, '<>');
+ $facets['body'] = array(
+ 'field' => 'body',
+ 'limit' => 0,
+ 'min_count' => 1,
+ 'missing' => FALSE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 4, 'filter' => '"test"'),
+ array('count' => 1, 'filter' => '"bar"'),
+ array('count' => 1, 'filter' => '"foobar"'),
+ );
+ usort($results['search_api_facets']['body'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['body'], $expected, 'Correct facets were returned for a fulltext field.');
+
+ // Regression tests for #2511860.
+ $query = $this->buildSearch();
+ $query->condition('body', 'ab xy');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 5, 'Fulltext filters on short words do not change the result.');
+
+ $query = $this->buildSearch();
+ $query->condition('body', 'ab ab');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 5, 'Fulltext filters on duplicate short words do not change the result.');
+
+ // Regression test for #2632426.
+ $query = $this->buildSearch();
+ $query->condition('type', 'unknown_type');
+ $query->setOption('skip result count', TRUE);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], FALSE, 'Search for unknown type returned correct result count.');
+ $this->assertEqual($results['results'], array(), 'Search for unknown type returned an empty result set.');
+
+ // Regression tests for #2566329.
+ $query = $this->buildSearch();
+ $query->condition('id', 5, '<>');
+ $facets['body'] = array(
+ 'field' => 'body',
+ 'limit' => 0,
+ 'min_count' => 0,
+ 'missing' => FALSE,
+ );
+ $query->setOption('search_api_facets', $facets);
+ $query->range(0, 0);
+ $results = $query->execute();
+ $expected = array(
+ array('count' => 4, 'filter' => '"test"'),
+ array('count' => 1, 'filter' => '"bar"'),
+ array('count' => 1, 'filter' => '"foobar"'),
+ array('count' => 0, 'filter' => '"foo"'),
+ );
+ usort($results['search_api_facets']['body'], array($this, 'facetCompare'));
+ $this->assertEqual($results['search_api_facets']['body'], $expected, 'Correct facets were returned for a fulltext field with minimum count 0.');
+ }
+
+ /**
+ * Compares two facets for ordering.
+ *
+ * Used as a callback for usort() in checkFacets() and regressionTests().
+ */
+ public function facetCompare($a, $b) {
+ if ($a['count'] != $b['count']) {
+ return $b['count'] - $a['count'];
+ }
+ return strcasecmp($a['filter'], $b['filter']);
+ }
+
+ protected function clearIndex() {
+ $success = search_api_index_load($this->index_id)->clear();
+ $this->assertTrue($success, 'The index was successfully cleared.');
+ }
+
+ /**
+ * Executes regression tests which are unpractical to run in between.
+ */
+ protected function regressionTests2() {
+ // Regression test for #1916474.
+ $index = search_api_index_load($this->index_id, TRUE);
+ $index->options['fields']['prices']['type'] = 'list';
+ $success = $index->save();
+ $this->assertTrue($success, 'The index field settings were successfully changed.');
+
+ // Reset the internal cache so the new values will be available.
+ search_api_server_load($this->server_id, TRUE);
+ search_api_index_load($this->index_id, TRUE);
+
+ $this->indexItems();
+
+ $this->backdropGet('search_api_test/insert');
+ $mb_string = 'äöüßáŧæøðđŋħĸµäöüßáŧæøðđŋħĸµ';
+ $this->insertItem(array(
+ 'id' => 6,
+ 'body' => $mb_string,
+ 'prices' => '3.5,3.25,3.75,3.5',
+ ));
+
+ $query = $this->buildSearch(NULL, array('prices,3.25'));
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 1, 'Filter on decimal field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(6), 'Filter on decimal field returned correct result.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch(NULL, array('prices,3.5'));
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 1, 'Filter on decimal field returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(6), 'Filter on decimal field returned correct result.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression test for #2616804.
+ // The word has 28 Unicode characters but 56 bytes. Verify that it was still
+ // indexed correctly.
+ $query = $this->buildSearch($mb_string);
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 1, 'Search for word with 28 multi-byte characters returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(6), 'Search for word with 28 multi-byte characters returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $query = $this->buildSearch($mb_string . 'ä');
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 0, 'Search for unknown word with 29 multi-byte characters returned no results.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #2745655.
+ $results = $this->buildSearch()
+ ->condition('title', NULL)
+ ->execute();
+ // "Minimum chars" is 3 at this point, so all items with no longer words in
+ // their titles will be returned, too.
+ $this->assertEqual($results['result count'], 4, 'Search for items without title returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4, 5, 6), 'Search for items without title returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch()
+ ->condition('title', NULL, '<>')
+ ->execute();
+ $this->assertEqual($results['result count'], 2, 'Search for items with title returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2), 'Search for items with title returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ // Regression tests for #2795245.
+ // Make sure changing a field's type from something else to "text" works
+ // correctly.
+ $index->options['fields']['type']['type'] = 'text';
+ $index->save();
+ search_api_index_items($index);
+
+ $results = $this->buildSearch()->condition('type', NULL)->execute();
+ $this->assertEqual($results['result count'], 2, 'Search for items without type returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(3, 6), 'Search for items without type returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+
+ $results = $this->buildSearch()->condition('type', NULL, '<>')->execute();
+ $this->assertEqual($results['result count'], 4, 'Search for items with type returned correct number of results.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 4, 5), 'Search for items with type returned correct result.');
+ $this->assertEqual($results['ignored'], array(), 'No keys were ignored.');
+ $this->assertEqual($results['warnings'], array(), 'No warnings were displayed.');
+ }
+
+ /**
+ * Tests whether removing the configuration again works as it should.
+ */
+ protected function uninstallModule() {
+ // See whether clearing the server works.
+ // Regression test for #2156151.
+ $server = search_api_server_load($this->server_id, TRUE);
+ $server->deleteItems();
+ $query = $this->buildSearch();
+ $results = $query->execute();
+ $this->assertEqual($results['result count'], 0, 'Clearing the server worked correctly.');
+ $table = 'search_api_db_' . $this->index_id;
+ $this->assertTrue(db_table_exists($table), 'The index tables were left in place.');
+
+ // Remove first the index and then the server.
+ $index = search_api_index_load($this->index_id, TRUE);
+ $index->update(array('server' => NULL));
+ $server = search_api_server_load($this->server_id, TRUE);
+ $this->assertEqual($server->options['indexes'], array(), 'The index was successfully removed from the server.');
+ $this->assertFalse(db_table_exists($table), 'The index tables were deleted.');
+ $server->delete();
+
+ // Uninstall the module.
+ module_disable(array('search_api_db'), FALSE);
+ $this->assertFalse(module_exists('search_api_db'), 'The Database Search module was successfully disabled.');
+ backdrop_uninstall_modules(array('search_api_db'), FALSE);
+ $prefix = Database::getConnection()->prefixTables('{search_api_db_}') . '%';
+ $this->assertEqual(db_find_tables($prefix), array(), 'The Database Search module was successfully uninstalled.');
+ }
+
+}
diff --git a/www/modules/contrib/search_api_db/search_api_db.tests.info b/www/modules/contrib/search_api_db/search_api_db.tests.info
new file mode 100644
index 000000000..815d7f5ac
--- /dev/null
+++ b/www/modules/contrib/search_api_db/search_api_db.tests.info
@@ -0,0 +1,11 @@
+[SearchApiDbTest]
+name = Test "Database search" module
+description = Tests indexing and searching with the "Database search" module.
+group = Search API Database Search
+file = search_api_db.test
+
+
+; Added by Backdrop CMS packaging script on 2026-02-06
+project = search_api_db
+version = 1.x-1.9.2
+timestamp = 1770378646
diff --git a/www/modules/contrib/search_api_db/service.inc b/www/modules/contrib/search_api_db/service.inc
new file mode 100644
index 000000000..b0c8fa1d6
--- /dev/null
+++ b/www/modules/contrib/search_api_db/service.inc
@@ -0,0 +1,2336 @@
+options['database'])) {
+ list($key, $target) = explode(':', $this->options['database'], 2);
+ $this->connection = Database::getConnection($target, $key);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ // Discern between creation and editing of a server, since we don't allow
+ // the database to be changed later on.
+ if (empty($this->options)) {
+ global $databases;
+ foreach ($databases as $key => $targets) {
+ foreach ($targets as $target => $info) {
+ $options[$key]["$key:$target"] = "$key > $target";
+ }
+ }
+ if (count($options) > 1 || count(reset($options)) > 1) {
+ $form['database'] = array(
+ '#type' => 'select',
+ '#title' => t('Database'),
+ '#description' => t('Select the database key and target to use for storing indexing information in. ' .
+ 'Cannot be changed after creation.'),
+ '#options' => $options,
+ '#default_value' => 'default:default',
+ '#required' => TRUE,
+ );
+ }
+ else {
+ $form['database'] = array(
+ '#type' => 'value',
+ '#value' => "$key:$target",
+ );
+ }
+ }
+ else {
+ $form = array(
+ 'database' => array(
+ '#type' => 'value',
+ '#value' => $this->options['database'],
+ ),
+ 'database_text' => array(
+ '#type' => 'item',
+ '#title' => t('Database'),
+ '#markup' => check_plain(str_replace(':', ' > ', $this->options['database'])),
+ ),
+ );
+ }
+
+ // Set default settings.
+ $options = $this->options + array(
+ 'min_chars' => 1,
+ 'autocomplete' => array(),
+ 'partial_matches' => FALSE,
+ );
+ $options['autocomplete'] += array(
+ 'suggest_suffix' => TRUE,
+ 'suggest_words' => TRUE,
+ );
+
+ $form['min_chars'] = array(
+ '#type' => 'select',
+ '#title' => t('Minimum word length'),
+ '#description' => t('The minimum number of characters a word must consist of to be indexed.'),
+ '#options' => backdrop_map_assoc(array(1, 2, 3, 4, 5, 6)),
+ '#default_value' => $options['min_chars'],
+ );
+
+ $form['partial_matches'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Search on parts of a word'),
+ '#description' => t('Find keywords in parts of a word, too. (E.g., find results with "database" when searching for "base"). Caution: This can make searches much slower on large sites!'),
+ '#default_value' => $options['partial_matches'],
+ );
+
+ if (module_exists('search_api_autocomplete')) {
+ $form['autocomplete'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Autocomplete settings'),
+ '#description' => t('These settings allow you to configure how suggestions are computed when autocompletion is used. If you are seeing many inappropriate suggestions you might want to deactivate the corresponding suggestion type. You can also deactivate one method to speed up the generation of suggestions.'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ );
+ $form['autocomplete']['suggest_suffix'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Suggest word endings'),
+ '#description' => t('Suggest endings for the currently entered word.'),
+ '#default_value' => $options['autocomplete']['suggest_suffix'],
+ );
+ $form['autocomplete']['suggest_words'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Suggest additional words'),
+ '#description' => t('Suggest additional words the user might want to search for.'),
+ '#default_value' => $options['autocomplete']['suggest_words'],
+ );
+ }
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsFeature($feature) {
+ $supported = array(
+ 'search_api_autocomplete' => TRUE,
+ 'search_api_between' => TRUE,
+ 'search_api_facets' => TRUE,
+ 'search_api_facets_operator_or' => TRUE,
+ 'search_api_random_sort' => TRUE,
+ 'search_api_service_extra' => TRUE,
+ );
+ return isset($supported[$feature]);
+ }
+
+ /**
+ * Overrides SearchApiAbstractService::viewSettings().
+ *
+ * Returns an empty string since information is instead added via
+ * getExtraInformation().
+ */
+ public function viewSettings() {
+ return '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getExtraInformation() {
+ // Set default settings.
+ $options = $this->options + array(
+ 'min_chars' => 1,
+ 'partial_matches' => FALSE,
+ );
+
+ $info = array();
+
+ $info[] = array(
+ 'label' => t('Database'),
+ 'info' => check_plain(str_replace(':', ' > ', $options['database'])),
+ );
+ if ($options['min_chars'] > 1) {
+ $info[] = array(
+ 'label' => t('Minimum word length'),
+ 'info' => $options['min_chars'],
+ );
+ }
+ $info[] = array(
+ 'label' => t('Search on parts of a word'),
+ 'info' => $options['partial_matches'] ? t('enabled') : t('disabled'),
+ );
+ if (!empty($options['autocomplete'])) {
+ $options['autocomplete'] += array(
+ 'suggest_suffix' => TRUE,
+ 'suggest_words' => TRUE,
+ );
+ $autocomplete_modes = array();
+ if ($options['autocomplete']['suggest_suffix']) {
+ $autocomplete_modes[] = t('Suggest word endings');
+ }
+ if ($options['autocomplete']['suggest_words']) {
+ $autocomplete_modes[] = t('Suggest additional words');
+ }
+ $autocomplete_modes = $autocomplete_modes ? implode('; ', $autocomplete_modes) : t('none');
+ $info[] = array(
+ 'label' => t('Autocomplete suggestions'),
+ 'info' => $autocomplete_modes,
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postUpdate() {
+ return !empty($this->server->original) && $this->server->options != $this->server->original->options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preDelete() {
+ // Only react on real deletes, not on reverts.
+ if ($this->server->hasStatus(ENTITY_PLUS_IN_CODE)) {
+ return;
+ }
+ if (empty($this->options['indexes'])) {
+ return;
+ }
+ foreach ($this->options['indexes'] as $index) {
+ foreach ($index as $field) {
+ // Some fields share a de-normalized table, brute force since
+ // everything is going.
+ if ($this->connection->schema()->tableExists($field['table'])) {
+ $this->connection->schema()->dropTable($field['table']);
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addIndex(SearchApiIndex $index) {
+ try {
+ // If there are no fields, we can take a shortcut.
+ if (!isset($index->options['fields'])) {
+ if (!isset($this->options['indexes'][$index->machine_name])) {
+ $this->options['indexes'][$index->machine_name] = array();
+ $this->server->save();
+ }
+ elseif ($this->options['indexes'][$index->machine_name]) {
+ $this->removeIndex($index);
+ $this->options['indexes'][$index->machine_name] = array();
+ $this->server->save();
+ }
+ return;
+ }
+ $this->options += array('indexes' => array());
+ $this->options['indexes'] += array($index->machine_name => array());
+ }
+ // The database operations might throw PDO or other exceptions, so we catch
+ // them all and re-wrap them appropriately.
+ catch (Exception $e) {
+ throw new SearchApiException($e->getMessage());
+ }
+
+ // If dealing with features or stale data or whatever, we might already have
+ // settings stored for this index. If we have, we should take care to only
+ // change what is needed, so we don't save the server (potentially setting
+ // it to "Overridden") unnecessarily.
+ // The easiest way to do this is by just pretending the index was already
+ // present, but its fields were updated.
+ $this->fieldsUpdated($index);
+ }
+
+ /**
+ * Finds a free table name using a certain prefix and name base.
+ *
+ * Used as a helper method in fieldsUpdated().
+ *
+ * MySQL 5.0 imposes a 64 characters length limit for table names, PostgreSQL
+ * 8.3 only allows 62 bytes. Therefore, always return a name at most 62
+ * bytes long.
+ *
+ * @param string $prefix
+ * Prefix to start the table name.
+ * @param string $name
+ * Name to base the table name on.
+ *
+ * @return string
+ * A database table name that isn't in use yet.
+ */
+ protected function findFreeTable($prefix, $name) {
+ // A DB prefix might further reduce the maximum length of the table name.
+ $maxbytes = 62;
+ if ($db_prefix = $this->connection->tablePrefix()) {
+ // Use strlen instead of backdrop_strlen since we want to measure bytes
+ // instead of characters.
+ $maxbytes -= strlen($db_prefix);
+ }
+
+ $base = $table = self::mbStrcut($prefix . backdrop_strtolower(preg_replace('/[^a-z0-9]/i', '_', $name)), 0, $maxbytes);
+ $i = 0;
+ while ($this->connection->schema()->tableExists($table)) {
+ $suffix = '_' . ++$i;
+ $table = self::mbStrcut($base, 0, $maxbytes - strlen($suffix)) . $suffix;
+ }
+ return $table;
+ }
+
+ /**
+ * Finds a free column name within a database table.
+ *
+ * Used as a helper method in fieldsUpdated().
+ *
+ * MySQL 5.0 imposes a 64 characters length limit for identifier names,
+ * PostgreSQL 8.3 only allows 62 bytes. Therefore, always return a name at
+ * most 62 bytes long.
+ *
+ * @param string $table
+ * Name of the table.
+ * @param string $column
+ * If adding a column to $name, the name to base the column name on.
+ *
+ * @return string
+ * A column name that isn't in use in the specified table yet.
+ */
+ protected function findFreeColumn($table, $column) {
+ $maxbytes = 62;
+
+ $base = $name = self::mbStrcut(backdrop_strtolower(preg_replace('/[^a-z0-9]/i', '_', $column)), 0, $maxbytes);
+ // If the table does not exist yet, the initial name is not taken.
+ if ($this->connection->schema()->tableExists($table)) {
+ $i = 0;
+ while ($this->connection->schema()->fieldExists($table, $name)) {
+ $suffix = '_' . ++$i;
+ $name = self::mbStrcut($base, 0, $maxbytes - strlen($suffix)) . $suffix;
+ }
+ }
+ return $name;
+ }
+
+ /**
+ * Creates or modifies a table to add an indexed field.
+ *
+ * Used as a helper method in fieldsUpdated().
+ *
+ * @param SearchApiIndex $index
+ * Search API index for this field.
+ * @param array $field
+ * Single field definition from SearchApiIndex::getFields().
+ * @param array $db
+ * Associative array containing the following:
+ * - table: The table to use for the field.
+ * - column: (optional) The column to use in that table. Defaults to
+ * "value".
+ */
+ protected function createFieldTable(SearchApiIndex $index, $field, &$db) {
+ $type = search_api_extract_inner_type($field['type']);
+ $new_table = !$this->connection->schema()->tableExists($db['table']);
+ if ($new_table) {
+ $table = array(
+ 'name' => $db['table'],
+ 'module' => 'search_api_db',
+ 'fields' => array(
+ 'item_id' => array(
+ 'description' => 'The primary identifier of the item.',
+ 'not null' => TRUE,
+ ),
+ ),
+ );
+ // The type of the item_id field depends on the ID field's type.
+ $id_field = $index->datasource()->getIdFieldInfo();
+ $table['fields']['item_id'] += $this->sqlType($id_field['type'] == 'text' ? 'string' : $id_field['type']);
+ if (isset($table['fields']['item_id']['length'])) {
+ // A length of 255 is overkill for IDs. 50 should be more than enough.
+ $table['fields']['item_id']['length'] = 50;
+ }
+
+ $this->connection->schema()->createTable($db['table'], $table);
+
+ // Some DBMSs will need a character encoding and collation set.
+ switch ($this->connection->databaseType()) {
+ case 'mysql':
+ $this->connection->query("ALTER TABLE {{$db['table']}} CONVERT TO CHARACTER SET 'utf8' COLLATE 'utf8_bin'");
+ break;
+
+ // @todo Add fixes for other DBMSs.
+ case 'oracle':
+ case 'pgsql':
+ case 'sqlite':
+ case 'sqlsrv':
+ break;
+ }
+ }
+
+ if (!isset($db['column'])) {
+ $db['column'] = 'value';
+ }
+ $db_field = $this->sqlType($type);
+ $db_field += array(
+ 'description' => "The field's value for this item.",
+ );
+ if ($new_table && search_api_is_list_type($field['type'])) {
+ $db_field['not null'] = TRUE;
+ }
+ $this->connection->schema()->addField($db['table'], $db['column'], $db_field);
+ try {
+ if ($db_field['type'] === 'varchar') {
+ $this->connection->schema()->addIndex($db['table'], $db['column'], array(array($db['column'], 10)));
+ }
+ else {
+ $this->connection->schema()->addIndex($db['table'], $db['column'], array($db['column']));
+ }
+ }
+ catch (PDOException $e) {
+ $variables['%field'] = $field['name'];
+ $variables['%index'] = $index->name;
+ watchdog_exception('search_api_db', $e, '%type while trying to add DBMS index for the column of field %field on index %index: !message in %function (line %line of %file).', $variables, WATCHDOG_WARNING);
+ }
+ if ($new_table) {
+ if (search_api_is_list_type($field['type'])) {
+ // Add a covering index for lists.
+ $this->connection->schema()->addPrimaryKey($db['table'], array('item_id', $db['column']));
+ }
+ else {
+ // Otherwise, a denormalized table with many columns, where we can't
+ // predict the best covering index.
+ $this->connection->schema()->addPrimaryKey($db['table'], array('item_id'));
+ }
+ }
+ }
+
+ /**
+ * Returns the schema definition for a database column for a search data type.
+ *
+ * @param string $type
+ * An indexed field's search type. One of the keys from
+ * search_api_default_field_types().
+ *
+ * @return array
+ * Column configurations to use for the field's database column.
+ *
+ * @throws SearchApiException
+ * If $type is unknown.
+ */
+ protected function sqlType($type) {
+ $type = search_api_extract_inner_type($type);
+ switch ($type) {
+ case 'string':
+ case 'uri':
+ return array('type' => 'varchar', 'length' => 255);
+ case 'integer':
+ case 'duration':
+ case 'date':
+ // 'datetime' sucks. Therefore, we just store the timestamp.
+ return array('type' => 'int', 'size' => 'big');
+ case 'decimal':
+ return array('type' => 'float');
+ case 'boolean':
+ return array('type' => 'int', 'size' => 'tiny');
+
+ default:
+ throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
+ }
+ }
+
+ /**
+ * Overrides SearchApiAbstractService::fieldsUpdated().
+ *
+ * Internally, this is also used by addIndex().
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ try {
+ $fields = &$this->options['indexes'][$index->machine_name];
+ $new_fields = $index->getFields();
+
+ $reindex = FALSE;
+ $cleared = FALSE;
+ $change = FALSE;
+ $text_table = NULL;
+ $missing_text_tables = array();
+
+ foreach ($fields as $name => $field) {
+ if (!isset($text_table) && search_api_is_text_type($field['type'])) {
+ // Stash the shared text table name for the index, if it exists.
+ // Otherwise, there was some error previously and we have to remember
+ // to later come back and set the correct table here.
+ if ($this->connection->schema()->tableExists($field['table'])) {
+ $text_table = $field['table'];
+ }
+ else {
+ $missing_text_tables[$name] = $name;
+ }
+ }
+
+ if (!isset($new_fields[$name])) {
+ // The field is no longer in the index, drop the data.
+ $this->removeFieldStorage($name, $field);
+ unset($fields[$name]);
+ $change = TRUE;
+ continue;
+ }
+ $old_type = $field['type'];
+ $new_type = $new_fields[$name]['type'];
+ $fields[$name]['type'] = $new_type;
+ $fields[$name]['boost'] = $new_fields[$name]['boost'];
+ $old_inner_type = search_api_extract_inner_type($old_type);
+ $new_inner_type = search_api_extract_inner_type($new_type);
+ if ($old_type != $new_type) {
+ $change = TRUE;
+ $list_old = (bool) search_api_list_nesting_level($old_type);
+ $list_new = (bool) search_api_list_nesting_level($new_type);
+ if ($old_inner_type == 'text' || $new_inner_type == 'text' || $list_old != $list_new) {
+ // A change in fulltext or list status necessitates completely
+ // clearing the index.
+ $reindex = TRUE;
+ if (!$cleared) {
+ $cleared = TRUE;
+ $this->deleteItems('all', $index);
+ }
+ $this->removeFieldStorage($name, $field);
+ // Keep the table in $new_fields to create the new storage.
+ continue;
+ }
+ elseif ($this->sqlType($old_inner_type) != $this->sqlType($new_inner_type)) {
+ // There is a change in SQL type. We don't have to clear the index,
+ // since types can be converted.
+ $column = isset($field['column']) ? $field['column'] : 'value';
+ $this->connection->schema()->changeField($field['table'], $column, $column, $this->sqlType($new_type) + array('description' => "The field's value for this item."));
+ $reindex = TRUE;
+ }
+ elseif ($old_inner_type == 'date' || $new_inner_type == 'date') {
+ // Even though the SQL type stays the same, we have to reindex since
+ // conversion rules change.
+ $reindex = TRUE;
+ }
+ }
+ elseif ($text_table && $new_inner_type == 'text' && $field['boost'] != $new_fields[$name]['boost']) {
+ $change = TRUE;
+ if (!$reindex) {
+ // If there was a non-zero boost set previously, we can just update
+ // all scores with a single UPDATE query. Otherwise, no way around
+ // re-indexing.
+ if ($field['boost']) {
+ $multiplier = $new_fields[$name]['boost'] / $field['boost'];
+ $this->connection->update($text_table)
+ ->expression('score', 'score * :mult', array(':mult' => $multiplier))
+ ->condition('field_name', self::getTextFieldName($name))
+ ->execute();
+ }
+ else {
+ $reindex = TRUE;
+ }
+ }
+ }
+ // Make sure the table and column now exist. (Especially important when
+ // we actually add the index for the first time.)
+ if (!search_api_is_text_type($field['type'])) {
+ $storageExists = $this->connection->schema()->tableExists($field['table'])
+ && (!isset($field['column'])
+ || $this->connection->schema()->fieldExists($field['table'], $field['column']));
+ if (!$storageExists) {
+ $this->createFieldTable($index, $new_fields[$name], $field);
+ }
+ }
+ // People have reported that sometimes a text field has a different
+ // table set than the combined fulltext table, so we try to fix that
+ // here as well.
+ elseif ($text_table && $fields[$name]['table'] != $text_table) {
+ $fields[$name]['table'] = $text_table;
+ $change = TRUE;
+ }
+ unset($new_fields[$name]);
+ }
+
+ $prefix = 'search_api_db_' . $index->machine_name;
+ // These are new fields that were previously not indexed.
+ foreach ($new_fields as $name => $field) {
+ $reindex = TRUE;
+ if (search_api_is_text_type($field['type'])) {
+ if (!isset($text_table)) {
+ // If we have not encountered a text table, assign a name for it.
+ $text_table = $this->findFreeTable($prefix . '_', 'text');
+ }
+ $fields[$name] = array(
+ 'table' => $text_table,
+ );
+ }
+ else {
+ if ($this->canDenormalize($field)) {
+ $fields[$name] = array(
+ 'table' => $prefix,
+ 'column' => $this->findFreeColumn($prefix, $name),
+ );
+ }
+ else {
+ $fields[$name] = array(
+ 'table' => $this->findFreeTable($prefix . '_', $name),
+ );
+ }
+ $this->createFieldTable($index, $field, $fields[$name]);
+ }
+ $fields[$name]['type'] = $field['type'];
+ $fields[$name]['boost'] = $field['boost'];
+ $change = TRUE;
+ }
+
+ // If there were fulltext fields without valid table set, set it now.
+ if ($missing_text_tables) {
+ if (!isset($text_table)) {
+ // If we have not encountered a text table, assign a name for it.
+ $text_table = $this->findFreeTable($prefix . '_', 'text');
+ }
+ foreach ($missing_text_tables as $name) {
+ $fields[$name]['table'] = $text_table;
+ }
+ }
+
+ // If needed, make sure the text table exists.
+ if (isset($text_table) && !$this->connection->schema()->tableExists($text_table)) {
+ $table = array(
+ 'name' => $text_table,
+ 'module' => 'search_api_db',
+ 'fields' => array(
+ 'item_id' => array(
+ 'description' => 'The primary identifier of the item.',
+ 'not null' => TRUE,
+ ),
+ 'field_name' => array(
+ 'description' => "The name of the field in which the token appears, or a base-64 encoded sha-256 hash of the field.",
+ 'not null' => TRUE,
+ 'type' => 'varchar',
+ 'length' => 255,
+ ),
+ 'word' => array(
+ 'description' => 'The text of the indexed token.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'score' => array(
+ 'description' => 'The score associated with this token.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'word_field' => array(array('word', 20), 'field_name'),
+ ),
+ // Add a covering index since word is not repeated for each item.
+ 'primary key' => array('item_id', 'field_name', 'word'),
+ );
+ // The type of the item_id field depends on the ID field's type.
+ $id_field = $index->datasource()->getIdFieldInfo();
+ $table['fields']['item_id'] += $this->sqlType($id_field['type'] == 'text' ? 'string' : $id_field['type']);
+ if (isset($table['fields']['item_id']['length'])) {
+ // A length of 255 is overkill for IDs. 50 should be more than enough.
+ $table['fields']['item_id']['length'] = 50;
+ }
+ $this->connection->schema()->createTable($text_table, $table);
+
+ // Some DBMSs will need a character encoding and collation set. Since
+ // this largely circumvents Backdrop's database layer (but isn't integral
+ // enough to fail completely when it doesn't work), we wrap it in a
+ // try/catch, to be on the safe side.
+ try {
+ switch ($this->connection->databaseType()) {
+ case 'mysql':
+ $this->connection->query("ALTER TABLE {{$text_table}} CONVERT TO CHARACTER SET 'utf8' COLLATE 'utf8_bin'");
+ break;
+
+ case 'pgsql':
+ $this->connection->query("ALTER TABLE {{$text_table}} ALTER COLUMN word SET DATA TYPE character varying(50) COLLATE \"C\"");
+ break;
+
+ // @todo Add fixes for other DBMSs.
+ case 'oracle':
+ case 'sqlite':
+ case 'sqlsrv':
+ break;
+ }
+ }
+ catch (PDOException $e) {
+ $vars['%index'] = $index->name;
+ watchdog_exception('search_api_db', $e, '%type while trying to change collation for the fulltext table of index %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+
+ if ($change) {
+ $this->server->save();
+ }
+ return $reindex;
+ }
+ // The database operations might throw PDO or other exceptions, so we catch
+ // them all and re-wrap them appropriately.
+ catch (Exception $e) {
+ throw new SearchApiException($e->getMessage());
+ }
+ }
+
+ /**
+ * Checks if a field can be denormalized.
+ *
+ * List fields have multiple values, so cannot be denormalized. Text fields
+ * are tokenized into words, so cannot be denormalized either.
+ *
+ * @param array $field
+ * Single field definition from SearchApiIndex::getFields().
+ *
+ * @return bool
+ * TRUE if the field can be stored in a table with other fields (i.e., will
+ * only need a single row), FALSE otherwise.
+ */
+ protected function canDenormalize($field) {
+ return !search_api_is_list_type($field['type']) && !search_api_is_text_type($field['type']);
+ }
+
+ /**
+ * Drops a field's table or column for storage.
+ *
+ * @param string $name
+ * The field name.
+ * @param array $field
+ * Server-internal information about the field.
+ */
+ protected function removeFieldStorage($name, $field) {
+ // This might, in some instances, be called when the necessary table hasn't
+ // even been created yet.
+ if (!$this->connection->schema()->tableExists($field['table'])) {
+ return;
+ }
+ if (search_api_is_text_type($field['type'])) {
+ $this->connection->delete($field['table'])
+ ->condition('field_name', self::getTextFieldName($name))
+ ->execute();
+ }
+ // Legacy non-denormalized fields will not have a column.
+ elseif ($this->canDenormalize($field) && isset($field['column'])) {
+ $this->connection->schema()->dropField($field['table'], $field['column']);
+ }
+ elseif ($this->connection->schema()->tableExists($field['table'])) {
+ $this->connection->schema()->dropTable($field['table']);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeIndex($index) {
+ try {
+ $id = is_object($index) ? $index->machine_name : $index;
+ if (!isset($this->options['indexes'][$id])) {
+ return;
+ }
+ // Don't delete the index data of read-only indexes!
+ if (!is_object($index) || empty($index->read_only)) {
+ foreach ($this->options['indexes'][$id] as $field) {
+ // Some fields share a de-normalized table, brute force since
+ // everything is going.
+ if ($this->connection->schema()->tableExists($field['table'])) {
+ $this->connection->schema()->dropTable($field['table']);
+ }
+ }
+ }
+ unset($this->options['indexes'][$id]);
+ $this->server->save();
+ }
+ // The database operations might throw PDO or other exceptions, so we catch
+ // them all and re-wrap them appropriately.
+ catch (Exception $e) {
+ throw new SearchApiException($e->getMessage());
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function indexItems(SearchApiIndex $index, array $items) {
+ if (empty($this->options['indexes'][$index->machine_name])) {
+ throw new SearchApiException(t('No field settings for index with id @id.', array('@id' => $index->machine_name)));
+ }
+ $indexed = array();
+ foreach ($items as $id => $item) {
+ try {
+ $this->indexItem($index, $id, $item);
+ $indexed[] = $id;
+ }
+ catch (Exception $e) {
+ // We just log the error, hoping we can index the other items.
+ $vars['%item_id'] = $id;
+ $vars['%index'] = $index->name;
+ watchdog_exception('search_api_db', $e, '%type while trying to index item %item_id on index %index: !message in %function (line %line of %file).', $vars);
+ }
+ }
+ return $indexed;
+ }
+
+ /**
+ * Indexes a single item on the specified index.
+ *
+ * Used as a helper method in indexItems().
+ *
+ * @param SearchApiIndex $index
+ * The index for which the item is being indexed.
+ * @param $id
+ * The item's ID.
+ * @param array $item
+ * The extracted fields of the item.
+ *
+ * @throws Exception
+ * Any encountered database (or other) exceptions are passed on, out of this
+ * method.
+ */
+ protected function indexItem(SearchApiIndex $index, $id, array $item) {
+ $fields = $this->getFieldInfo($index);
+ $fields_updated = FALSE;
+ $txn = $this->connection->startTransaction('search_api_indexing');
+ try {
+ $inserts = array();
+ $text_inserts = array();
+ foreach ($item as $name => $field) {
+ // Sometimes index changes are not triggering the update hooks
+ // correctly. Therefore, to avoid DB errors, we re-check the tables
+ // here before indexing.
+ if (empty($fields[$name]['table']) && !$fields_updated) {
+ unset($this->options['indexes'][$index->machine_name][$name]);
+ $this->fieldsUpdated($index);
+ $fields_updated = TRUE;
+ $fields = $this->options['indexes'][$index->machine_name];
+ }
+ if (empty($fields[$name]['table'])) {
+ watchdog('search_api_db', "Unknown field !field: please check (and re-save) the index's fields settings.",
+ array('!field' => $name), WATCHDOG_WARNING);
+ continue;
+ }
+ $table = $fields[$name]['table'];
+ $boost = $fields[$name]['boost'];
+ $this->connection->delete($table)
+ ->condition('item_id', $id)
+ ->execute();
+ // Don't index null values
+ if ($field['value'] === NULL) {
+ continue;
+ }
+ $type = $field['type'];
+ $value = $this->convert($field['value'], $type, $field['original_type'], $index);
+
+ if (search_api_is_text_type($type, array('text', 'tokens'))) {
+ $words = array();
+ foreach ($value as $token) {
+ // Taken from core search to reflect less importance of words later
+ // in the text.
+ // Focus is a decaying value in terms of the amount of unique words
+ // up to this point. From 100 words and more, it decays, to e.g. 0.5
+ // at 500 words and 0.3 at 1000 words.
+ $focus = min(1, .01 + 3.5 / (2 + count($words) * .015));
+
+ $token_value = &$token['value'];
+ $token_value = trim(preg_replace('/[\pZ\pC]+/u', ' ', $token_value));
+ if (is_numeric($token_value)) {
+ $token_value = ltrim($token_value, '-0');
+ }
+ elseif (backdrop_strlen($token_value) < $this->options['min_chars']) {
+ continue;
+ }
+ $token_value = backdrop_strtolower($token_value);
+ $token['score'] *= $focus;
+ if (!isset($words[$token_value])) {
+ $words[$token_value] = $token;
+ }
+ else {
+ $words[$token_value]['score'] += $token['score'];
+ }
+ unset($token_value);
+ }
+ if ($words) {
+ $field_name = self::getTextFieldName($name);
+ foreach ($words as $word) {
+ $score = round($word['score'] * $boost * self::SCORE_MULTIPLIER);
+ // Take care that the score doesn't exceed the maximum value for
+ // the database column (2^32-1).
+ $score = min((int) $score, 4294967295);
+ $text_inserts[$table][] = array(
+ 'item_id' => $id,
+ 'field_name' => $field_name,
+ 'word' => $word['value'],
+ 'score' => $score,
+ );
+ }
+ }
+ }
+ elseif (search_api_is_list_type($type)) {
+ $values = array();
+ if (is_array($value)) {
+ foreach ($value as $v) {
+ if (isset($v)) {
+ $values["$v"] = TRUE;
+ }
+ }
+ $values = array_keys($values);
+ }
+ elseif (isset($value)) {
+ $values[] = $value;
+ }
+ if ($values) {
+ $insert = $this->connection->insert($table)
+ ->fields(array('item_id', $fields[$name]['column']));
+ foreach ($values as $v) {
+ $insert->values(array(
+ 'item_id' => $id,
+ $fields[$name]['column'] => $v,
+ ));
+ }
+ $insert->execute();
+ }
+ }
+ elseif (isset($value)) {
+ $inserts[$table][$fields[$name]['column']] = $value;
+ }
+ }
+ foreach ($inserts as $table => $data) {
+ $this->connection->insert($table)
+ ->fields(array_merge($data, array('item_id' => $id)))
+ ->execute();
+ }
+ foreach ($text_inserts as $table => $data) {
+ $query = $this->connection->insert($table)
+ ->fields(array('item_id', 'field_name', 'word', 'score'));
+ foreach ($data as $row) {
+ $query->values($row);
+ }
+ $query->execute();
+ }
+ }
+ catch (Exception $e) {
+ $txn->rollback();
+ throw $e;
+ }
+ }
+
+ /**
+ * Trims long field names to fit into the text table's field_name column.
+ *
+ * @param string $name
+ * The field name.
+ *
+ * @return string
+ * The field name as stored in the field_name column.
+ */
+ protected static function getTextFieldName($name) {
+ if (strlen($name) > 255) {
+ // Replace long field names with something unique and predictable.
+ return backdrop_hash_base64($name);
+ }
+ else {
+ return $name;
+ }
+ }
+
+ /**
+ * Converts a value between two search types.
+ *
+ * @param $value
+ * The value to convert.
+ * @param $type
+ * The type to convert to. One of the keys from
+ * search_api_default_field_types().
+ * @param $original_type
+ * The value's original type.
+ * @param SearchApiIndex $index
+ * The index for which this conversion takes place.
+ *
+ * @return mixed
+ * The converted value.
+ *
+ * @throws SearchApiException
+ * If $type is unknown.
+ */
+ protected function convert($value, $type, $original_type, SearchApiIndex $index) {
+ if (search_api_is_list_type($type)) {
+ $type = substr($type, 5, -1);
+ $original_type = search_api_extract_inner_type($original_type);
+ $ret = array();
+ if (is_array($value)) {
+ foreach ($value as $v) {
+ $v = $this->convert($v, $type, $original_type, $index);
+
+ // Don't add NULL values to the return array. Also, adding an empty
+ // array is, of course, a waste of time.
+ if (isset($v) && $v !== array()) {
+ $ret = array_merge($ret, is_array($v) ? $v : array($v));
+ }
+ }
+ }
+ return $ret;
+ }
+ if (!isset($value)) {
+ // For text fields, we have to return an array even if the value is NULL.
+ return search_api_is_text_type($type, array('text', 'tokens')) ? array() : NULL;
+ }
+ switch ($type) {
+ case 'text':
+ // For dates, splitting the timestamp makes no sense.
+ if ($original_type == 'date') {
+ $value = format_date($value, 'custom', 'Y y F M n m j d l D');
+ }
+ $ret = array();
+ foreach (preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) as $v) {
+ if ($v) {
+ $ret[] = array(
+ 'value' => $v,
+ 'score' => 1,
+ );
+ }
+ }
+ $value = $ret;
+ // FALL-THROUGH!
+ case 'tokens':
+ while (TRUE) {
+ foreach ($value as $i => $v) {
+ // Check for over-long tokens.
+ $score = $v['score'];
+ $v = $v['value'];
+ if (backdrop_strlen($v) > 50) {
+ $words = preg_split('/[^\p{L}\p{N}]+/u', $v, -1, PREG_SPLIT_NO_EMPTY);
+ if (count($words) > 1 && max(array_map('backdrop_strlen', $words)) <= 50) {
+ // Overlong token is due to bad tokenizing.
+ // Check for "Tokenizer" preprocessor on index.
+ if (empty($index->options['processors']['search_api_tokenizer']['status'])) {
+ watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
+ 'It is recommended to enable the "Tokenizer" preprocessor for indexes using database servers. ' .
+ 'Otherwise, the service class has to use its own, fixed tokenizing.', array(), WATCHDOG_WARNING);
+ }
+ else {
+ watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
+ 'Please check your settings for the "Tokenizer" preprocessor to ensure that data is tokenized correctly.',
+ array(), WATCHDOG_WARNING);
+ }
+ }
+
+ $tokens = array();
+ foreach ($words as $word) {
+ if (backdrop_strlen($word) > 50) {
+ watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing: %word.
' .
+ 'Database search servers currently cannot index such words correctly – the word was therefore trimmed to the allowed length.',
+ array('%word' => $word), WATCHDOG_WARNING);
+ $word = backdrop_substr($word, 0, 50);
+ }
+ $tokens[] = array(
+ 'value' => $word,
+ 'score' => $score,
+ );
+ }
+ array_splice($value, $i, 1, $tokens);
+ continue 2;
+ }
+ }
+ break;
+ }
+ return $value;
+
+ case 'string':
+ case 'uri':
+ // For non-dates, PHP can handle this well enough.
+ if ($original_type == 'date') {
+ return date('c', $value);
+ }
+ if (backdrop_strlen($value) > 255) {
+ $value = backdrop_substr($value, 0, 255);
+ watchdog('search_api_db', 'An overlong value (more than 255 characters) was encountered while indexing: %value.
' .
+ 'Database search servers currently cannot index such values correctly – the value was therefore trimmed to the allowed length.',
+ array('%value' => $value), WATCHDOG_WARNING);
+ }
+ return $value;
+
+ case 'integer':
+ case 'duration':
+ case 'decimal':
+ return 0 + $value;
+
+ case 'boolean':
+ // Numeric strings need to be converted to a numeric type before
+ // converting to a boolean, as strings like '0.00' evaluate to TRUE.
+ if (is_string($value) && is_numeric($value)) {
+ $value = 0 + $value;
+ }
+ return $value ? 1 : 0;
+
+ case 'date':
+ if (is_numeric($value) || !$value) {
+ return 0 + $value;
+ }
+ return strtotime($value);
+
+ default:
+ throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteItems($ids = 'all', ?SearchApiIndex $index = NULL) {
+ try {
+ if (!$index) {
+ if (empty($this->options['indexes'])) {
+ return;
+ }
+ $truncated = array();
+ foreach ($this->options['indexes'] as $fields) {
+ foreach ($fields as $field) {
+ if (isset($field['table']) && !isset($truncated[$field['table']])) {
+ $this->connection->truncate($field['table'])->execute();
+ $truncated[$field['table']] = TRUE;
+ }
+ }
+ }
+ return;
+ }
+
+ if (empty($this->options['indexes'][$index->machine_name])) {
+ return;
+ }
+ foreach ($this->options['indexes'][$index->machine_name] as $field) {
+ if (is_array($ids)) {
+ $this->connection->delete($field['table'])
+ ->condition('item_id', $ids, 'IN')
+ ->execute();
+ }
+ else {
+ $this->connection->truncate($field['table'])->execute();
+ }
+ }
+ }
+ // The database operations might throw PDO or other exceptions, so we catch
+ // them all and re-wrap them appropriately.
+ catch (Exception $e) {
+ throw new SearchApiException($e->getMessage());
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function search(SearchApiQueryInterface $query) {
+ $time_method_called = microtime(TRUE);
+ $this->ignored = $this->warnings = array();
+ $this->aliasCounter = 0;
+ $index = $query->getIndex();
+ if (empty($this->options['indexes'][$index->machine_name])) {
+ throw new SearchApiException(t('Unknown index @id.', array('@id' => $index->machine_name)));
+ }
+ $fields = $this->getFieldInfo($index);
+
+ $db_query = $this->createDbQuery($query, $fields);
+
+ $time_processing_done = microtime(TRUE);
+ $results = array(
+ 'results' => array(),
+ );
+
+ $skip_count = $query->getOption('skip result count');
+ if (!$skip_count) {
+ $count_query = $db_query->countQuery();
+ $results['result count'] = $count_query->execute()->fetchField();
+ }
+
+ if ($skip_count || $results['result count']) {
+ if ($query->getOption('search_api_facets')) {
+ $results['search_api_facets'] = $this->getFacets($query, clone $db_query);
+ }
+
+ $query_options = $query->getOptions();
+ if (isset($query_options['offset']) || isset($query_options['limit'])) {
+ $offset = isset($query_options['offset']) ? $query_options['offset'] : 0;
+ $limit = isset($query_options['limit']) ? $query_options['limit'] : 1000000;
+ $db_query->range($offset, $limit);
+ }
+
+ $this->setQuerySort($query, $db_query, $fields);
+
+ $result = $db_query->execute();
+
+ foreach ($result as $row) {
+ $results['results'][$row->item_id] = array(
+ 'id' => $row->item_id,
+ 'score' => $row->score / self::SCORE_MULTIPLIER,
+ );
+ }
+ if ($skip_count) {
+ $results['result count'] = !empty($results['results']);
+ }
+ }
+ $time_queries_done = microtime(TRUE);
+
+ $results['warnings'] = array_keys($this->warnings);
+ $results['ignored'] = array_keys($this->ignored);
+
+ $this->postQuery($results, $query);
+
+ $time_end = microtime(TRUE);
+ $results['performance'] = array(
+ 'complete' => $time_end - $time_method_called,
+ 'preprocessing' => $time_processing_done - $time_method_called,
+ 'execution' => $time_queries_done - $time_processing_done,
+ 'postprocessing' => $time_end - $time_queries_done,
+ );
+
+ return $results;
+ }
+
+ /**
+ * Creates a database query for a search.
+ *
+ * Used as a helper method in search() and getAutocompleteSuggestions().
+ *
+ * @param SearchApiQueryInterface $query
+ * The search query for which to create the database query.
+ *
+ * @param array $fields
+ * The internal field information to use.
+ *
+ * @return SelectQuery
+ * A database query object which will return the appropriate results (except
+ * for the range setting) for the given search query.
+ *
+ * @throws SearchApiException
+ * If some illegal query setting (unknown field, etc.) was encountered.
+ */
+ protected function createDbQuery(SearchApiQueryInterface $query, array $fields) {
+ $keys = &$query->getKeys();
+ $keys_set = (boolean) $keys;
+ $tokenizer_active = static::isTokenizerActive($query->getIndex());
+ $keys = $this->prepareKeys($keys, $tokenizer_active);
+ // Special case: if the outermost $keys array has "#negation" set, we can't
+ // handle it like other negated subkeys. To avoid additional complexity
+ // later, we just wrap $keys so it becomes a subkey.
+ if (!empty($keys['#negation'])) {
+ $keys = array(
+ '#conjunction' => 'AND', $keys,
+ );
+ }
+ // Only filter by fulltext keys if there are any real keys present.
+ if ($keys && (!is_array($keys) || count($keys) > 2 || (!isset($keys['#negation']) && count($keys) > 1))) {
+ $fulltext_fields = $query->getFields();
+ if ($fulltext_fields) {
+ $_fulltext_fields = $fulltext_fields;
+ $fulltext_fields = array();
+ foreach ($_fulltext_fields as $name) {
+ if (!isset($fields[$name])) {
+ throw new SearchApiException(t('Unknown field @field specified as search target.', array('@field' => $name)));
+ }
+ if (!search_api_is_text_type($fields[$name]['type'])) {
+ $types = search_api_field_types();
+ $type = $types[$fields[$name]['type']];
+ throw new SearchApiException(t('Cannot perform fulltext search on field @field of type @type.', array('@field' => $name, '@type' => $type)));
+ }
+ $fulltext_fields[$name] = $fields[$name];
+ }
+
+ $db_query = $this->createKeysQuery($keys, $fulltext_fields, $fields);
+ if (is_array($keys) && !empty($keys['#negation'])) {
+ $db_query->addExpression(':score', 'score', array(':score' => self::SCORE_MULTIPLIER));
+ $db_query->distinct();
+ }
+ }
+ else {
+ $msg = t('Search keys are given but no fulltext fields are defined.');
+ watchdog('search_api_db', $msg, NULL, WATCHDOG_WARNING);
+ $this->warnings[$msg] = 1;
+ }
+ }
+ elseif ($keys_set) {
+ $msg = t('No valid search keys were present in the query.');
+ $this->warnings[$msg] = 1;
+ }
+
+ if (!isset($db_query)) {
+ $db_query = $this->connection->select($fields['search_api_language']['table'], 't');
+ $db_query->addField('t', 'item_id', 'item_id');
+ $db_query->addExpression(':score', 'score', array(':score' => self::SCORE_MULTIPLIER));
+ $db_query->distinct();
+ }
+
+ $filter = $query->getFilter();
+ if ($filter->getFilters()) {
+ $condition = $this->createFilterCondition($filter, $fields, $db_query, $query->getIndex());
+ if ($condition) {
+ $db_query->condition($condition);
+ }
+ }
+
+ $db_query->addTag('search_api_db_search');
+ $db_query->addMetaData('search_api_query', $query);
+ $db_query->addMetaData('search_api_db_fields', $fields);
+
+ // Allow subclasses and other modules to alter the query.
+ backdrop_alter('search_api_db_query', $db_query, $query);
+ $this->preQuery($db_query, $query);
+
+ return $db_query;
+ }
+
+ /**
+ * Removes nested expressions and phrase groupings from the search keys.
+ *
+ * Used as a helper method in createDbQuery() and createFilterCondition().
+ *
+ * @param array|string|null $keys
+ * The keys which should be preprocessed.
+ * @param bool $tokenizer_active
+ * (optional) TRUE if we can rely on the "Tokenizer" processor already
+ * having preprocessed the keywords.
+ *
+ * @return array|string|null
+ * The preprocessed keys.
+ */
+ protected function prepareKeys($keys, $tokenizer_active = FALSE) {
+ if (is_scalar($keys)) {
+ $keys = $this->splitKeys($keys, $tokenizer_active);
+ return is_array($keys) ? $this->eliminateDuplicates($keys) : $keys;
+ }
+ elseif (!$keys) {
+ return NULL;
+ }
+ $keys = $this->splitKeys($keys, $tokenizer_active);
+ $keys = $this->eliminateDuplicates($keys);
+ $conj = $keys['#conjunction'];
+ $neg = !empty($keys['#negation']);
+ foreach ($keys as $i => &$nested) {
+ if (is_array($nested)) {
+ $nested = $this->prepareKeys($nested, $tokenizer_active);
+ if (is_array($nested) && $neg == !empty($nested['#negation'])) {
+ if ($nested['#conjunction'] == $conj) {
+ unset($nested['#conjunction'], $nested['#negation']);
+ foreach ($nested as $renested) {
+ $keys[] = $renested;
+ }
+ unset($keys[$i]);
+ }
+ }
+ }
+ }
+ $keys = array_filter($keys);
+ if (($count = count($keys)) <= 2) {
+ if ($count < 2 || isset($keys['#negation'])) {
+ $keys = NULL;
+ }
+ else {
+ unset($keys['#conjunction']);
+ $keys = reset($keys);
+ }
+ }
+ return $keys;
+ }
+
+ /**
+ * Splits a keyword expression into separate words.
+ *
+ * Used as a helper method in prepareKeys().
+ *
+ * @param array|string|null $keys
+ * The keys to split.
+ * @param bool $tokenizer_active
+ * (optional) TRUE if we can rely on the "Tokenizer" processor already
+ * having preprocessed the keywords.
+ *
+ * @return array|string|null
+ * The keys split into separate words.
+ */
+ protected function splitKeys($keys, $tokenizer_active = FALSE) {
+ if (is_scalar($keys)) {
+ $proc = backdrop_strtolower(trim($keys));
+ if (is_numeric($proc)) {
+ return ltrim($proc, '-0');
+ }
+ elseif (backdrop_strlen($proc) < $this->options['min_chars']) {
+ $this->ignored[$keys] = 1;
+ return NULL;
+ }
+
+ if ($tokenizer_active) {
+ $words = array_filter(explode(' ', $proc), 'strlen');
+ }
+ else {
+ $words = preg_split('/[^\p{L}\p{N}]+/u', $proc, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ if (count($words) > 1) {
+ $proc = $this->splitKeys($words, $tokenizer_active);
+ if ($proc) {
+ $proc['#conjunction'] = 'AND';
+ }
+ else {
+ $proc = NULL;
+ }
+ }
+ return $proc;
+ }
+ foreach ($keys as $i => $key) {
+ if (element_child($i)) {
+ $keys[$i] = $this->splitKeys($key, $tokenizer_active);
+ }
+ }
+ return array_filter($keys);
+ }
+
+ /**
+ * Eliminates duplicate keys from a keyword array.
+ *
+ * Used as a helper method in prepareKeys().
+ *
+ * @param array $keys
+ * The keywords to parse.
+ * @param array $words
+ * (optional) A cache of all encountered words so far, used internally for
+ * recursive invocations.
+ *
+ * @return array
+ * The processed keywords.
+ */
+ protected function eliminateDuplicates($keys, &$words = array()) {
+ foreach ($keys as $i => $word) {
+ if (!element_child($i)) {
+ continue;
+ }
+ if (is_scalar($word)) {
+ if (isset($words[$word])) {
+ unset($keys[$i]);
+ }
+ else {
+ $words[$word] = TRUE;
+ }
+ }
+ else {
+ $keys[$i] = $this->eliminateDuplicates($word, $words);
+ }
+ }
+ return $keys;
+ }
+
+ /**
+ * Creates a SELECT query for given search keys.
+ *
+ * Used as a helper method in createDbQuery() and createFilterCondition().
+ *
+ * @param $keys
+ * The search keys, formatted like the return value of
+ * SearchApiQueryInterface::getKeys(), but preprocessed according to
+ * internal requirements.
+ * @param array $fields
+ * The fulltext fields on which to search, with their names as keys mapped
+ * to internal information about them.
+ * @param array $all_fields
+ * Internal information about all indexed fields on the index.
+ *
+ * @return SelectQueryInterface
+ * A SELECT query returning item_id and score (or only item_id, if
+ * $keys['#negation'] is set).
+ */
+ protected function createKeysQuery($keys, array $fields, array $all_fields) {
+ if (!is_array($keys)) {
+ $keys = array(
+ '#conjunction' => 'AND', $keys,
+ );
+ }
+
+ $neg = !empty($keys['#negation']);
+ $conj = $keys['#conjunction'];
+ $words = array();
+ $nested = array();
+ $negated = array();
+ $db_query = NULL;
+ $word_hits = array();
+ $neg_nested = $neg && $conj == 'AND';
+
+ foreach ($keys as $i => $key) {
+ if (!element_child($i)) {
+ continue;
+ }
+ if (is_scalar($key)) {
+ $words[] = $key;
+ }
+ elseif (empty($key['#negation'])) {
+ if ($neg) {
+ // If this query is negated, we also only need item_ids from
+ // subqueries.
+ $key['#negation'] = TRUE;
+ }
+ $nested[] = $key;
+ }
+ else {
+ $negated[] = $key;
+ }
+ }
+ $subs = count($words) + count($nested);
+ $mul_words = count($words) > 1;
+ $not_nested = ($subs <= 1 && !$mul_words) || ($neg && $conj == 'OR' && !$negated);
+
+ if ($words) {
+ // All text fields in the index share a table. Get name from the first.
+ $field = reset($fields);
+ $db_query = $this->connection->select($field['table'], 't');
+ if ($neg_nested) {
+ $db_query->fields('t', array('item_id', 'word'));
+ }
+ elseif ($neg) {
+ $db_query->fields('t', array('item_id'));
+ }
+ elseif ($not_nested) {
+ $db_query->fields('t', array('item_id'));
+ $db_query->addExpression('SUM(score)', 'score');
+ $db_query->groupBy('t.item_id');
+ }
+ else {
+ $db_query->fields('t', array('item_id', 'word'));
+ $db_query->addExpression('SUM(score)', 'score');
+ $db_query->groupBy('t.item_id');
+ $db_query->groupBy('t.word');
+ }
+
+ if (empty($this->options['partial_matches'])) {
+ $db_query->condition('word', $words, 'IN');
+ }
+ else {
+ $db_or = db_or();
+ // GROUP BY all existing non-grouped, non-aggregated columns – except
+ // "word", which we remove since it will be useless to us in this case.
+ $columns = &$db_query->getFields();
+ unset($columns['word']);
+ foreach ($columns as $column => $info) {
+ $db_query->groupBy($info['table'] . '.' . $column);
+ }
+
+ foreach ($words as $word) {
+ $like = '%' . $this->connection->escapeLike($word) . '%';
+ $db_or->condition('t.word', $like, 'LIKE');
+
+ // Add an expression for each keyword that shows whether the indexed
+ // word matches that particular keyword. That way we don't return a
+ // result multiple times if a single indexed word (partially) matches
+ // multiple keywords. We also remember the column name so we can
+ // afterwards verify that each word matched at least once.
+ $alias = 'w' . $this->aliasCounter++;
+ $alias = $db_query->addExpression("CASE WHEN t.word LIKE :like_$alias THEN 1 ELSE 0 END", $alias, array(":like_$alias" => $like));
+ $db_query->groupBy($alias);
+ $word_hits[] = $alias;
+ }
+ $db_query->condition($db_or);
+ }
+ $db_query->condition('field_name', array_map(array(__CLASS__, 'getTextFieldName'), array_keys($fields)), 'IN');
+ }
+
+ if ($nested) {
+ $word = '';
+ foreach ($nested as $k) {
+ $query = $this->createKeysQuery($k, $fields, $all_fields);
+ if (!$neg) {
+ $word .= ' ';
+ $var = ':word' . strlen($word);
+ $query->addExpression($var, 'word', array($var => $word));
+ }
+ if (!isset($db_query)) {
+ $db_query = $query;
+ }
+ elseif ($not_nested) {
+ $db_query->union($query, 'UNION');
+ }
+ else {
+ $db_query->union($query, 'UNION ALL');
+ }
+ }
+ }
+
+ if (isset($db_query) && !$not_nested) {
+ $db_query = $this->connection->select($db_query, 't');
+ $db_query->addField('t', 'item_id', 'item_id');
+ if (!$neg) {
+ $db_query->addExpression('SUM(t.score)', 'score');
+ $db_query->groupBy('t.item_id');
+ }
+ if ($conj == 'AND' && $subs > 1) {
+ $var = ':subs' . ((int) $subs);
+ if (!$db_query->getGroupBy()) {
+ $db_query->groupBy('t.item_id');
+ }
+ if ($word_hits) {
+ // Simply check whether each word matched at least once.
+ foreach ($word_hits as $column) {
+ $db_query->having("SUM($column) >= 1");
+ }
+ }
+ elseif ($mul_words) {
+ $db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
+ }
+ else {
+ $db_query->having('COUNT(t.word) >= ' . $var, array($var => $subs));
+ }
+ }
+ }
+
+ if ($negated) {
+ if (!isset($db_query) || $conj == 'OR') {
+ if (isset($db_query)) {
+ // We are in a rather bizarre case where the keys are something like
+ // "a OR (NOT b)".
+ $old_query = $db_query;
+ }
+ // We use this table because all items should be contained exactly once.
+ $db_query = $this->connection->select($all_fields['search_api_language']['table'], 't');
+ $db_query->addField('t', 'item_id', 'item_id');
+ if (!$neg) {
+ $db_query->addExpression(':score', 'score', array(':score' => self::SCORE_MULTIPLIER));
+ $db_query->distinct();
+ }
+ }
+
+ if ($conj == 'AND') {
+ foreach ($negated as $k) {
+ $db_query->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
+ }
+ }
+ else {
+ $or = db_or();
+ foreach ($negated as $k) {
+ $or->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
+ }
+ if (isset($old_query)) {
+ $or->condition('t.item_id', $old_query, 'NOT IN');
+ }
+ $db_query->condition($or);
+ }
+ }
+
+ if ($neg_nested) {
+ $db_query = $this->connection->select($db_query, 't')->fields('t', array('item_id'));
+ }
+
+ return $db_query;
+ }
+
+ /**
+ * Creates a database query condition for a given search filter.
+ *
+ * Used as a helper method in createDbQuery().
+ *
+ * @param SearchApiQueryFilterInterface $filter
+ * The filter for which a condition should be created.
+ * @param array $fields
+ * Internal information about the index's fields.
+ * @param SelectQueryInterface $db_query
+ * The database query to which the condition will be added.
+ * @param ?SearchApiIndex $index
+ * (optional) The search index whose settings should be used.
+ *
+ * @return DatabaseCondition|null
+ * The condition to set on the query, or NULL if none is necessary.
+ *
+ * @throws SearchApiException
+ * If an unknown field was used in the filter.
+ */
+ protected function createFilterCondition(SearchApiQueryFilterInterface $filter, array $fields, SelectQueryInterface $db_query, ?SearchApiIndex $index = NULL) {
+ $cond = db_condition($filter->getConjunction());
+ // Store whether a JOIN already occurred for a field, so we don't JOIN
+ // repeatedly for OR filters.
+ $first_join = array();
+ // Store the table aliases for the fields in this condition group.
+ $tables = array();
+ foreach ($filter->getFilters() as $f) {
+ if (is_object($f)) {
+ $c = $this->createFilterCondition($f, $fields, $db_query, $index);
+ if ($c) {
+ $cond->condition($c);
+ }
+ }
+ else {
+ list($field, $value, $operator) = $f;
+ if (!isset($fields[$field])) {
+ throw new SearchApiException(t('Unknown field in filter clause: @field.', array('@field' => $field)));
+ }
+ $field_info = $fields[$field];
+ $not_between = $operator === 'NOT BETWEEN';
+ $not_equals = $not_between || $operator === '<>' || $operator === '!=';
+ $text_type = search_api_is_text_type($field_info['type']);
+ // If the field is in its own table, we have to check for NULL values in
+ // a special way (i.e., check for missing entries in that table).
+ if ($value === NULL && ($field_info['column'] === 'value' || $text_type)) {
+ $query = $this->connection->select($field_info['table'], 't')
+ ->fields('t', array('item_id'));
+ if ($text_type) {
+ $query->condition('t.field_name', $field);
+ }
+ $cond->condition('t.item_id', $query, $not_equals ? 'IN' : 'NOT IN');
+ continue;
+ }
+ if ($text_type) {
+ if (!isset($tokenizer_active)) {
+ $tokenizer_active = $index && static::isTokenizerActive($index);
+ }
+ $keys = $this->prepareKeys($value, $tokenizer_active);
+ if (!isset($keys)) {
+ continue;
+ }
+ $query = $this->createKeysQuery($keys, array($field => $field_info), $fields);
+ // We only want the item IDs, so we use the keys query as a nested query.
+ $query = $this->connection->select($query, 't')->fields('t', array('item_id'));
+ $cond->condition('t.item_id', $query, $not_equals ? 'NOT IN' : 'IN');
+ }
+ else {
+ $new_join = search_api_is_list_type($field_info['type'])
+ && ($filter->getConjunction() == 'AND'
+ || empty($first_join[$field]));
+ if ($new_join || empty($tables[$field])) {
+ $tables[$field] = $this->getTableAlias($field_info, $db_query, $new_join);
+ $first_join[$field] = TRUE;
+ }
+ $column = $tables[$field] . '.' . $field_info['column'];
+ if ($value === NULL) {
+ $method = ($operator == '=') ? 'isNull' : 'isNotNull';
+ $cond->$method($column);
+ }
+ elseif ($not_equals && search_api_is_list_type($field_info['type'])) {
+ // The situation is more complicated for multi-valued fields, since
+ // we must make sure that results are excluded if ANY of the field's
+ // values equals the one given in this condition.
+ $sub_operator = ($not_between) ? 'BETWEEN' : '=';
+ $query = $this->connection->select($field_info['table'], 't')
+ ->fields('t', array('item_id'))
+ ->condition($field_info['column'], $value, $sub_operator);
+ $cond->condition('t.item_id', $query, 'NOT IN');
+ }
+ elseif ($not_between) {
+ $cond->where("$column NOT BETWEEN {$value[0]} AND {$value[1]}");
+ }
+ else {
+ $cond->condition($column, $value, $operator);
+ }
+ }
+ }
+ }
+ return count($cond->conditions()) > 1 ? $cond : NULL;
+ }
+
+ /**
+ * Joins a field's table into a database select query.
+ *
+ * @param array $field
+ * The field information array. The "table" key should contain the table
+ * name to which a join should be made.
+ * @param SelectQueryInterface $db_query
+ * The database query used.
+ * @param bool $newjoin
+ * (optional) If TRUE, a join is done even if the table was already joined
+ * to in the query.
+ * @param string $join
+ * (optional) The join method to use. Must be a method of the $db_query.
+ * Normally, "join", "innerJoin", "leftJoin" and "rightJoin" are supported.
+ *
+ * @return string
+ * The alias for the field's table.
+ */
+ protected function getTableAlias(array $field, SelectQueryInterface $db_query, $newjoin = FALSE, $join = 'leftJoin') {
+ if (!$newjoin) {
+ foreach ($db_query->getTables() as $alias => $info) {
+ $table = $info['table'];
+ if (is_scalar($table) && $table == $field['table']) {
+ return $alias;
+ }
+ }
+ }
+ return $db_query->$join($field['table'], 't', 't.item_id = %alias.item_id');
+ }
+
+ /**
+ * Preprocesses a search's database query before it is executed.
+ *
+ * This allows subclasses to apply custom changes before the query (and the
+ * count query) is executed.
+ *
+ * @param SelectQueryInterface $db_query
+ * The database query to be executed for the search. Will have "item_id" and
+ * "score" columns in its result.
+ * @param SearchApiQueryInterface $query
+ * The search query that is being executed.
+ *
+ * @see hook_search_api_db_query_alter()
+ */
+ protected function preQuery(SelectQueryInterface &$db_query, SearchApiQueryInterface $query) { }
+
+ /**
+ * Postprocess search results.
+ *
+ * This allows subclasses to apply custom changes before the results are
+ * returned.
+ *
+ * @param array $results
+ * The results array that will be returned for the search, in the format
+ * defined by SearchApiQueryInterface::execute().
+ * @param SearchApiQueryInterface $query
+ * The executed search query.
+ */
+ protected function postQuery(array &$results, SearchApiQueryInterface $query) { }
+
+ /**
+ * Adds the query sort to a search database query.
+ *
+ * @param SearchApiQueryInterface $query
+ * The search query whose sorts should be applied.
+ * @param SelectQueryInterface $db_query
+ * The database query used for the search.
+ * @param array $fields
+ * An array containing information about the internal server storage of the
+ * indexed fields.
+ *
+ * @throws SearchApiException
+ * If an illegal sort was specified.
+ */
+ protected function setQuerySort(SearchApiQueryInterface $query, SelectQueryInterface $db_query, array $fields) {
+ $sort = $query->getSort();
+ if ($sort) {
+ foreach ($sort as $field_name => $order) {
+ if ($order != 'ASC' && $order != 'DESC') {
+ $msg = t('Unknown sort order @order. Assuming "ASC".', array('@order' => $order));
+ $this->warnings[$msg] = $msg;
+ $order = 'ASC';
+ }
+ if ($field_name == 'search_api_relevance') {
+ $db_query->orderBy('score', $order);
+ continue;
+ }
+ if ($field_name == 'search_api_id') {
+ $db_query->orderBy('item_id', $order);
+ continue;
+ }
+ if ($field_name == 'search_api_random') {
+ $db_query->orderRandom();
+ continue;
+ }
+ if (!isset($fields[$field_name])) {
+ throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $field_name)));
+ }
+ $field = $fields[$field_name];
+ if (search_api_is_list_type($field['type'])) {
+ throw new SearchApiException(t('Cannot sort on field @field of a list type.', array('@field' => $field_name)));
+ }
+ if (search_api_is_text_type($field['type'])) {
+ throw new SearchApiException(t('Cannot sort on fulltext field @field.', array('@field' => $field_name)));
+ }
+ $alias = $this->getTableAlias($field, $db_query);
+ $db_query->orderBy($alias . '.' . $fields[$field_name]['column'], $order);
+ // PostgreSQL automatically adds a field to the SELECT list when sorting
+ // on it. Therefore, if we have aggregations present we also have to
+ // add the field to the GROUP BY (since Backdrop won't do it for us).
+ // However, if no aggregations are present, a GROUP BY would lead to
+ // another error. Therefore, we only add it if there is already a GROUP
+ // BY.
+ if ($db_query->getGroupBy()) {
+ $db_query->groupBy($alias . '.' . $fields[$field_name]['column']);
+ }
+ // For SELECT DISTINCT queries in combination with an ORDER BY clause,
+ // MySQL 5.7 and higher require that the ORDER BY expressions are part
+ // of the field list. Ensure that all fields used for sorting are part
+ // of the select list.
+ if (empty($db_fields[$fields[$field_name]['column']])) {
+ $db_query->addField($alias, $fields[$field_name]['column']);
+ }
+ }
+ }
+ else {
+ $db_query->orderBy('score', 'DESC');
+ }
+ }
+
+ /**
+ * Computes facets for a search query.
+ *
+ * @param SearchApiQueryInterface $query
+ * The search query for which facets should be computed.
+ * @param SelectQueryInterface $db_query
+ * A database select query which returns all results of that search query.
+ *
+ * @return array
+ * An array of facets, as specified by the search_api_facets feature.
+ */
+ protected function getFacets(SearchApiQueryInterface $query, SelectQueryInterface $db_query) {
+ try {
+ // Add a tag to the database query to identify it as a facet base query.
+ $db_query->addTag('search_api_db_facets_base');
+
+ // Store the results of the query in a temporary table to run facet
+ // queries on it afterwards.
+ $table = $this->getTemporaryResultsTable($db_query);
+ if (!$table) {
+ return array();
+ }
+
+ $fields = $this->getFieldInfo($query->getIndex());
+ $ret = array();
+ foreach ($query->getOption('search_api_facets') as $key => $facet) {
+ if (empty($fields[$facet['field']])) {
+ $this->warnings[] = t('Unknown facet field @field.', array('@field' => $facet['field']));
+ continue;
+ }
+ $field = $fields[$facet['field']];
+
+ if (empty($facet['operator']) || $facet['operator'] != 'or') {
+ // All the AND facets can use the main query.
+ $select = $this->connection->select($table, 't');
+ }
+ else {
+ // For OR facets, we need to build a different base query that
+ // excludes the facet filters applied to the facet.
+ $or_query = clone $query;
+ $filters = &$or_query->getFilter()->getFilters();
+ $tag = 'facet:' . $facet['field'];
+ foreach ($filters as $filter_id => $filter) {
+ if ($filter instanceof SearchApiQueryFilterInterface && $filter->hasTag($tag)) {
+ unset($filters[$filter_id]);
+ }
+ }
+ $or_db_query = $this->createDbQuery($or_query, $fields);
+ $select = $this->connection->select($or_db_query, 't');
+ }
+
+ // Add tags and metadata.
+ $select->addTag('search_api_db_facet');
+ $select->addMetaData('search_api_query', $query);
+ $select->addMetaData('search_api_db_fields', $fields);
+ $select->addMetaData('search_api_db_facet', $facet);
+
+ // If "Include missing facet" is disabled, we use an INNER JOIN and add
+ // IS NOT NULL for shared tables.
+ $is_text_type = search_api_is_text_type($field['type']);
+ $alias = $this->getTableAlias($field, $select, TRUE, $facet['missing'] ? 'leftJoin' : 'innerJoin');
+ $select->addField($alias, $field['column'], 'value');
+ if ($is_text_type) {
+ $select->condition("$alias.field_name", $this->getTextFieldName($facet['field']));
+ }
+ if (!$facet['missing'] && !$is_text_type) {
+ $select->isNotNull($alias . '.' . $field['column']);
+ }
+ $select->addExpression('COUNT(DISTINCT t.item_id)', 'num');
+ $select->groupBy('value');
+ $select->orderBy('num', 'DESC');
+
+ $limit = $facet['limit'];
+ if ((int) $limit > 0) {
+ $select->range(0, $limit);
+ }
+ if ($facet['min_count'] > 1) {
+ $select->having('COUNT(DISTINCT t.item_id) >= :count', array(':count' => $facet['min_count']));
+ }
+
+ $terms = array();
+ $values = array();
+ $has_missing = FALSE;
+ foreach ($select->execute() as $row) {
+ $terms[] = array(
+ 'count' => $row->num,
+ 'filter' => isset($row->value) ? '"' . $row->value . '"' : '!',
+ );
+ if (isset($row->value)) {
+ $values[] = $row->value;
+ }
+ else {
+ $has_missing = TRUE;
+ }
+ }
+
+ // If 'Minimum facet count' is set to 0 in the display options for this
+ // facet, we need to retrieve all facets, even ones that aren't matched
+ // in our search result set above. Here we SELECT all DISTINCT facets,
+ // and add in those facets that weren't added above.
+ if ($facet['min_count'] < 1) {
+ $select = $this->connection->select($field['table'], 't');
+ $select->addField('t', $field['column'], 'value');
+ $select->distinct();
+ if ($values) {
+ $select->condition($field['column'], $values, 'NOT IN');
+ }
+ if ($is_text_type) {
+ $select->condition('t.field_name', $this->getTextFieldName($facet['field']));
+ }
+ else {
+ $select->isNotNull($field['column']);
+ }
+
+ // Add tags and metadata.
+ $select->addTag('search_api_db_facet_all');
+ $select->addMetaData('search_api_query', $query);
+ $select->addMetaData('search_api_db_fields', $fields);
+ $select->addMetaData('search_api_db_facet', $facet);
+
+ foreach ($select->execute() as $row) {
+ $terms[] = array(
+ 'count' => 0,
+ 'filter' => '"' . $row->value . '"',
+ );
+ }
+ if ($facet['missing'] && !$has_missing) {
+ $terms[] = array(
+ 'count' => 0,
+ 'filter' => '!',
+ );
+ }
+ }
+
+ $ret[$key] = $terms;
+ }
+ return $ret;
+ }
+ catch (PDOException $e) {
+ watchdog_exception('search_api_db', $e, '%type while trying to calculate facets: !message in %function (line %line of %file).');
+ return array();
+ }
+ }
+
+ /**
+ * Creates a temporary table from a SelectQuery.
+ *
+ * Will return the name of a table containing the item IDs of all results, or
+ * FALSE on failure.
+ *
+ * @param SelectQueryInterface $db_query
+ * The select query whose results should be stored in the temporary table.
+ *
+ * @return string|false
+ * The name of the temporary table, or FALSE on failure.
+ */
+ protected function getTemporaryResultsTable(SelectQueryInterface $db_query) {
+ // We only need the ID column, not the score.
+ $fields = &$db_query->getFields();
+ unset($fields['score']);
+ if (count($fields) != 1 || !isset($fields['item_id'])) {
+ watchdog('search_api_db', 'Error while adding facets: only "item_id" field should be used, used are: @fields.',
+ array('@fields' => implode(', ', array_keys($fields))), WATCHDOG_WARNING);
+ return FALSE;
+ }
+ $expressions = &$db_query->getExpressions();
+ $expressions = array();
+
+ // If there's a GROUP BY for item_id, we leave that, all others need to be
+ // discarded.
+ $group_by = &$db_query->getGroupBy();
+ $group_by = array_intersect_key($group_by, array('t.item_id' => TRUE));
+
+ // The order of results also doesn't matter here. Also, this might lead to
+ // errors if the ORDER BY clause references any expressions we removed.
+ $sort = &$db_query->getOrderBy();
+ $sort = array();
+
+ $db_query->distinct();
+ if (!$db_query->preExecute()) {
+ return FALSE;
+ }
+ $args = $db_query->getArguments();
+ return $this->connection->queryTemporary((string) $db_query, $args);
+ }
+
+ /**
+ * Implements SearchApiAutocompleteInterface::getAutocompleteSuggestions().
+ */
+ public function getAutocompleteSuggestions(SearchApiQueryInterface $query, SearchApiAutocompleteSearch $search, $incomplete_key, $user_input) {
+ $settings = isset($this->options['autocomplete']) ? $this->options['autocomplete'] : array();
+ $settings += array(
+ 'suggest_suffix' => TRUE,
+ 'suggest_words' => TRUE,
+ );
+ // If none of these options is checked, the user apparently chose a very
+ // roundabout way of telling us he doesn't want autocompletion.
+ if (!array_filter($settings)) {
+ return array();
+ }
+
+ $index = $query->getIndex();
+ if (empty($this->options['indexes'][$index->machine_name])) {
+ throw new SearchApiException(t('Unknown index @id.', array('@id' => $index->machine_name)));
+ }
+ $fields = $this->getFieldInfo($index);
+
+ $suggestions = array();
+ $passes = array();
+
+ // Make the input lowercase as the indexed data is also all lowercase.
+ $user_input = backdrop_strtolower($user_input);
+ $incomplete_key = backdrop_strtolower($incomplete_key);
+
+ // Decide which methods we want to use.
+ if ($incomplete_key && $settings['suggest_suffix']) {
+ $tokenizer_active = static::isTokenizerActive($index);
+ $processed_key = $this->splitKeys($incomplete_key, $tokenizer_active);
+ if ($processed_key) {
+ // In case the $incomplete_key turned out to be more than one word, add
+ // all but the last one to the user input.
+ if (is_array($processed_key)) {
+ unset($processed_key['#conjunction']);
+ $incomplete_key = array_pop($processed_key);
+ if ($processed_key) {
+ $user_input .= ' ' . implode(' ', $processed_key);
+ }
+ $processed_key = $incomplete_key;
+ }
+ $passes[] = 1;
+ $incomplete_like = $this->connection->escapeLike($processed_key) . '%';
+ }
+ }
+ if ($settings['suggest_words']
+ && (!$incomplete_key || strlen($incomplete_key) >= $this->options['min_chars'])) {
+ $passes[] = 2;
+ }
+
+ if (!$passes) {
+ return array();
+ }
+
+ // We want about half of the suggestions from each enabled method.
+ $limit = $query->getOption('limit', 10);
+ $limit /= count($passes);
+ $limit = ceil($limit);
+
+ // Also collect all keywords already contained in the query so we don't
+ // suggest them.
+ $keys = backdrop_map_assoc(preg_split('/[^\p{L}\p{N}]+/u', $user_input, -1, PREG_SPLIT_NO_EMPTY));
+ if ($incomplete_key) {
+ $keys[$incomplete_key] = $incomplete_key;
+ }
+
+ foreach ($passes as $pass) {
+ if ($pass == 2 && $incomplete_key) {
+ $query->keys($user_input);
+ }
+ // To avoid suggesting incomplete words, we have to temporarily disable
+ // the "partial_matches" option. (There should be no way we'll save the
+ // server during the createDbQuery() call, so this should be safe.)
+ $options = $this->options;
+ $this->options['partial_matches'] = FALSE;
+ $db_query = $this->createDbQuery($query, $fields);
+ $this->options = $options;
+
+ // Add additional tags and metadata.
+ $db_query->addTag('search_api_db_autocomplete');
+ $db_query->addMetaData('search_api_db_autocomplete', array(
+ 'search' => $search,
+ 'incomplete_key' => $incomplete_key,
+ 'user_input' => $user_input,
+ 'pass' => $pass,
+ ));
+
+ $text_fields = array();
+ foreach ($query->getFields() as $field) {
+ if (isset($fields[$field]) && search_api_is_text_type($fields[$field]['type'])) {
+ $text_fields[] = $field;
+ }
+ }
+ if (empty($text_fields)) {
+ return array();
+ }
+
+ // For each text field that will be searched, store the item IDs in a
+ // temporary table. This is unfortunately necessary since MySQL doesn't
+ // allow using a temporary table multiple times in a single query.
+ $all_results = array();
+ $total = NULL;
+ $first_temp_table = TRUE;
+ foreach ($text_fields as $field) {
+ $table = $this->getTemporaryResultsTable($db_query);
+ if (!$table) {
+ return array();
+ }
+ if ($first_temp_table) {
+ // For subsequent temporary tables, just use a plain SELECT over the
+ // first to fill them, instead of the (potentially very complex)
+ // search query.
+ $first_temp_table = FALSE;
+ $db_query = $this->connection->select($table)
+ ->fields($table, array('item_id'));
+ }
+ $all_results[$field] = $this->connection->select($table, 't')
+ ->fields('t', array('item_id'));
+ if ($total === NULL) {
+ $total = $this->connection->query("SELECT COUNT(item_id) FROM {{$table}}")->fetchField();
+ }
+ }
+ $max_occurrences = max(1, floor($total * config_get('search_api_db.settings', 'autocomplete_max_occurrences')));
+
+ if (!$total) {
+ if ($pass == 1) {
+ return NULL;
+ }
+ continue;
+ }
+
+ $word_query = NULL;
+ foreach ($text_fields as $field) {
+ $field_query = $this->connection->select($fields[$field]['table'], 't')
+ ->fields('t', array('word', 'item_id'))
+ ->condition('item_id', $all_results[$field], 'IN')
+ ->condition('field_name', $this->getTextFieldName($field));
+ if ($pass == 1) {
+ $field_query->condition('word', $incomplete_like, 'LIKE')
+ ->condition('word', $keys, 'NOT IN');
+ }
+ if (!isset($word_query)) {
+ $word_query = $field_query;
+ }
+ else {
+ $word_query->union($field_query);
+ }
+ }
+ if (!$word_query) {
+ return array();
+ }
+ $db_query = $this->connection->select($word_query, 't');
+ $db_query->addExpression('COUNT(DISTINCT item_id)', 'results');
+ $db_query->fields('t', array('word'))
+ ->groupBy('word')
+ ->having('COUNT(DISTINCT item_id) <= :max', array(':max' => $max_occurrences))
+ ->orderBy('results', 'DESC')
+ ->range(0, $limit);
+ $incomp_len = strlen($incomplete_key);
+ foreach ($db_query->execute() as $row) {
+ $suffix = ($pass == 1) ? substr($row->word, $incomp_len) : ' ' . $row->word;
+ $suggestions[] = array(
+ 'suggestion_suffix' => $suffix,
+ 'results' => $row->results,
+ );
+ }
+ }
+
+ return $suggestions;
+ }
+
+ /**
+ * Retrieves the internal field information.
+ *
+ * @param SearchApiIndex $index
+ * The index whose fields should be retrieved.
+ *
+ * @return array $fields
+ * An array of arrays. The outer array is keyed by field name. Each value
+ * is an associative array with information on the field.
+ */
+ protected function getFieldInfo(SearchApiIndex $index) {
+ $fields = $this->options['indexes'][$index->machine_name];
+ foreach ($fields as $key => $field) {
+ // Legacy fields do not have column set.
+ if (!isset($field['column'])) {
+ $fields[$key]['column'] = search_api_is_text_type($field['type']) ? 'word' : 'value';
+ }
+ }
+ return $fields;
+ }
+
+ /**
+ * Emulates self::mbStrcut() if that is not available.
+ *
+ * Though the Mbstring PHP extension is recommended for running Backdrop, it is
+ * not required. Therefore, we have to wrap calls to its functions.
+ *
+ * @param string $str
+ * The string being cut.
+ * @param int $start
+ * Starting position in bytes.
+ * @param int|null $length
+ * (optional) Length in bytes. If NULL is passed, extract all bytes to the
+ * end of the string.
+ *
+ * @return string
+ * The portion of $str specified by the $start and $length parameters.
+ */
+ protected static function mbStrcut($str, $start, $length = NULL) {
+ global $multibyte;
+ if ($multibyte == UNICODE_MULTIBYTE) {
+ return mb_strcut($str, $start, $length);
+ }
+ return substr($str, $start, $length);
+ }
+
+ /**
+ * Determines whether the "Tokenizer" processor is enabled for an index.
+ *
+ * @param SearchApiIndex $index
+ * The index to check.
+ *
+ * @return bool
+ * TRUE if the built-in "Tokenizer" processor is enabled on the given index,
+ * FALSE otherwise.
+ */
+ protected static function isTokenizerActive(SearchApiIndex $index) {
+ return !empty($index->options['processors']['search_api_tokenizer']['status']);
+ }
+
+}
diff --git a/www/themes/backdropcms/css/components-views.css b/www/themes/backdropcms/css/components-views.css
index e1507743e..4b3d45cf1 100644
--- a/www/themes/backdropcms/css/components-views.css
+++ b/www/themes/backdropcms/css/components-views.css
@@ -35,7 +35,8 @@
/*******************************************************************************
* Projects view
******************************************************************************/
-.view-modules ol {
+.view-modules ol,
+.view-project-listings ol {
margin-left: 0 ;
list-style-type: none;
}
diff --git a/www/themes/backdropcms/css/page-project-search.css b/www/themes/backdropcms/css/page-project-search.css
index 0364e7bf6..eee0330b7 100644
--- a/www/themes/backdropcms/css/page-project-search.css
+++ b/www/themes/backdropcms/css/page-project-search.css
@@ -12,14 +12,16 @@
padding: 0;
}
-.view-modules h2 {
+.view-modules h2,
+.view-project-listings h2 {
margin-top: 0;
color: #929597;
font-size: 24px;
line-height: 32px;
}
@media (min-width: 30em) { /* 480px */
- .view-modules h2 {
+ .view-modules h2,
+ .view-project-listings h2 {
font-size: 30px;
line-height: 36px;
}
@@ -68,12 +70,18 @@
.block-views--exp-modules-modules,
.block-views--exp-modules-themes,
-.block-views--exp-modules-layouts {
+.block-views--exp-modules-layouts,
+.block-views--exp-project-listings-modules,
+.block-views--exp-project-listings-themes,
+.block-views--exp-project-listings-layouts {
width: 100%;
}
.block-views--exp-modules-modules .block-content,
.block-views--exp-modules-themes .block-content,
-.block-views--exp-modules-layouts .block-content {
+.block-views--exp-modules-layouts .block-content,
+.block-views--exp-project-listings-modules .block-content,
+.block-views--exp-project-listings-themes .block-content,
+.block-views--exp-project-listings-layouts .block-content {
max-width: 750px;
}
@@ -85,11 +93,15 @@ form.views-exposed-form,
#views-exposed-form-modules-modules,
#views-exposed-form-modules-themes,
-#views-exposed-form-modules-layouts {
+#views-exposed-form-modules-layouts,
+#views-exposed-form-project-listings-modules,
+#views-exposed-form-project-listings-themes,
+#views-exposed-form-project-listings-layouts {
border: 0;
padding: 0;
}
+.views-exposed-form .views-widget-filter-search_api_views_fulltext,
.views-exposed-form .views-widget-filter-combine,
.views-exposed-form .views-widget-filter-title,
.views-exposed-form .views-widget-filter-keys,
@@ -100,6 +112,7 @@ form.views-exposed-form,
line-height: 1.5;
}
+.views-exposed-form .views-widget-filter-search_api_views_fulltext,
.views-exposed-form .views-widget-filter-combine,
.views-exposed-form .views-widget-filter-title,
.views-exposed-form .views-widget-filter-keys {
@@ -229,6 +242,11 @@ form.views-exposed-form,
padding: 0;
}
+.result__info li.rendered-entity {
+ width: 100%;
+ max-width: 100%;
+}
+
.result__info li {
margin: 0 5px 0 0;
display: inline-block;
@@ -237,10 +255,6 @@ form.views-exposed-form,
margin: 0;
}
-.result__info a {
- color: #00a1d3;
-}
-
/* @todo delete me */
.result__info .view-node a {
font-size: 16px;
diff --git a/www/themes/backdropcms/template.php b/www/themes/backdropcms/template.php
index 1dd32dd6b..5b353d16c 100644
--- a/www/themes/backdropcms/template.php
+++ b/www/themes/backdropcms/template.php
@@ -40,7 +40,7 @@ function backdropcms_preprocess_page(&$variables) {
$icons_needed[] = 'arrow-circle-down-fill';
}
}
- elseif ($arg0 == 'modules' || $arg0 == 'themes' || $arg0 == 'layouts') {
+ elseif(in_array($arg0, array('modules', 'themes', 'layouts', 'modules2', 'themes2', 'layouts2'))) {
$variables['classes'][] = 'project-search';
backdrop_add_css($path . '/css/page-project-search.css');
}
@@ -465,4 +465,4 @@ function backdropcms_field__body__docs($variables) {
function backdropcms_system_powered_by() {
return '' . t('Backdrop CMS and the Backdrop logo are registered trademarks of the Softrware Freedom conservancy.', array('@url' => 'https://backdropcms.org')) . '';
}
-*/
\ No newline at end of file
+*/