Changeset 3684724
- Timestamp:
- 09/07/2026 11:19:00 AM (7 weeks ago)
- Location:
- wbs-connect-for-dataverse/trunk
- Files:
-
- 16 added
- 13 edited
-
assets/blocks-editor.css (added)
-
assets/blocks.js (added)
-
assets/wbs-connect-for-dataverse-forms.css (added)
-
assets/wbs-connect-for-dataverse.css (modified) (1 diff)
-
assets/wbs-connect-for-dataverse.js (modified) (3 diffs)
-
blocks (added)
-
blocks/collection (added)
-
blocks/collection/block.json (added)
-
blocks/form (added)
-
blocks/form/block.json (added)
-
blocks/record (added)
-
blocks/record/block.json (added)
-
includes/blocks.php (added)
-
includes/bridge-wbs-forms.php (modified) (1 diff)
-
includes/collections.php (modified) (13 diffs)
-
includes/dataverse.php (modified) (7 diffs)
-
includes/forms.php (modified) (5 diffs)
-
includes/media.php (added)
-
includes/metadata.php (added)
-
includes/rest.php (modified) (6 diffs)
-
includes/shortcodes.php (modified) (7 diffs)
-
includes/webhooks.php (added)
-
readme.txt (modified) (7 diffs)
-
templates/collection-pagination.php (added)
-
templates/collection-toolbar.php (added)
-
templates/form.php (modified) (2 diffs)
-
templates/record-detail.php (modified) (2 diffs)
-
uninstall.php (modified) (1 diff)
-
wbs-connect-for-dataverse.php (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
wbs-connect-for-dataverse/trunk/assets/wbs-connect-for-dataverse.css
r3620392 r3684724 84 84 .dvc-form__status--error { color: #a02222; } 85 85 86 .dvc-collection { margin: 1rem 0; } 87 88 .dvc-toolbar { 89 display: flex; 90 flex-wrap: wrap; 91 align-items: flex-end; 92 gap: 0.75rem 1rem; 93 margin: 0 0 1.25rem; 94 padding: 0.9rem 1rem; 95 background: #f6f6f6; 96 border: 1px solid #e0e0e0; 97 border-radius: 8px; 98 } 99 .dvc-toolbar__field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 10rem; flex: 1 1 10rem; } 100 .dvc-toolbar__field--search { flex: 2 1 14rem; } 101 .dvc-toolbar__label { font-size: 0.85rem; font-weight: 500; color: #555; } 102 .dvc-toolbar__input, 103 .dvc-toolbar__select { 104 width: 100%; 105 padding: 0.5rem 0.65rem; 106 border: 1px solid #ccc; 107 border-radius: 6px; 108 font: inherit; 109 background: #fff; 110 box-sizing: border-box; 111 } 112 .dvc-toolbar__input:focus, 113 .dvc-toolbar__select:focus { outline: 2px solid #333; outline-offset: 1px; } 114 .dvc-toolbar__actions { display: flex; align-items: center; gap: 0.75rem; flex: 0 0 auto; } 115 .dvc-toolbar__submit { 116 background: #141414; 117 color: #fff; 118 border: 0; 119 border-radius: 6px; 120 padding: 0.55rem 1.2rem; 121 font: inherit; 122 font-weight: 600; 123 cursor: pointer; 124 } 125 .dvc-toolbar__submit:hover { background: #333; } 126 .dvc-toolbar__reset { font-size: 0.9rem; color: #555; } 127 .dvc-toolbar__count { flex: 1 0 100%; margin: 0; font-size: 0.85rem; color: #666; } 128 @media (max-width: 560px) { 129 .dvc-toolbar__field, 130 .dvc-toolbar__field--search { flex-basis: 100%; } 131 .dvc-toolbar__actions { width: 100%; } 132 } 133 134 .dvc-pagination { 135 display: flex; 136 align-items: center; 137 justify-content: center; 138 gap: 1.25rem; 139 margin: 1.5rem 0 1rem; 140 font-size: 0.95rem; 141 } 142 .dvc-pagination__link { 143 padding: 0.45rem 1rem; 144 border: 1px solid #ccc; 145 border-radius: 6px; 146 color: inherit; 147 text-decoration: none; 148 background: #fff; 149 } 150 .dvc-pagination__link:hover { border-color: #333; } 151 .dvc-pagination__link--disabled { opacity: 0.45; } 152 .dvc-pagination__page { color: #666; } 153 154 .dvc-files { list-style: none; margin: 0; padding: 0; } 155 .dvc-files__item { padding: 0.45rem 0; border-bottom: 1px solid #ececec; } 156 .dvc-files__item:last-child { border-bottom: 0; } 157 .dvc-files__link { font-weight: 500; } 158 .dvc-files__label { margin-left: 0.6rem; color: #666; font-size: 0.9em; } 159 86 160 .dvc-notice { 87 161 padding: 0.9rem 1.1rem; -
wbs-connect-for-dataverse/trunk/assets/wbs-connect-for-dataverse.js
r3620392 r3684724 2 2 (function () { 3 3 'use strict'; 4 5 /** 6 * Serialize the form. Names ending in [] become arrays (multi-select 7 * choices); the empty sentinel of an untouched group is dropped. A 8 * checkbox preceded by a hidden "0" input sends "1" or "0", never nothing. 9 */ 10 function collect(form) { 11 var data = {}; 12 new FormData(form).forEach(function (v, k) { 13 if (k.slice(-2) === '[]') { 14 var key = k.slice(0, -2); 15 if (!data[key]) { 16 data[key] = []; 17 } 18 if (v !== '') { 19 data[key].push(v); 20 } 21 return; 22 } 23 data[k] = v; 24 }); 25 var consent = form.querySelector('input[name="consent"]'); 26 if (consent) { 27 data.consent = consent.checked ? 1 : 0; 28 } 29 return data; 30 } 31 32 function resetTurnstile(form) { 33 var widget = form.querySelector('.cf-turnstile'); 34 if (widget && window.turnstile && typeof window.turnstile.reset === 'function') { 35 try { 36 window.turnstile.reset(widget); 37 } catch (e) { 38 // The widget is optional; a failed reset only means a fresh page load is needed. 39 } 40 } 41 } 4 42 5 43 function init(form) { … … 10 48 } 11 49 var submitLabel = button.textContent; 50 var updateMode = form.getAttribute('data-dvc-mode') === 'update'; 51 52 function fail(message) { 53 button.disabled = false; 54 button.textContent = submitLabel; 55 status.textContent = message || form.getAttribute('data-dvc-error'); 56 status.className = 'dvc-form__status dvc-form__status--error'; 57 resetTurnstile(form); 58 } 12 59 13 60 form.addEventListener('submit', function (e) { 14 61 e.preventDefault(); 15 var data = {};16 new FormData(form).forEach(function (v, k) { data[k] = v; });17 var consent = form.querySelector('input[name="consent"]');18 if (consent) {19 data.consent = consent.checked ? 1 : 0;20 }21 62 button.disabled = true; 22 63 button.textContent = form.getAttribute('data-dvc-sending') || submitLabel; 64 status.textContent = ''; 65 status.className = 'dvc-form__status'; 23 66 fetch(form.getAttribute('data-dvc-rest') || form.action, { 24 67 method: 'POST', … … 27 70 'X-WP-Nonce': form.getAttribute('data-dvc-nonce') || '' 28 71 }, 29 body: JSON.stringify( data)72 body: JSON.stringify(collect(form)) 30 73 }).then(function (r) { 31 74 return r.json().then(function (j) { return { ok: r.ok, body: j }; }); 32 75 }).then(function (res) { 33 76 if (res.ok && res.body && res.body.ok) { 34 form.querySelectorAll('input, textarea, button').forEach(function (el) { el.disabled = true; }); 77 if (res.body.redirect) { 78 window.location.assign(res.body.redirect); 79 return; 80 } 81 if (updateMode) { 82 button.disabled = false; 83 button.textContent = submitLabel; 84 resetTurnstile(form); 85 } else { 86 form.querySelectorAll('input, textarea, select, button').forEach(function (el) { el.disabled = true; }); 87 } 35 88 status.textContent = res.body.message || ''; 36 89 status.className = 'dvc-form__status dvc-form__status--ok'; 37 90 } else { 38 button.disabled = false; 39 button.textContent = submitLabel; 40 status.textContent = (res.body && res.body.message) ? res.body.message : form.getAttribute('data-dvc-error'); 41 status.className = 'dvc-form__status dvc-form__status--error'; 91 fail(res.body && res.body.message ? res.body.message : ''); 42 92 } 43 93 }).catch(function () { 44 button.disabled = false; 45 button.textContent = submitLabel; 46 status.textContent = form.getAttribute('data-dvc-error'); 47 status.className = 'dvc-form__status dvc-form__status--error'; 94 fail(''); 48 95 }); 49 96 }); -
wbs-connect-for-dataverse/trunk/includes/bridge-wbs-forms.php
r3684633 r3684724 150 150 wbs_dataverse_connect_review_note_success(); 151 151 152 /** 153 * Fires after a record was created from a form submission. 154 * 155 * @param string $key Form or bridge map key. 156 * @param string $entity_set Entity set the record was created in. 157 * @param string $id GUID of the new record, '' when unknown. 158 * @param array $record The payload that was posted. 159 */ 160 do_action('wbs_dataverse_connect_record_created', 'bridge:' . $slug, $entity_set, is_array($result) ? (string) ($result['id'] ?? '') : '', $record); 161 152 162 // Mark this form's submission as captured for wbs_forms_submission_handled. 153 163 $GLOBALS['wbs_dataverse_connect_bridge_handled'][(int) $form_id] = true; -
wbs-connect-for-dataverse/trunk/includes/collections.php
r3624546 r3684724 15 15 * status_filter optional OData filter, e.g. statecode eq 0 16 16 * order_by optional OData orderby, default createdon desc 17 * search_columns columns the visitor search box matches; empty hides it 18 * filter_columns columns offered as filter selects 19 * sort_options "column asc|desc" choices offered to visitors; empty 20 * keeps the fixed order_by 21 * per_page records per page, 1-100, default 12 22 * cache_ttl seconds this collection's reads stay cached, '' for 23 * the site default, 0 for no caching 24 * 25 * Visitor state (search term, filters, sort, page) travels in the GET vars 26 * dvc_q, dvc_f[<column>], dvc_sort and dvc_page. Only columns and sort 27 * options whitelisted in the config are honored; anything else is 28 * ignored silently. Paging follows @odata.nextLink because Dataverse has 29 * no $skip: the links of visited pages are cached per state so a later 30 * page resolves without re-walking from the start. One request follows 31 * at most WBS_DATAVERSE_CONNECT_PAGE_HOPS links, and only states drawn 32 * from a finite value space (no search term, filters on choice, status 33 * and yes/no columns only) are cached at all; everything else is read 34 * live so visitors cannot grow the transient table without bound. 17 35 * 18 36 * Collections registered in code via the wbs_dataverse_connect_collections … … 20 38 * merged over them, so the admin can override a code-registered key. 21 39 */ 40 41 const WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT = 12; 42 const WBS_DATAVERSE_CONNECT_PER_PAGE_MAX = 100; 43 const WBS_DATAVERSE_CONNECT_SEARCH_MAXLEN = 100; 44 const WBS_DATAVERSE_CONNECT_PAGE_MAX = 1000; 45 const WBS_DATAVERSE_CONNECT_PAGE_HOPS = 10; 46 const WBS_DATAVERSE_CONNECT_METADATA_RETRY = 300; 22 47 23 48 /** All collections, keyed by collection key. */ … … 61 86 62 87 /** 88 * A list of logical names from an array or a comma / newline separated 89 * string. Empty entries and duplicates are dropped, order is kept. 90 */ 91 function wbs_dataverse_connect_logical_list($value) { 92 if (is_string($value)) { 93 $value = preg_split('/[\r\n,]+/', $value); 94 } 95 if (!is_array($value)) { 96 return []; 97 } 98 $out = []; 99 foreach ($value as $item) { 100 if (!is_scalar($item)) { 101 continue; 102 } 103 $name = wbs_dataverse_connect_logical_name(trim((string) $item)); 104 if ($name !== '' && !in_array($name, $out, true)) { 105 $out[] = $name; 106 } 107 } 108 return $out; 109 } 110 111 /** 112 * The first $length characters of a string, cut on character boundaries 113 * so a multibyte character is never split into invalid UTF-8. 114 */ 115 function wbs_dataverse_connect_truncate($value, $length) { 116 $value = (string) $value; 117 if (function_exists('mb_substr')) { 118 return mb_substr($value, 0, (int) $length, 'UTF-8'); 119 } 120 $cut = preg_replace('/^(.{0,' . (int) $length . '}).*$/su', '$1', $value); 121 return $cut === null ? substr($value, 0, (int) $length) : $cut; 122 } 123 124 /** 125 * One sort option, "column asc" or "column desc", from loose input such 126 * as "Createdon DESC". Missing direction means ascending; '' when the 127 * column is missing. 128 */ 129 function wbs_dataverse_connect_sort_option($value) { 130 $parts = preg_split('/\s+/', strtolower(trim((string) $value))); 131 $column = wbs_dataverse_connect_logical_name($parts[0] ?? ''); 132 if ($column === '') { 133 return ''; 134 } 135 $direction = (($parts[1] ?? 'asc') === 'desc') ? 'desc' : 'asc'; 136 return $column . ' ' . $direction; 137 } 138 139 /** Sort options list from an array or a comma / newline separated string. */ 140 function wbs_dataverse_connect_sort_list($value) { 141 if (is_string($value)) { 142 $value = preg_split('/[\r\n,]+/', $value); 143 } 144 if (!is_array($value)) { 145 return []; 146 } 147 $out = []; 148 foreach ($value as $item) { 149 if (!is_scalar($item)) { 150 continue; 151 } 152 $option = wbs_dataverse_connect_sort_option($item); 153 if ($option !== '' && !in_array($option, $out, true)) { 154 $out[] = $option; 155 } 156 } 157 return $out; 158 } 159 160 /** Records per page clamped to 1-100; anything below 1 means $default. */ 161 function wbs_dataverse_connect_per_page($value, $default = WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT) { 162 $value = (int) $value; 163 if ($value < 1) { 164 $value = (int) $default; 165 } 166 return max(1, min(WBS_DATAVERSE_CONNECT_PER_PAGE_MAX, $value)); 167 } 168 169 /** Cache lifetime setting: '' keeps the site default, otherwise seconds >= 0. */ 170 function wbs_dataverse_connect_normalize_cache_ttl($value) { 171 if ($value === '' || $value === null || $value === false) { 172 return ''; 173 } 174 if (is_string($value) && trim($value) === '') { 175 return ''; 176 } 177 return max(0, (int) $value); 178 } 179 180 /** 63 181 * Validate and normalize one collection config. Returns the clean config 64 182 * array or null when a required field is missing. … … 67 185 if (!is_array($config)) { 68 186 return null; 69 }70 71 $columns = [];72 if (isset($config['columns']) && is_array($config['columns'])) {73 foreach ($config['columns'] as $column) {74 $column = wbs_dataverse_connect_logical_name($column);75 if ($column !== '') {76 $columns[] = $column;77 }78 }79 187 } 80 188 … … 85 193 'name_column' => wbs_dataverse_connect_logical_name($config['name_column'] ?? ''), 86 194 'slug_column' => wbs_dataverse_connect_logical_name($config['slug_column'] ?? ''), 87 'columns' => array_values(array_unique($columns)),195 'columns' => wbs_dataverse_connect_logical_list($config['columns'] ?? []), 88 196 'image_url_column' => wbs_dataverse_connect_logical_name($config['image_url_column'] ?? ''), 89 197 'status_filter' => sanitize_text_field($config['status_filter'] ?? ''), 90 198 'order_by' => sanitize_text_field($config['order_by'] ?? ''), 199 'search_columns' => wbs_dataverse_connect_logical_list($config['search_columns'] ?? []), 200 'filter_columns' => wbs_dataverse_connect_logical_list($config['filter_columns'] ?? []), 201 'sort_options' => wbs_dataverse_connect_sort_list($config['sort_options'] ?? []), 202 'per_page' => wbs_dataverse_connect_per_page($config['per_page'] ?? 0), 203 'cache_ttl' => wbs_dataverse_connect_normalize_cache_ttl($config['cache_ttl'] ?? ''), 91 204 ]; 92 205 … … 111 224 } 112 225 113 /** $select list for a collection: id, name, slug, image and the configured columns. */ 226 /** 227 * Name of a column in $select and in the rows Dataverse returns: lookup, 228 * customer and owner columns are exposed as _<column>_value (the bare 229 * logical name is a navigation property $select refuses); every other 230 * column keeps its name. Without metadata the name is kept as is. 231 */ 232 function wbs_dataverse_connect_value_column($config, $column) { 233 $type = wbs_dataverse_connect_column_type($config, $column); 234 if (in_array($type, ['Lookup', 'Customer', 'Owner'], true)) { 235 return '_' . $column . '_value'; 236 } 237 return $column; 238 } 239 240 /** 241 * Key under which a row carries a column: the name itself when present, 242 * otherwise the _<column>_value form lookups arrive in. 243 */ 244 function wbs_dataverse_connect_record_key($record, $column) { 245 $record = (array) $record; 246 if (!array_key_exists($column, $record) && array_key_exists('_' . $column . '_value', $record)) { 247 return '_' . $column . '_value'; 248 } 249 return $column; 250 } 251 252 /** 253 * $select list for a collection: id, name, slug, image, the configured 254 * columns and the filter columns (their page values feed the filter 255 * selects of columns without metadata options). Lookups are selected 256 * through their _value property. 257 */ 114 258 function wbs_dataverse_connect_collection_select($config) { 115 259 $columns = array_merge( 116 260 [wbs_dataverse_connect_id_column($config), $config['name_column']], 117 $config['columns'] 261 $config['columns'], 262 (array) ($config['filter_columns'] ?? []) 118 263 ); 119 264 if ($config['slug_column'] !== '') { … … 123 268 $columns[] = $config['image_url_column']; 124 269 } 125 return implode(',', array_unique($columns)); 270 $out = []; 271 foreach (array_unique($columns) as $column) { 272 $out[] = wbs_dataverse_connect_value_column($config, $column); 273 } 274 return implode(',', array_unique($out)); 275 } 276 277 /** Client context of a collection read. */ 278 function wbs_dataverse_connect_collection_context($config) { 279 return ['type' => 'collection', 'config' => $config]; 280 } 281 282 /** Cache lifetime of a collection's reads: null for the site default. */ 283 function wbs_dataverse_connect_collection_ttl($config) { 284 $ttl = $config['cache_ttl'] ?? ''; 285 return ($ttl === '' || $ttl === null) ? null : max(0, (int) $ttl); 286 } 287 288 /** 289 * Attribute metadata of a collection's table, memoized per request so a 290 * grid of many cards asks the metadata module once. A failed fetch is 291 * remembered per connection and table for WBS_DATAVERSE_CONNECT_METADATA_RETRY 292 * seconds so an unavailable EntityDefinitions endpoint costs one failed 293 * request per window, not one per page view. [] when metadata is 294 * unavailable; the callers fall back to their heuristics. 295 */ 296 function wbs_dataverse_connect_collection_attributes($config) { 297 static $memo = []; 298 299 $table = (string) ($config['table'] ?? ''); 300 if ($table === '') { 301 return []; 302 } 303 $key = $table . '|' . (string) ($config['key'] ?? ''); 304 if (!array_key_exists($key, $memo)) { 305 $context = ['type' => 'metadata', 'config' => $config]; 306 $conn = wbs_dataverse_connect_active_connection($context); 307 $failed = 'wbsdvc_metaerr' . wbs_dataverse_connect_cache_ver() . '_' . md5((string) ($conn['env_url'] ?? '') . '|' . $table); 308 if (get_transient($failed) !== false) { 309 $memo[$key] = []; 310 return $memo[$key]; 311 } 312 $attrs = wbs_dataverse_connect_metadata_attributes($table, $context); 313 if (is_array($attrs) && !is_wp_error($attrs)) { 314 $memo[$key] = $attrs; 315 } else { 316 $memo[$key] = []; 317 set_transient($failed, 1, WBS_DATAVERSE_CONNECT_METADATA_RETRY); 318 } 319 } 320 return $memo[$key]; 321 } 322 323 /** AttributeType of one column, '' when metadata is unavailable. */ 324 function wbs_dataverse_connect_column_type($config, $column) { 325 $attrs = wbs_dataverse_connect_collection_attributes($config); 326 return isset($attrs[$column]['type']) ? (string) $attrs[$column]['type'] : ''; 327 } 328 329 /** 330 * Columns a collection already exposes through its config: the name 331 * column, the displayed columns, the filter columns and the column of 332 * the admin's order_by. Shortcode attributes may narrow the controls to 333 * these but never reach beyond them, so an author placing a shortcode 334 * cannot publish a column the admin did not whitelist. 335 */ 336 function wbs_dataverse_connect_exposed_columns($config) { 337 $columns = array_merge( 338 [$config['name_column']], 339 (array) ($config['columns'] ?? []), 340 (array) ($config['filter_columns'] ?? []) 341 ); 342 $order = wbs_dataverse_connect_sort_option($config['order_by'] ?? ''); 343 if ($order !== '') { 344 $columns[] = explode(' ', $order)[0]; 345 } 346 return array_values(array_unique(array_filter($columns, 'strlen'))); 347 } 348 349 /** 350 * Apply the [wbsdvc_collection] attributes that reshape the config: 351 * search ("1"/"0"), filters ("0" or a column list), sort ("0" or a list 352 * of "column asc|desc"), per_page. Filter and sort columns named by the 353 * shortcode are honored only when the config already exposes them (see 354 * wbs_dataverse_connect_exposed_columns; sort also accepts the columns 355 * of the configured sort options); the rest is dropped silently. 356 * Returns the adjusted config; the stored one is untouched. 357 */ 358 function wbs_dataverse_connect_collection_overrides($config, $args) { 359 $exposed = wbs_dataverse_connect_exposed_columns($config); 360 361 $search = trim((string) ($args['search'] ?? '')); 362 if ($search === '0') { 363 $config['search_columns'] = []; 364 } elseif ($search === '1' && empty($config['search_columns'])) { 365 $config['search_columns'] = [$config['name_column']]; 366 } 367 368 $filters = trim((string) ($args['filters'] ?? '')); 369 if ($filters === '0') { 370 $config['filter_columns'] = []; 371 } elseif ($filters !== '' && $filters !== '1') { 372 $config['filter_columns'] = array_values(array_intersect(wbs_dataverse_connect_logical_list($filters), $exposed)); 373 } 374 375 $sort = trim((string) ($args['sort'] ?? '')); 376 if ($sort === '0') { 377 $config['sort_options'] = []; 378 } elseif ($sort !== '' && $sort !== '1') { 379 $sortable = $exposed; 380 foreach ((array) ($config['sort_options'] ?? []) as $option) { 381 $sortable[] = explode(' ', $option)[0]; 382 } 383 $options = []; 384 foreach (wbs_dataverse_connect_sort_list($sort) as $option) { 385 if (in_array(explode(' ', $option)[0], $sortable, true)) { 386 $options[] = $option; 387 } 388 } 389 $config['sort_options'] = $options; 390 } 391 392 if ((int) ($args['per_page'] ?? 0) > 0) { 393 $config['per_page'] = wbs_dataverse_connect_per_page($args['per_page']); 394 } 395 return $config; 396 } 397 398 /** 399 * Visitor state of a collection view: q (search term), filters 400 * (column => value, whitelisted columns only), sort (a configured sort 401 * option or ''), page and per_page. $source defaults to the registered 402 * query vars; tests pass an array shaped like $_GET. 403 */ 404 function wbs_dataverse_connect_collection_state($config, $args = [], $source = null) { 405 if ($source === null) { 406 $source = [ 407 'dvc_q' => get_query_var('dvc_q', ''), 408 'dvc_f' => get_query_var('dvc_f', []), 409 'dvc_sort' => get_query_var('dvc_sort', ''), 410 'dvc_page' => get_query_var('dvc_page', ''), 411 ]; 412 } 413 414 $q = ''; 415 if (!empty($config['search_columns']) && isset($source['dvc_q']) && is_scalar($source['dvc_q'])) { 416 $q = trim(wbs_dataverse_connect_truncate(sanitize_text_field((string) $source['dvc_q']), WBS_DATAVERSE_CONNECT_SEARCH_MAXLEN)); 417 } 418 419 $filters = []; 420 $raw = isset($source['dvc_f']) && is_array($source['dvc_f']) ? $source['dvc_f'] : []; 421 foreach ((array) ($config['filter_columns'] ?? []) as $column) { 422 if (!isset($raw[$column]) || !is_scalar($raw[$column])) { 423 continue; 424 } 425 $value = trim(sanitize_text_field((string) $raw[$column])); 426 if ($value !== '') { 427 $filters[$column] = wbs_dataverse_connect_truncate($value, 200); 428 } 429 } 430 431 $sort = ''; 432 if (isset($source['dvc_sort']) && is_scalar($source['dvc_sort'])) { 433 $candidate = wbs_dataverse_connect_sort_option($source['dvc_sort']); 434 if ($candidate !== '' && in_array($candidate, (array) ($config['sort_options'] ?? []), true)) { 435 $sort = $candidate; 436 } 437 } 438 439 $page = isset($source['dvc_page']) && is_scalar($source['dvc_page']) ? (int) $source['dvc_page'] : 1; 440 441 return [ 442 'q' => $q, 443 'filters' => $filters, 444 'sort' => $sort, 445 'page' => max(1, min(WBS_DATAVERSE_CONNECT_PAGE_MAX, $page)), 446 'per_page' => wbs_dataverse_connect_per_page($args['per_page'] ?? 0, $config['per_page'] ?? WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT), 447 ]; 448 } 449 450 /** Whether the visitor narrowed or re-sorted the view. */ 451 function wbs_dataverse_connect_state_active($state) { 452 return ($state['q'] ?? '') !== '' || !empty($state['filters']) || ($state['sort'] ?? '') !== ''; 453 } 454 455 /** 456 * The GET args that reproduce a state (page excluded), ready for 457 * add_query_arg. Empty parts are left out so URLs stay short. 458 */ 459 function wbs_dataverse_connect_state_args($state) { 460 $args = []; 461 if (($state['q'] ?? '') !== '') { 462 $args['dvc_q'] = $state['q']; 463 } 464 if (!empty($state['filters'])) { 465 $args['dvc_f'] = $state['filters']; 466 } 467 if (($state['sort'] ?? '') !== '') { 468 $args['dvc_sort'] = $state['sort']; 469 } 470 return $args; 471 } 472 473 /** Lowercase GUID from loose input, '' when it is not one. */ 474 function wbs_dataverse_connect_guid($value) { 475 $guid = strtolower(preg_replace('/[^0-9a-fA-F\-]/', '', (string) $value)); 476 return strlen($guid) === 36 ? $guid : ''; 477 } 478 479 /** 480 * OData comparison for one filter value, typed by the column metadata: 481 * whole numbers for Picklist, State, Status and integers, plain numbers 482 * for Decimal, Double and Money, true/false for Boolean, GUIDs for lookups 483 * and identifiers, ContainValues for multi-select choices, a quoted 484 * string otherwise. Without metadata statecode and statuscode are treated 485 * as numbers and the literals true/false as booleans. '' means the value 486 * does not fit the column and the filter is skipped. 487 */ 488 function wbs_dataverse_connect_filter_expression($config, $column, $value) { 489 $value = trim((string) $value); 490 if ($value === '') { 491 return ''; 492 } 493 $type = wbs_dataverse_connect_column_type($config, $column); 494 if ($type === '') { 495 if (in_array($column, ['statecode', 'statuscode'], true)) { 496 $type = 'State'; 497 } elseif ($value === 'true' || $value === 'false') { 498 $type = 'Boolean'; 499 } 500 } 501 502 switch ($type) { 503 case 'Picklist': 504 case 'State': 505 case 'Status': 506 case 'Integer': 507 case 'BigInt': 508 return preg_match('/^-?\d+$/', $value) ? $column . ' eq ' . $value : ''; 509 case 'Decimal': 510 case 'Double': 511 case 'Money': 512 return preg_match('/^-?\d+(\.\d+)?$/', $value) ? $column . ' eq ' . $value : ''; 513 case 'Boolean': 514 if ($value === '1' || $value === 'true') { 515 return $column . ' eq true'; 516 } 517 if ($value === '0' || $value === 'false') { 518 return $column . ' eq false'; 519 } 520 return ''; 521 case 'Lookup': 522 case 'Customer': 523 case 'Owner': 524 $guid = wbs_dataverse_connect_guid($value); 525 return $guid !== '' ? '_' . $column . '_value eq ' . $guid : ''; 526 case 'Uniqueidentifier': 527 $guid = wbs_dataverse_connect_guid($value); 528 return $guid !== '' ? $column . ' eq ' . $guid : ''; 529 case 'MultiSelectPicklist': 530 return preg_match('/^-?\d+$/', $value) 531 ? "Microsoft.Dynamics.CRM.ContainValues(PropertyName='" . $column . "',PropertyValues=['" . $value . "'])" 532 : ''; 533 } 534 return $column . " eq '" . wbs_dataverse_connect_odata_str($value) . "'"; 535 } 536 537 /** 538 * Combined $filter of a state: the status filter first, then the search 539 * (contains() OR-joined over the search columns), then one comparison per 540 * filter value, all ANDed. '' when nothing applies. 541 */ 542 function wbs_dataverse_connect_collection_filter($config, $state) { 543 $parts = []; 544 if ($config['status_filter'] !== '') { 545 $parts[] = '(' . $config['status_filter'] . ')'; 546 } 547 548 $q = (string) ($state['q'] ?? ''); 549 if ($q !== '' && !empty($config['search_columns'])) { 550 $term = wbs_dataverse_connect_odata_str($q); 551 $contains = []; 552 foreach ($config['search_columns'] as $column) { 553 $contains[] = 'contains(' . $column . ",'" . $term . "')"; 554 } 555 $parts[] = '(' . implode(' or ', $contains) . ')'; 556 } 557 558 foreach ((array) ($state['filters'] ?? []) as $column => $value) { 559 if (!in_array($column, (array) ($config['filter_columns'] ?? []), true)) { 560 continue; 561 } 562 $expression = wbs_dataverse_connect_filter_expression($config, $column, $value); 563 if ($expression !== '') { 564 $parts[] = $expression; 565 } 566 } 567 $access = wbs_dataverse_connect_access_filter($config, 'list'); 568 if ($access !== '') { 569 $parts[] = '(' . $access . ')'; 570 } 571 return implode(' and ', $parts); 572 } 573 574 /** 575 * $orderby of a state: the chosen sort option, else order_by, else 576 * createdon desc. The primary key is appended as a tiebreaker unless the 577 * order already names it, so pages never overlap on equal values. 578 */ 579 function wbs_dataverse_connect_collection_order($config, $state) { 580 $order = (string) ($state['sort'] ?? ''); 581 if ($order === '') { 582 $order = $config['order_by'] !== '' ? $config['order_by'] : 'createdon desc'; 583 } 584 $id = wbs_dataverse_connect_id_column($config); 585 if (!preg_match('/(^|,)\s*' . preg_quote($id, '/') . '(\s|,|$)/', $order)) { 586 $order .= ',' . $id . ' asc'; 587 } 588 return $order; 589 } 590 591 /** 592 * OData path of the first page of a state: entity set, $select, $filter, 593 * $orderby, $top and $count. Later pages follow the nextLink Dataverse 594 * returns, so this is only ever built for page one. 595 */ 596 function wbs_dataverse_connect_collection_query($config, $state) { 597 $state = array_merge(['q' => '', 'filters' => [], 'sort' => '', 'page' => 1, 'per_page' => $config['per_page'] ?? WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT], (array) $state); 598 599 $path = $config['entity_set'] . '?$select=' . wbs_dataverse_connect_collection_select($config); 600 $filter = wbs_dataverse_connect_collection_filter($config, $state); 601 if ($filter !== '') { 602 $path .= '&$filter=' . rawurlencode($filter); 603 } 604 // No $top: Dataverse omits the nextLink once $top is satisfied, so a 605 // $top equal to the page size would make page two unreachable. The page 606 // size travels as Prefer: odata.maxpagesize (see _collection_page). 607 $path .= '&$orderby=' . rawurlencode(wbs_dataverse_connect_collection_order($config, $state)) 608 . '&$count=true'; 609 return $path; 610 } 611 612 /** 613 * Whether a state may be persisted (response cache and page links). 614 * Only states drawn from a finite value space are: no search term, and 615 * every filter either a yes/no column or a choice, state, status or 616 * multi-select column whose value is one of its metadata options. A 617 * search term or a filter on any other column (free text, numbers, 618 * lookups) is read live, so a crawler cycling values cannot fill the 619 * transient table. 620 */ 621 function wbs_dataverse_connect_state_cacheable($config, $state) { 622 if (($state['q'] ?? '') !== '') { 623 return false; 624 } 625 $attrs = wbs_dataverse_connect_collection_attributes($config); 626 foreach ((array) ($state['filters'] ?? []) as $column => $value) { 627 $type = isset($attrs[$column]['type']) ? (string) $attrs[$column]['type'] : ''; 628 if ($type === 'Boolean') { 629 continue; 630 } 631 if (in_array($type, ['Picklist', 'State', 'Status', 'MultiSelectPicklist'], true) 632 && !empty($attrs[$column]['options']) 633 && isset($attrs[$column]['options'][(string) $value])) { 634 continue; 635 } 636 return false; 637 } 638 return true; 639 } 640 641 /** Transient key holding the page links and count of one state. */ 642 function wbs_dataverse_connect_state_key($config, $state) { 643 $conn = wbs_dataverse_connect_active_connection(wbs_dataverse_connect_collection_context($config)); 644 return 'wbsdvc_p' . wbs_dataverse_connect_cache_ver() . '_' . md5( 645 (string) ($conn['env_url'] ?? '') . '|' . $config['key'] . '|' . wbs_dataverse_connect_collection_query($config, $state) 646 ); 647 } 648 649 /** 650 * Path relative to the api base from an absolute @odata.nextLink, '' 651 * when the link does not point at the Web API. 652 */ 653 function wbs_dataverse_connect_link_path($link) { 654 if (preg_match('#/api/data/v[0-9.]+/(.+)$#i', (string) $link, $m)) { 655 return $m[1]; 656 } 657 return ''; 658 } 659 660 /** Pages a result set spans by its count, or $fallback when the count is unknown or capped. */ 661 function wbs_dataverse_connect_page_limit($count, $per_page, $fallback) { 662 if ($count === null || (int) $count >= 5000) { 663 return $fallback; 664 } 665 return max(1, (int) ceil((int) $count / max(1, (int) $per_page))); 666 } 667 668 /** 669 * One page of a collection for a state. Page one is the query itself; 670 * page N follows the nextLinks, starting from the nearest link cached for 671 * this state. When fewer pages exist the last reachable page is returned 672 * (its 'page' tells which): the wanted page is clamped to the pages the 673 * count allows as soon as it is known, and one request follows at most 674 * WBS_DATAVERSE_CONNECT_PAGE_HOPS links, so a far page on an uncached 675 * state costs a bounded number of round trips. Returns 676 * ['records', 'page', 'per_page', 'has_prev', 'has_next', 'count'] or 677 * WP_Error; count is null until Dataverse reports it (it caps at 5000). 678 */ 679 function wbs_dataverse_connect_collection_page($config, $state) { 680 $per_page = wbs_dataverse_connect_per_page($state['per_page'] ?? 0, $config['per_page'] ?? WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT); 681 $want = max(1, (int) ($state['page'] ?? 1)); 682 683 $context = wbs_dataverse_connect_collection_context($config); 684 $context['headers'] = [ 685 'Prefer' => 'odata.include-annotations="OData.Community.Display.V1.FormattedValue",odata.maxpagesize=' . $per_page, 686 ]; 687 $ttl = wbs_dataverse_connect_collection_ttl($config); 688 $store_ttl = ($ttl === null) ? wbs_dataverse_connect_cache_ttl() : $ttl; 689 if (!wbs_dataverse_connect_state_cacheable($config, $state)) { 690 $ttl = 0; 691 $store_ttl = 0; 692 } 693 694 $links = []; 695 $count = null; 696 $key = wbs_dataverse_connect_state_key($config, $state); 697 if ($store_ttl > 0) { 698 $stored = get_transient($key); 699 if (is_array($stored)) { 700 $links = is_array($stored['links'] ?? null) ? $stored['links'] : []; 701 $count = isset($stored['count']) ? (int) $stored['count'] : null; 702 $want = min($want, wbs_dataverse_connect_page_limit($count, $per_page, $want)); 703 } 704 } 705 706 $page = 1; 707 $path = wbs_dataverse_connect_collection_query($config, $state); 708 for ($p = $want; $p >= 2; $p--) { 709 if (!empty($links[$p])) { 710 $page = $p; 711 $path = (string) $links[$p]; 712 break; 713 } 714 } 715 716 $records = []; 717 $next = ''; 718 $dirty = false; 719 $hops = 0; 720 while (true) { 721 $body = wbs_dataverse_connect_get($path, $ttl, $context); 722 if (is_wp_error($body)) { 723 return $body; 724 } 725 $hops++; 726 $records = (isset($body['value']) && is_array($body['value'])) ? $body['value'] : []; 727 if (isset($body['@odata.count']) && (int) $body['@odata.count'] !== $count) { 728 $count = (int) $body['@odata.count']; 729 $dirty = true; 730 } 731 $want = min($want, wbs_dataverse_connect_page_limit($count, $per_page, $want)); 732 $next = wbs_dataverse_connect_link_path($body['@odata.nextLink'] ?? ''); 733 if ($next !== '' && (($links[$page + 1] ?? '') !== $next)) { 734 $links[$page + 1] = $next; 735 $dirty = true; 736 } 737 if ($page >= $want || $next === '' || $hops >= WBS_DATAVERSE_CONNECT_PAGE_HOPS) { 738 break; 739 } 740 $page++; 741 $path = $next; 742 } 743 744 if ($dirty && $store_ttl > 0) { 745 set_transient($key, ['links' => $links, 'count' => $count], $store_ttl); 746 } 747 748 return [ 749 'records' => $records, 750 'page' => $page, 751 'per_page' => $per_page, 752 'has_prev' => $page > 1, 753 'has_next' => $next !== '', 754 'count' => $count, 755 ]; 126 756 } 127 757 128 758 /** 129 759 * Records of a collection, newest first unless order_by says otherwise. 130 * Returns an array of rows or WP_Error. 131 */ 132 function wbs_dataverse_connect_get_records($config, $limit = 12) { 133 $order_by = $config['order_by'] !== '' ? $config['order_by'] : 'createdon desc'; 134 135 $path = $config['entity_set'] 136 . '?$select=' . wbs_dataverse_connect_collection_select($config); 137 if ($config['status_filter'] !== '') { 138 $path .= '&$filter=' . rawurlencode($config['status_filter']); 139 } 140 $path .= '&$orderby=' . rawurlencode($order_by) 141 . '&$top=' . (int) $limit; 142 143 $result = wbs_dataverse_connect_get($path, null, ['type' => 'collection', 'config' => $config]); 144 if (is_wp_error($result)) { 145 return $result; 146 } 147 return isset($result['value']) && is_array($result['value']) ? $result['value'] : []; 760 * The first page of the default state, kept for callers of earlier 761 * versions. Returns an array of rows or WP_Error. 762 */ 763 function wbs_dataverse_connect_get_records($config, $limit = WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT) { 764 $result = wbs_dataverse_connect_collection_page($config, ['per_page' => $limit]); 765 return is_wp_error($result) ? $result : $result['records']; 148 766 } 149 767 … … 161 779 } 162 780 163 $guid = strtolower(preg_replace('/[^0-9a-fA-F\-]/', '', $slug)); 164 if (strlen($guid) !== 36) { 165 $guid = ''; 166 } 781 $guid = wbs_dataverse_connect_guid($slug); 167 782 168 783 if ($config['slug_column'] !== '') { … … 190 805 $filter = '(' . $config['status_filter'] . ') and ' . $filter; 191 806 } 807 $access = wbs_dataverse_connect_access_filter($config, 'single'); 808 if ($access !== '') { 809 $filter = '(' . $access . ') and ' . $filter; 810 } 192 811 193 812 $path = $config['entity_set'] … … 196 815 . '&$top=1'; 197 816 198 $result = wbs_dataverse_connect_get($path, null, ['type' => 'collection', 'config' => $config]);817 $result = wbs_dataverse_connect_get($path, wbs_dataverse_connect_collection_ttl($config), wbs_dataverse_connect_collection_context($config)); 199 818 if (is_wp_error($result)) { 200 819 return $result; … … 212 831 */ 213 832 function wbs_dataverse_connect_display_value($record, $column) { 214 $formatted = $record[$column . '@OData.Community.Display.V1.FormattedValue'] ?? null; 833 $key = wbs_dataverse_connect_record_key($record, $column); 834 $formatted = $record[$key . '@OData.Community.Display.V1.FormattedValue'] ?? null; 215 835 if (is_string($formatted) && $formatted !== '') { 216 836 return $formatted; 217 837 } 218 $value = $record[$ column] ?? '';838 $value = $record[$key] ?? ''; 219 839 if (is_bool($value)) { 220 840 return $value ? __('Yes', 'wbs-connect-for-dataverse') : __('No', 'wbs-connect-for-dataverse'); … … 224 844 225 845 /** 226 * Human label for a column: the table's publisher prefix is stripped, 227 * underscores become spaces, words are capitalized. wbs_locationtext on 228 * wbs_listing becomes "Locationtext". Override per column with the 229 * wbs_dataverse_connect_field_label filter. 846 * Human label for a column: the display name from the table metadata 847 * when available, otherwise a guess from the logical name (the table's 848 * publisher prefix is stripped, underscores become spaces, words are 849 * capitalized: wbs_locationtext on wbs_listing becomes "Locationtext"). 850 * Override per column with the wbs_dataverse_connect_field_label filter. 230 851 */ 231 852 function wbs_dataverse_connect_field_label($config, $column) { 232 $label = $column; 233 $pos = strpos($config['table'], '_'); 234 if ($pos !== false) { 235 $prefix = substr($config['table'], 0, $pos + 1); 236 if (strpos($label, $prefix) === 0) { 237 $label = substr($label, strlen($prefix)); 238 } 239 } 240 $label = ucwords(str_replace('_', ' ', $label)); 853 $attrs = wbs_dataverse_connect_collection_attributes($config); 854 $label = isset($attrs[$column]['label']) ? trim((string) $attrs[$column]['label']) : ''; 855 856 if ($label === '') { 857 $label = $column; 858 $pos = strpos((string) ($config['table'] ?? ''), '_'); 859 if ($pos !== false) { 860 $prefix = substr($config['table'], 0, $pos + 1); 861 if (strpos($label, $prefix) === 0) { 862 $label = substr($label, strlen($prefix)); 863 } 864 } 865 $label = ucwords(str_replace('_', ' ', $label)); 866 } 867 868 /** 869 * The label shown for a column in cards, detail tables and the toolbar. 870 * 871 * @param string $label Metadata display name or the heuristic label. 872 * @param string $column Column logical name. 873 * @param array $config The collection config. 874 */ 241 875 return apply_filters('wbs_dataverse_connect_field_label', $label, $column, $config); 876 } 877 878 /** 879 * Options of one filter select as value => label. Choice, state, status, 880 * yes/no and multi-select columns list every option from the metadata. 881 * Other columns list the distinct values found in $records, which is the 882 * current page only: a value that occurs on a later page is not offered 883 * until the visitor reaches it. Labels are the formatted display values; 884 * lookups are offered by id with the referenced record's name as label. 885 */ 886 function wbs_dataverse_connect_filter_options($config, $column, $records) { 887 $attrs = wbs_dataverse_connect_collection_attributes($config); 888 $type = isset($attrs[$column]['type']) ? (string) $attrs[$column]['type'] : ''; 889 if (in_array($type, ['Picklist', 'State', 'Status', 'Boolean', 'MultiSelectPicklist'], true) && !empty($attrs[$column]['options'])) { 890 $out = []; 891 foreach ((array) $attrs[$column]['options'] as $value => $label) { 892 $label = (string) $label; 893 $out[(string) $value] = $label !== '' ? $label : (string) $value; 894 } 895 return $out; 896 } 897 898 $out = []; 899 foreach ((array) $records as $record) { 900 if (!is_array($record)) { 901 continue; 902 } 903 $key = wbs_dataverse_connect_record_key($record, $column); 904 if (!isset($record[$key]) || !is_scalar($record[$key])) { 905 continue; 906 } 907 $raw = $record[$key]; 908 if (is_bool($raw)) { 909 $value = $raw ? 'true' : 'false'; 910 } else { 911 $value = trim((string) $raw); 912 } 913 if ($value === '' || isset($out[$value])) { 914 continue; 915 } 916 $label = wbs_dataverse_connect_display_value($record, $column); 917 $out[$value] = $label !== '' ? $label : $value; 918 } 919 natcasesort($out); 920 return $out; 921 } 922 923 /** Sort choices of a collection as option value => visitor label. */ 924 function wbs_dataverse_connect_sort_choices($config) { 925 $out = []; 926 foreach ((array) ($config['sort_options'] ?? []) as $option) { 927 $parts = explode(' ', $option); 928 $label = wbs_dataverse_connect_field_label($config, $parts[0]); 929 $out[$option] = (($parts[1] ?? 'asc') === 'desc') 930 /* translators: %s: column label. */ 931 ? sprintf(__('%s (descending)', 'wbs-connect-for-dataverse'), $label) 932 /* translators: %s: column label. */ 933 : sprintf(__('%s (ascending)', 'wbs-connect-for-dataverse'), $label); 934 } 935 return $out; 242 936 } 243 937 … … 293 987 return apply_filters('wbs_dataverse_connect_record_vm', $vm, $config, $record); 294 988 } 989 990 /** The visitor-state query vars WordPress should recognize. */ 991 function wbs_dataverse_connect_reading_query_vars($vars) { 992 foreach (['dvc_q', 'dvc_f', 'dvc_sort', 'dvc_page'] as $var) { 993 if (!in_array($var, $vars, true)) { 994 $vars[] = $var; 995 } 996 } 997 return $vars; 998 } 999 add_filter('query_vars', 'wbs_dataverse_connect_reading_query_vars'); 1000 1001 /** Extra rows of the collection edit form: search, filters, sort, paging, cache. */ 1002 function wbs_dataverse_connect_collection_reading_fields($editing) { 1003 $search = $editing !== null ? implode("\n", (array) ($editing['search_columns'] ?? [])) : ''; 1004 $filters = $editing !== null ? implode("\n", (array) ($editing['filter_columns'] ?? [])) : ''; 1005 $sorts = $editing !== null ? implode("\n", (array) ($editing['sort_options'] ?? [])) : ''; 1006 $per_page = $editing !== null ? (int) ($editing['per_page'] ?? WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT) : WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT; 1007 $ttl = $editing !== null ? ($editing['cache_ttl'] ?? '') : ''; 1008 ?> 1009 <tr> 1010 <th scope="row"><label for="dvc_search_columns"><?php esc_html_e('Search columns', 'wbs-connect-for-dataverse'); ?></label></th> 1011 <td><textarea id="dvc_search_columns" name="dvc_search_columns" class="regular-text" rows="3"><?php echo esc_textarea($search); ?></textarea> 1012 <p class="description"><?php esc_html_e('Text columns the visitor search box matches, one per line. Leave empty to hide the search box.', 'wbs-connect-for-dataverse'); ?></p></td> 1013 </tr> 1014 <tr> 1015 <th scope="row"><label for="dvc_filter_columns"><?php esc_html_e('Filter columns', 'wbs-connect-for-dataverse'); ?></label></th> 1016 <td><textarea id="dvc_filter_columns" name="dvc_filter_columns" class="regular-text" rows="3"><?php echo esc_textarea($filters); ?></textarea> 1017 <p class="description"><?php esc_html_e('Columns offered as filter dropdowns, one per line. Choice, status and yes/no columns list all their options; other columns list the values found on the current page only.', 'wbs-connect-for-dataverse'); ?></p></td> 1018 </tr> 1019 <tr> 1020 <th scope="row"><label for="dvc_sort_options"><?php esc_html_e('Sort options', 'wbs-connect-for-dataverse'); ?></label></th> 1021 <td><textarea id="dvc_sort_options" name="dvc_sort_options" class="regular-text" rows="3" placeholder="createdon desc"><?php echo esc_textarea($sorts); ?></textarea> 1022 <p class="description"><?php esc_html_e('Sort choices offered to visitors, one per line as column and direction, for example createdon desc. Leave empty to keep the fixed order.', 'wbs-connect-for-dataverse'); ?></p></td> 1023 </tr> 1024 <tr> 1025 <th scope="row"><label for="dvc_per_page"><?php esc_html_e('Records per page', 'wbs-connect-for-dataverse'); ?></label></th> 1026 <td><input type="number" id="dvc_per_page" name="dvc_per_page" class="small-text" min="1" max="<?php echo (int) WBS_DATAVERSE_CONNECT_PER_PAGE_MAX; ?>" 1027 value="<?php echo (int) $per_page; ?>"> 1028 <p class="description"><?php esc_html_e('1 to 100, default 12. A limit or per_page attribute on the shortcode takes precedence.', 'wbs-connect-for-dataverse'); ?></p></td> 1029 </tr> 1030 <tr> 1031 <th scope="row"><label for="dvc_cache_ttl"><?php esc_html_e('Cache lifetime (seconds)', 'wbs-connect-for-dataverse'); ?></label></th> 1032 <td><input type="number" id="dvc_cache_ttl" name="dvc_cache_ttl" class="small-text" min="0" 1033 value="<?php echo esc_attr((string) $ttl); ?>"> 1034 <p class="description"><?php esc_html_e('How long this collection stays cached. Leave empty for the site default, 0 to disable caching for this collection.', 'wbs-connect-for-dataverse'); ?></p></td> 1035 </tr> 1036 <?php 1037 } 1038 add_action('wbs_dataverse_connect_collection_fields', 'wbs_dataverse_connect_collection_reading_fields'); 1039 1040 /** Attach the posted reading rows to the collection config being saved. */ 1041 function wbs_dataverse_connect_save_collection_reading($config, $post) { 1042 $config['search_columns'] = wbs_dataverse_connect_logical_list($post['dvc_search_columns'] ?? ''); 1043 $config['filter_columns'] = wbs_dataverse_connect_logical_list($post['dvc_filter_columns'] ?? ''); 1044 $config['sort_options'] = wbs_dataverse_connect_sort_list($post['dvc_sort_options'] ?? ''); 1045 $config['per_page'] = wbs_dataverse_connect_per_page($post['dvc_per_page'] ?? 0); 1046 $config['cache_ttl'] = wbs_dataverse_connect_normalize_cache_ttl(is_scalar($post['dvc_cache_ttl'] ?? '') ? $post['dvc_cache_ttl'] : ''); 1047 return $config; 1048 } 1049 add_filter('wbs_dataverse_connect_save_collection', 'wbs_dataverse_connect_save_collection_reading', 10, 2); 1050 1051 /** 1052 * Extra OData restriction applied to every read of a collection, for 1053 * example "only records owned by the signed-in visitor". Empty by default; 1054 * add-ons return an expression that is ANDed with the status filter for 1055 * both the grid ($scope 'list') and single-record lookups ($scope 'single'), 1056 * so a direct link, a media request or a form bound to a hidden record all 1057 * see the same restriction. 1058 */ 1059 function wbs_dataverse_connect_access_filter($config, $scope) { 1060 /** 1061 * Restrict which records of a collection are readable at all. 1062 * 1063 * @param string $expression OData filter expression, '' for none. 1064 * @param array $config The collection config. 1065 * @param string $scope 'list' or 'single'. 1066 */ 1067 $expression = apply_filters('wbs_dataverse_connect_access_filter', '', $config, $scope); 1068 return is_string($expression) ? trim($expression) : ''; 1069 } 1070 1071 /** 1072 * Whether the current visitor may see a collection, a record, a form or a 1073 * media file at all. True unless an add-on says otherwise (for example a 1074 * collection that requires sign-in). $config is the collection or form 1075 * config, $scope one of 'collection', 'record', 'form', 'media'. 1076 */ 1077 function wbs_dataverse_connect_can_view($config, $scope) { 1078 /** 1079 * Gate a collection, record, form or media file for the current visitor. 1080 * 1081 * @param bool $allowed True to render, false to refuse. 1082 * @param array $config Collection or form config. 1083 * @param string $scope 'collection', 'record', 'form' or 'media'. 1084 */ 1085 return (bool) apply_filters('wbs_dataverse_connect_can_view', true, $config, $scope); 1086 } 1087 1088 /** Notice shown in place of gated content. Add-ons replace it with a sign-in prompt. */ 1089 function wbs_dataverse_connect_denied_notice($config, $scope) { 1090 $html = '<div class="dvc-notice dvc-notice--denied">' 1091 . esc_html__('Please sign in to view this content.', 'wbs-connect-for-dataverse') 1092 . '</div>'; 1093 /** 1094 * Markup rendered when wbs_dataverse_connect_can_view refused. 1095 * 1096 * @param string $html Default notice. 1097 * @param array $config Collection or form config. 1098 * @param string $scope 'collection', 'record' or 'form'. 1099 */ 1100 return apply_filters('wbs_dataverse_connect_denied_notice', $html, $config, $scope); 1101 } -
wbs-connect-for-dataverse/trunk/includes/dataverse.php
r3624546 r3684724 48 48 * wbs_dataverse_connect_active_connection). Sets Prefer so responses 49 49 * carry formatted-value annotations (choices, money, dates, lookups) 50 * alongside the raw values. Returns the decoded array or WP_Error. 50 * alongside the raw values; $context['headers'] (name => value) is merged 51 * over the request headers, which is how paging passes its Prefer. 52 * Metadata reads ($context['type'] 'metadata') do not touch the health 53 * state: they are optional and fall back to heuristics, so a failing 54 * EntityDefinitions endpoint next to working data reads must not raise 55 * and clear alerts in turn. Returns the decoded array or WP_Error. 51 56 */ 52 57 function wbs_dataverse_connect_get($path, $ttl = null, $context = []) { 53 $conn = wbs_dataverse_connect_active_connection($context); 58 $conn = wbs_dataverse_connect_active_connection($context); 59 $track = (($context['type'] ?? '') !== 'metadata'); 54 60 $ttl = ($ttl === null) ? wbs_dataverse_connect_cache_ttl() : (int) $ttl; 55 $key = 'wbsdvc_c' . wbs_dataverse_connect_cache_ver() . '_' . md5($conn['env_url'] . '|' . $path );61 $key = 'wbsdvc_c' . wbs_dataverse_connect_cache_ver() . '_' . md5($conn['env_url'] . '|' . $path . '|' . wp_json_encode($context['headers'] ?? [])); 56 62 57 63 if ($ttl > 0) { … … 69 75 $headers = wbs_dataverse_connect_default_headers($token); 70 76 $headers['Prefer'] = 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"'; 77 $headers = array_merge($headers, (array) ($context['headers'] ?? [])); 71 78 72 79 $resp = wp_remote_get(wbs_dataverse_connect_api_base($conn) . $path, [ … … 84 91 ) 85 92 ); 86 wbs_dataverse_connect_health_failure('network', $error->get_error_message()); 93 if ($track) { 94 wbs_dataverse_connect_health_failure('network', $error->get_error_message()); 95 } 87 96 return $error; 88 97 } … … 97 106 if ($code >= 400 || !is_array($body)) { 98 107 $detail = wbs_dataverse_connect_error_detail($body, $code); 99 if ( wbs_dataverse_connect_health_relevant_status($code)) {108 if ($track && wbs_dataverse_connect_health_relevant_status($code)) { 100 109 wbs_dataverse_connect_health_failure('api', $detail); 101 110 } … … 103 112 } 104 113 105 wbs_dataverse_connect_health_success(); 114 if ($track) { 115 wbs_dataverse_connect_health_success(); 116 } 106 117 if ($ttl > 0) { 107 118 set_transient($key, $body, $ttl); … … 118 129 */ 119 130 function wbs_dataverse_connect_post($path, $body, $context = []) { 131 return wbs_dataverse_connect_write('POST', $path, $body, $context); 132 } 133 134 /** 135 * Uncached PATCH (update) of one record, $path being the addressed 136 * record such as "contacts(guid)". Same contract as 137 * wbs_dataverse_connect_post: ['id' => guid] on success, WP_Error 138 * otherwise. Callers that must never create through an upsert pass 139 * ['headers' => ['If-Match' => '*']] in $context. 140 */ 141 function wbs_dataverse_connect_patch($path, $body, $context = []) { 142 return wbs_dataverse_connect_write('PATCH', $path, $body, $context); 143 } 144 145 /** Shared body of the two write methods. Never cached. */ 146 function wbs_dataverse_connect_write($method, $path, $body, $context = []) { 120 147 $conn = wbs_dataverse_connect_active_connection($context); 121 148 $where = (($context['type'] ?? '') === 'form' && isset($context['config']['key'])) … … 130 157 $headers = wbs_dataverse_connect_default_headers($token); 131 158 $headers['Content-Type'] = 'application/json; charset=utf-8'; 132 133 $resp = wp_remote_post(wbs_dataverse_connect_api_base($conn) . $path, [ 159 $headers = array_merge($headers, (array) ($context['headers'] ?? [])); 160 161 $resp = wp_remote_request(wbs_dataverse_connect_api_base($conn) . $path, [ 162 'method' => $method, 134 163 'timeout' => 15, 135 164 'headers' => $headers, -
wbs-connect-for-dataverse/trunk/includes/forms.php
r3624546 r3684724 3 3 4 4 /** 5 * Forms: the generalized write path. A form is a named mapping from6 * submitted fields to columns of one Dataverse table:5 * Forms: the generalized write path. A form is a named, typed mapping 6 * from submitted fields to columns of one Dataverse table: 7 7 * 8 8 * key unique handle used in [wbsdvc_form name="..."] 9 * table logical name of the target table 10 * entity_set plural entity set name the create is POSTed to 11 * field_map submitted field name => column logical name 12 * required submitted field names that must be filled in 9 * table logical name of the target table (metadata source) 10 * entity_set plural entity set name the write goes to 11 * fields ordered list of typed fields, each 12 * name submitted field name 13 * column column logical name 14 * type text|email|phone|textarea|number|money|date| 15 * datetime|choice|multichoice|boolean|lookup|hidden 16 * label '' means metadata label, then a prettified name 17 * required bool; metadata can add to it, never remove 18 * options value => label for choice/multichoice when 19 * metadata is unavailable or should be narrowed 20 * lookup_table, lookup_entity_set, lookup_name_column, 21 * lookup_nav for lookup fields (resolve-by-name and bind) 22 * default value of a hidden field, initial value otherwise 23 * field_map derived view: submitted field name => column logical name 24 * required derived view: submitted field names that must be filled in 13 25 * require_consent whether the consent checkbox is shown and enforced 14 26 * relate_to optional ['nav' => lookup navigation property (schema 15 27 * name casing), 'collection' => collection key] binding a 16 * submission to the record it came from 28 * created record to the record the form was shown on 29 * mode create (POST a new record) or update (PATCH the record 30 * the page is bound to via ?dvc=<slug> or the shortcode) 31 * bind_to collection key the update mode resolves and checks the 32 * record through; falls back to relate_to's collection 33 * success_message message shown after a successful write, '' = default 34 * redirect_url same-host URL the visitor is sent to after success 35 * turnstile whether Cloudflare Turnstile is required on this form 17 36 * 18 * v1 maps submitted values to text columns; the relate_to lookup bind is 19 * the one exception. Choices, booleans and other typed columns are left 20 * to the wbs_dataverse_connect_form_record filter. 37 * Configs saved before 1.5 only carry field_map and required; they are 38 * upgraded on normalization (every entry becomes a text field, email, 39 * phone and message keep their special types), so nothing changes for 40 * existing sites. 21 41 * 22 42 * Same merge order as collections: the wbs_dataverse_connect_forms filter 23 43 * first, the settings page over it. 24 */ 44 * 45 * Turnstile keys are site-wide options (wbs_dataverse_connect_turnstile_site_key 46 * and _secret_key). The plugin's own settings screen is not editable from 47 * this module, so the keys are entered inside the form editor rows and 48 * stored as options from the save hook; every form shares them. 49 */ 50 51 const WBS_DATAVERSE_CONNECT_LOOKUP_CAP = 200; 25 52 26 53 /** All forms, keyed by form key. */ … … 63 90 } 64 91 92 /** Field types a form can render and type, type => label. */ 93 function wbs_dataverse_connect_field_types() { 94 return [ 95 'text' => __('Text', 'wbs-connect-for-dataverse'), 96 'email' => __('Email', 'wbs-connect-for-dataverse'), 97 'phone' => __('Phone', 'wbs-connect-for-dataverse'), 98 'textarea' => __('Multi-line text', 'wbs-connect-for-dataverse'), 99 'number' => __('Number', 'wbs-connect-for-dataverse'), 100 'money' => __('Money', 'wbs-connect-for-dataverse'), 101 'date' => __('Date', 'wbs-connect-for-dataverse'), 102 'datetime' => __('Date and time', 'wbs-connect-for-dataverse'), 103 'choice' => __('Choice', 'wbs-connect-for-dataverse'), 104 'multichoice' => __('Multi-select choice', 'wbs-connect-for-dataverse'), 105 'boolean' => __('Yes/no', 'wbs-connect-for-dataverse'), 106 'lookup' => __('Lookup', 'wbs-connect-for-dataverse'), 107 'hidden' => __('Hidden (fixed value)', 'wbs-connect-for-dataverse'), 108 ]; 109 } 110 111 /** 112 * Parse "key = value" lines into an assoc array. A line without "=" is 113 * kept with an empty value so a bare option value still counts. 114 */ 115 function wbs_dataverse_connect_parse_pairs($text) { 116 $out = []; 117 foreach (preg_split('/[\r\n]+/', (string) $text) as $line) { 118 $line = trim($line); 119 if ($line === '') { 120 continue; 121 } 122 if (strpos($line, '=') === false) { 123 $out[$line] = ''; 124 continue; 125 } 126 list($k, $v) = array_map('trim', explode('=', $line, 2)); 127 if ($k !== '') { 128 $out[$k] = $v; 129 } 130 } 131 return $out; 132 } 133 134 /** 135 * Validate and normalize one field spec. Returns the clean field or null 136 * when name or column is missing. The field name defaults to the column. 137 */ 138 function wbs_dataverse_connect_normalize_field($raw) { 139 if (!is_array($raw)) { 140 return null; 141 } 142 $column = wbs_dataverse_connect_logical_name($raw['column'] ?? ''); 143 $name = sanitize_key($raw['name'] ?? ''); 144 if ($name === '') { 145 $name = sanitize_key($column); 146 } 147 if ($name === '' || $column === '') { 148 return null; 149 } 150 151 $type = sanitize_key($raw['type'] ?? 'text'); 152 if (!isset(wbs_dataverse_connect_field_types()[$type])) { 153 $type = 'text'; 154 } 155 156 $options = []; 157 if ($type === 'choice' || $type === 'multichoice') { 158 $source = $raw['options'] ?? []; 159 if (is_string($source)) { 160 $source = wbs_dataverse_connect_parse_pairs($source); 161 } 162 foreach ((array) $source as $value => $label) { 163 if (!is_scalar($value) || !is_numeric((string) $value)) { 164 continue; 165 } 166 $label = is_scalar($label) ? sanitize_text_field((string) $label) : ''; 167 $options[(int) $value] = $label !== '' ? $label : (string) (int) $value; 168 } 169 } 170 171 $lookup = ['lookup_table' => '', 'lookup_entity_set' => '', 'lookup_name_column' => '', 'lookup_nav' => '']; 172 if ($type === 'lookup') { 173 $source = $raw; 174 // The admin editor carries the lookup facts in the options textarea. 175 if (isset($raw['options']) && is_string($raw['options'])) { 176 $pairs = wbs_dataverse_connect_parse_pairs($raw['options']); 177 $source = array_merge($raw, [ 178 'lookup_table' => $pairs['table'] ?? ($raw['lookup_table'] ?? ''), 179 'lookup_entity_set' => $pairs['entity_set'] ?? ($raw['lookup_entity_set'] ?? ''), 180 'lookup_name_column' => $pairs['name_column'] ?? ($raw['lookup_name_column'] ?? ''), 181 'lookup_nav' => $pairs['nav'] ?? ($raw['lookup_nav'] ?? ''), 182 ]); 183 } 184 $lookup = [ 185 'lookup_table' => wbs_dataverse_connect_logical_name($source['lookup_table'] ?? ''), 186 'lookup_entity_set' => wbs_dataverse_connect_logical_name($source['lookup_entity_set'] ?? ''), 187 'lookup_name_column' => wbs_dataverse_connect_logical_name($source['lookup_name_column'] ?? ''), 188 'lookup_nav' => wbs_dataverse_connect_nav_name($source['lookup_nav'] ?? ''), 189 ]; 190 if ($lookup['lookup_nav'] === '') { 191 $lookup['lookup_nav'] = $column; 192 } 193 } 194 195 return array_merge([ 196 'name' => $name, 197 'column' => $column, 198 'type' => $type, 199 'label' => sanitize_text_field((string) ($raw['label'] ?? '')), 200 'required' => !empty($raw['required']), 201 'options' => $options, 202 'default' => sanitize_text_field((string) ($raw['default'] ?? '')), 203 ], $lookup); 204 } 205 206 /** Type of a legacy field-map entry, from its conventional name. */ 207 function wbs_dataverse_connect_legacy_field_type($field) { 208 switch ($field) { 209 case 'email': 210 return 'email'; 211 case 'phone': 212 return 'phone'; 213 case 'message': 214 return 'textarea'; 215 } 216 return 'text'; 217 } 218 219 /** 220 * A same-host redirect target. Accepts an absolute URL on the site's own 221 * host or a path starting with "/". Returns the absolute URL or ''. 222 */ 223 function wbs_dataverse_connect_form_redirect_url($raw) { 224 $raw = trim((string) $raw); 225 if ($raw === '') { 226 return ''; 227 } 228 if ($raw[0] === '/' && (strlen($raw) === 1 || $raw[1] !== '/')) { 229 $raw = home_url($raw); 230 } 231 $url = esc_url_raw($raw, ['http', 'https']); 232 $host = wp_parse_url($url, PHP_URL_HOST); 233 $home = wp_parse_url(home_url('/'), PHP_URL_HOST); 234 if ($url === '' || !is_string($host) || strcasecmp($host, (string) $home) !== 0) { 235 return ''; 236 } 237 return $url; 238 } 239 65 240 /** 66 241 * Validate and normalize one form config. Returns the clean config array … … 72 247 } 73 248 74 $field_map = []; 75 if (isset($config['field_map']) && is_array($config['field_map'])) { 76 foreach ($config['field_map'] as $field => $column) { 77 $field = sanitize_key($field); 78 $column = wbs_dataverse_connect_logical_name($column); 79 if ($field !== '' && $column !== '') { 80 $field_map[$field] = $column; 81 } 82 } 83 } 84 85 $required = []; 249 $legacy_required = []; 86 250 if (isset($config['required']) && is_array($config['required'])) { 87 251 foreach ($config['required'] as $field) { 88 $field = sanitize_key($field); 89 if ($field !== '' && isset($field_map[$field])) { 90 $required[] = $field; 91 } 252 $legacy_required[] = sanitize_key($field); 253 } 254 } 255 256 $fields = []; 257 if (isset($config['fields']) && is_array($config['fields'])) { 258 foreach ($config['fields'] as $name => $raw) { 259 if (is_array($raw) && !isset($raw['name']) && is_string($name)) { 260 $raw['name'] = $name; 261 } 262 $field = wbs_dataverse_connect_normalize_field($raw); 263 if ($field !== null && !isset($fields[$field['name']])) { 264 $fields[$field['name']] = $field; 265 } 266 } 267 } 268 // Pre-1.5 configs (and the legacy field map textarea) become typed fields. 269 if (isset($config['field_map']) && is_array($config['field_map'])) { 270 foreach ($config['field_map'] as $name => $column) { 271 $field = wbs_dataverse_connect_normalize_field([ 272 'name' => $name, 273 'column' => $column, 274 'type' => wbs_dataverse_connect_legacy_field_type(sanitize_key($name)), 275 'required' => in_array(sanitize_key($name), $legacy_required, true), 276 ]); 277 if ($field !== null && !isset($fields[$field['name']])) { 278 $fields[$field['name']] = $field; 279 } 280 } 281 } 282 283 $field_map = []; 284 $required = []; 285 foreach ($fields as $name => $field) { 286 $field_map[$name] = $field['column']; 287 if ($field['required']) { 288 $required[] = $name; 92 289 } 93 290 } … … 102 299 } 103 300 301 $bind_to = sanitize_key($config['bind_to'] ?? ''); 302 if ($bind_to === '' && $relate_to !== null) { 303 $bind_to = $relate_to['collection']; 304 } 305 $mode = (($config['mode'] ?? 'create') === 'update' && $bind_to !== '') ? 'update' : 'create'; 306 104 307 $clean = [ 105 308 'key' => sanitize_key(isset($config['key']) ? $config['key'] : $key), 106 309 'table' => wbs_dataverse_connect_logical_name($config['table'] ?? ''), 107 310 'entity_set' => wbs_dataverse_connect_logical_name($config['entity_set'] ?? ''), 311 'fields' => array_values($fields), 108 312 'field_map' => $field_map, 109 'required' => array_values(array_unique($required)),313 'required' => $required, 110 314 'require_consent' => !empty($config['require_consent']), 111 315 'relate_to' => $relate_to, 316 'mode' => $mode, 317 'bind_to' => $bind_to, 318 'success_message' => sanitize_text_field((string) ($config['success_message'] ?? '')), 319 'redirect_url' => wbs_dataverse_connect_form_redirect_url($config['redirect_url'] ?? ''), 320 'turnstile' => !empty($config['turnstile']), 112 321 ]; 113 322 114 if ($clean['key'] === '' || $clean['entity_set'] === '' || empty($clean['field _map'])) {323 if ($clean['key'] === '' || $clean['entity_set'] === '' || empty($clean['fields'])) { 115 324 return null; 116 325 } … … 144 353 return ucwords(str_replace(['_', '-'], ' ', $field)); 145 354 } 355 356 /** Client context of a form's reads and writes. */ 357 function wbs_dataverse_connect_form_context($config) { 358 return ['type' => 'form', 'config' => $config]; 359 } 360 361 /** 362 * Metadata facts of a field's column, or null when unavailable. The 363 * table's attribute map is fetched once per request and form: the client 364 * caches successful reads but not failures, and every field asks for its 365 * label, required level and options in turn, so without this memo an 366 * unreachable environment would cost several timeouts per field. 367 */ 368 function wbs_dataverse_connect_form_field_meta($config, $field) { 369 static $maps = []; 370 if (empty($config['table']) || !function_exists('wbs_dataverse_connect_metadata_attributes')) { 371 return null; 372 } 373 $key = $config['table'] . '|' . md5(serialize($config)); 374 if (!array_key_exists($key, $maps)) { 375 $attrs = wbs_dataverse_connect_metadata_attributes($config['table'], wbs_dataverse_connect_form_context($config)); 376 $maps[$key] = is_array($attrs) ? $attrs : []; 377 } 378 $column = wbs_dataverse_connect_logical_name($field['column']); 379 return isset($maps[$key][$column]) ? $maps[$key][$column] : null; 380 } 381 382 /** Display label of a field: admin label, metadata label, then the name heuristic. */ 383 function wbs_dataverse_connect_form_field_display_label($config, $field) { 384 if (($field['label'] ?? '') !== '') { 385 return $field['label']; 386 } 387 $meta = wbs_dataverse_connect_form_field_meta($config, $field); 388 if ($meta && $meta['label'] !== '') { 389 return $meta['label']; 390 } 391 return wbs_dataverse_connect_form_field_label($field['name']); 392 } 393 394 /** Whether a field must be filled: the admin flag, or the column's required level. */ 395 function wbs_dataverse_connect_form_field_required($config, $field) { 396 if ($field['required']) { 397 return true; 398 } 399 if ($field['type'] === 'hidden' || $field['type'] === 'boolean') { 400 return false; 401 } 402 $meta = wbs_dataverse_connect_form_field_meta($config, $field); 403 return $meta ? !empty($meta['required']) : false; 404 } 405 406 /** value => label options of a choice field: the admin's, else metadata's. */ 407 function wbs_dataverse_connect_form_field_options($config, $field) { 408 if (!empty($field['options'])) { 409 return $field['options']; 410 } 411 $meta = wbs_dataverse_connect_form_field_meta($config, $field); 412 return $meta && is_array($meta['options']) ? $meta['options'] : []; 413 } 414 415 /** The collection an update-mode form resolves its record through, or null. */ 416 function wbs_dataverse_connect_form_bind_collection($config) { 417 if (empty($config['bind_to'])) { 418 return null; 419 } 420 $collection = wbs_dataverse_connect_collection($config['bind_to']); 421 if ($collection === null || $collection['entity_set'] !== $config['entity_set']) { 422 return null; 423 } 424 return $collection; 425 } 426 427 /** A lowercased 36-char GUID, or '' when the input is not one. */ 428 function wbs_dataverse_connect_form_guid($raw) { 429 $guid = strtolower(preg_replace('/[^0-9a-fA-F\-]/', '', (string) $raw)); 430 return strlen($guid) === 36 ? $guid : ''; 431 } 432 433 /** Primary key column of a lookup field's target table, '' when unknown. */ 434 function wbs_dataverse_connect_form_lookup_id_column($field) { 435 $table = (string) ($field['lookup_table'] ?? ''); 436 return $table !== '' ? $table . 'id' : ''; 437 } 438 439 /** 440 * id => name rows of a lookup target for the select, capped at 441 * WBS_DATAVERSE_CONNECT_LOOKUP_CAP and cached like any read. Empty when 442 * the field lacks the lookup facts or the read fails. 443 */ 444 function wbs_dataverse_connect_form_lookup_options($field, $context = []) { 445 $set = (string) ($field['lookup_entity_set'] ?? ''); 446 $name = (string) ($field['lookup_name_column'] ?? ''); 447 $id = wbs_dataverse_connect_form_lookup_id_column($field); 448 if ($set === '' || $name === '' || $id === '') { 449 return []; 450 } 451 $path = $set 452 . '?$select=' . rawurlencode($id . ',' . $name) 453 . '&$orderby=' . rawurlencode($name . ' asc') 454 . '&$top=' . WBS_DATAVERSE_CONNECT_LOOKUP_CAP; 455 $body = wbs_dataverse_connect_get($path, null, $context); 456 if (is_wp_error($body) || empty($body['value']) || !is_array($body['value'])) { 457 return []; 458 } 459 $out = []; 460 foreach ($body['value'] as $row) { 461 $guid = wbs_dataverse_connect_form_guid($row[$id] ?? ''); 462 if ($guid === '') { 463 continue; 464 } 465 $label = isset($row[$name]) && is_scalar($row[$name]) ? (string) $row[$name] : ''; 466 $out[$guid] = $label !== '' ? $label : $guid; 467 if (count($out) >= WBS_DATAVERSE_CONNECT_LOOKUP_CAP) { 468 break; 469 } 470 } 471 return $out; 472 } 473 474 /** 475 * Resolve a lookup target's GUID by matching its name column, so a form 476 * can offer labels instead of ids. Cached read; '' when nothing matches. 477 */ 478 function wbs_dataverse_connect_form_resolve_lookup($field, $value, $context = []) { 479 $set = (string) ($field['lookup_entity_set'] ?? ''); 480 $name = (string) ($field['lookup_name_column'] ?? ''); 481 $id = wbs_dataverse_connect_form_lookup_id_column($field); 482 $value = trim((string) $value); 483 if ($set === '' || $name === '' || $id === '' || $value === '') { 484 return ''; 485 } 486 $path = $set 487 . '?$select=' . rawurlencode($id) 488 . '&$filter=' . rawurlencode($name . " eq '" . wbs_dataverse_connect_odata_str($value) . "'") 489 . '&$top=1'; 490 $body = wbs_dataverse_connect_get($path, null, $context); 491 if (is_wp_error($body) || empty($body['value'][0][$id])) { 492 return ''; 493 } 494 return wbs_dataverse_connect_form_guid($body['value'][0][$id]); 495 } 496 497 /** Interpret a checkbox or yes/no submission as a two-option value. */ 498 function wbs_dataverse_connect_form_truthy($raw) { 499 if (is_bool($raw)) { 500 return $raw; 501 } 502 if (is_array($raw)) { 503 return !empty($raw); 504 } 505 return in_array(strtolower(trim((string) $raw)), ['1', 'true', 'on', 'yes', 'ja', 'y'], true); 506 } 507 508 /** A submitted date as Y-m-d, '' when it does not parse. */ 509 function wbs_dataverse_connect_form_date($raw) { 510 $raw = trim((string) $raw); 511 if ($raw === '') { 512 return ''; 513 } 514 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})(T.*)?$/', $raw, $m)) { 515 return checkdate((int) $m[2], (int) $m[3], (int) $m[1]) ? $m[1] . '-' . $m[2] . '-' . $m[3] : ''; 516 } 517 $ts = strtotime($raw); 518 return $ts ? gmdate('Y-m-d', $ts) : ''; 519 } 520 521 /** 522 * A submitted date and time as ISO 8601 UTC. Values without an offset 523 * (the datetime-local input) are read in the site's timezone. 524 */ 525 function wbs_dataverse_connect_form_datetime($raw) { 526 $raw = trim((string) $raw); 527 if ($raw === '') { 528 return ''; 529 } 530 try { 531 $dt = new DateTime($raw, wp_timezone()); 532 } catch (Exception $e) { 533 return ''; 534 } 535 $dt->setTimezone(new DateTimeZone('UTC')); 536 return $dt->format('Y-m-d\TH:i:s\Z'); 537 } 538 539 /** 540 * Resolve a choice submission to its option value: an exact value in 541 * $options, a numeric passthrough (when options are unknown), then a 542 * case-insensitive label match. null when nothing resolves. 543 */ 544 function wbs_dataverse_connect_form_option($raw, $options) { 545 $raw = is_scalar($raw) ? trim((string) $raw) : ''; 546 if ($raw === '') { 547 return null; 548 } 549 $numeric = ctype_digit(ltrim($raw, '-')); 550 if ($numeric && (empty($options) || isset($options[(int) $raw]))) { 551 return (int) $raw; 552 } 553 foreach ($options as $value => $label) { 554 if (strcasecmp((string) $label, $raw) === 0) { 555 return (int) $value; 556 } 557 } 558 return null; 559 } 560 561 /** Validation error for a field, phrased for the visitor. */ 562 function wbs_dataverse_connect_form_field_error($code, $label) { 563 switch ($code) { 564 case 'required': 565 /* translators: %s: field label. */ 566 $message = sprintf(__('Please fill in "%s".', 'wbs-connect-for-dataverse'), $label); 567 break; 568 case 'email': 569 /* translators: %s: field label. */ 570 $message = sprintf(__('Please enter a valid email address for "%s".', 'wbs-connect-for-dataverse'), $label); 571 break; 572 case 'option': 573 /* translators: %s: field label. */ 574 $message = sprintf(__('Please choose one of the offered values for "%s".', 'wbs-connect-for-dataverse'), $label); 575 break; 576 default: 577 /* translators: %s: field label. */ 578 $message = sprintf(__('Please enter a valid value for "%s".', 'wbs-connect-for-dataverse'), $label); 579 } 580 return new WP_Error('wbs_dataverse_connect_validation', $message, ['field' => $code]); 581 } 582 583 /** 584 * Type one submitted value for the Web API. $field is a normalized field 585 * spec (see wbs_dataverse_connect_normalize_field), $raw the submitted 586 * value (scalar or array), $context the client context whose 'config' 587 * supplies the table for metadata. Returns: 588 * 589 * null nothing to write (empty submission) 590 * WP_Error the value is invalid; the message is visitor-facing 591 * mixed the typed value: string, int, float, bool, CSV of ints for 592 * multichoice, or the target GUID for a lookup (the caller 593 * builds the @odata.bind from lookup_entity_set and lookup_nav) 594 * 595 * Required-ness is not judged here; callers decide whether null is a 596 * problem. The WBS Forms bridge does its own typing today and could adopt 597 * this helper later. 598 */ 599 function wbs_dataverse_connect_typed_value($field, $raw, $context = []) { 600 $type = (string) ($field['type'] ?? 'text'); 601 $config = isset($context['config']) && is_array($context['config']) ? $context['config'] : null; 602 $label = $config ? wbs_dataverse_connect_form_field_display_label($config, $field) : wbs_dataverse_connect_form_field_label($field['name']); 603 604 if ($type !== 'multichoice' && is_array($raw)) { 605 $raw = reset($raw); 606 } 607 if ($type !== 'multichoice' && $type !== 'boolean') { 608 $raw = is_scalar($raw) ? trim((string) $raw) : ''; 609 if ($raw === '') { 610 return null; 611 } 612 } 613 614 switch ($type) { 615 case 'email': 616 $email = sanitize_email($raw); 617 if ($email === '' || !is_email($email)) { 618 return wbs_dataverse_connect_form_field_error('email', $label); 619 } 620 return $email; 621 622 case 'textarea': 623 return sanitize_textarea_field($raw); 624 625 case 'number': 626 case 'money': 627 $number = str_replace(' ', '', $raw); 628 if (strpos($number, '.') === false) { 629 $number = str_replace(',', '.', $number); 630 } 631 if (!is_numeric($number)) { 632 return wbs_dataverse_connect_form_field_error('invalid', $label); 633 } 634 if ($type === 'money') { 635 return (float) $number; 636 } 637 $meta = $config ? wbs_dataverse_connect_form_field_meta($config, $field) : null; 638 $integer = $meta ? in_array($meta['type'], ['Integer', 'BigInt'], true) : (preg_match('/^-?\d+$/', $number) === 1); 639 return $integer ? (int) round((float) $number) : (float) $number; 640 641 case 'date': 642 $date = wbs_dataverse_connect_form_date($raw); 643 return $date !== '' ? $date : wbs_dataverse_connect_form_field_error('invalid', $label); 644 645 case 'datetime': 646 $dt = wbs_dataverse_connect_form_datetime($raw); 647 return $dt !== '' ? $dt : wbs_dataverse_connect_form_field_error('invalid', $label); 648 649 case 'choice': 650 $options = $config ? wbs_dataverse_connect_form_field_options($config, $field) : (array) ($field['options'] ?? []); 651 $value = wbs_dataverse_connect_form_option($raw, $options); 652 return $value === null ? wbs_dataverse_connect_form_field_error('option', $label) : $value; 653 654 case 'multichoice': 655 $options = $config ? wbs_dataverse_connect_form_field_options($config, $field) : (array) ($field['options'] ?? []); 656 $items = is_array($raw) ? $raw : preg_split('/\s*,\s*/', (string) $raw); 657 $values = []; 658 foreach ((array) $items as $item) { 659 $item = is_scalar($item) ? trim((string) $item) : ''; 660 if ($item === '') { 661 continue; 662 } 663 $value = wbs_dataverse_connect_form_option($item, $options); 664 if ($value === null) { 665 return wbs_dataverse_connect_form_field_error('option', $label); 666 } 667 $values[] = $value; 668 } 669 if (empty($values)) { 670 return null; 671 } 672 return implode(',', array_values(array_unique($values))); 673 674 case 'boolean': 675 if (is_array($raw)) { 676 $raw = end($raw); 677 } 678 if (!is_bool($raw) && (!is_scalar($raw) || trim((string) $raw) === '')) { 679 return null; 680 } 681 return wbs_dataverse_connect_form_truthy($raw); 682 683 case 'lookup': 684 $guid = wbs_dataverse_connect_form_guid($raw); 685 if ($guid === '' && !empty($field['lookup_entity_set'])) { 686 $guid = wbs_dataverse_connect_form_resolve_lookup($field, $raw, $context); 687 } 688 return $guid !== '' ? $guid : wbs_dataverse_connect_form_field_error('option', $label); 689 690 case 'hidden': 691 // A fixed value still has to match the column's type when metadata knows it. 692 $meta = $config ? wbs_dataverse_connect_form_field_meta($config, $field) : null; 693 $mtype = $meta ? (string) $meta['type'] : ''; 694 if ($mtype === 'Boolean') { 695 return wbs_dataverse_connect_form_truthy($raw); 696 } 697 if (is_numeric($raw) && in_array($mtype, ['Picklist', 'State', 'Status', 'Integer', 'BigInt'], true)) { 698 return (int) $raw; 699 } 700 if (is_numeric($raw) && in_array($mtype, ['Decimal', 'Double', 'Money'], true)) { 701 return (float) $raw; 702 } 703 return sanitize_text_field($raw); 704 705 case 'text': 706 case 'phone': 707 default: 708 return sanitize_text_field($raw); 709 } 710 } 711 712 /** 713 * Fresh read of the record an update form edits, limited to the form's 714 * columns. Returns the row, null when it does not exist, or WP_Error. 715 */ 716 function wbs_dataverse_connect_form_load_record($config, $record_id) { 717 $guid = wbs_dataverse_connect_form_guid($record_id); 718 if ($guid === '') { 719 return null; 720 } 721 $select = []; 722 foreach ($config['fields'] as $field) { 723 $select[] = $field['type'] === 'lookup' ? '_' . $field['column'] . '_value' : $field['column']; 724 } 725 $path = $config['entity_set'] . '(' . $guid . ')?$select=' . rawurlencode(implode(',', array_unique($select))); 726 $row = wbs_dataverse_connect_get($path, 0, wbs_dataverse_connect_form_context($config)); 727 if (is_wp_error($row)) { 728 $status = (int) ($row->get_error_data()['status'] ?? 0); 729 return $status === 404 ? null : $row; 730 } 731 return $row; 732 } 733 734 /** 735 * The record an update form is bound to, checked through the bind 736 * collection so a hidden record cannot be edited by guessing its id. 737 * $record_id is a GUID or ''. Returns the GUID or '' when not visible. 738 */ 739 function wbs_dataverse_connect_form_bound_record($config, $record_id = '') { 740 $collection = wbs_dataverse_connect_form_bind_collection($config); 741 if ($collection === null) { 742 return ''; 743 } 744 $guid = wbs_dataverse_connect_form_guid($record_id); 745 if ($guid !== '') { 746 $row = wbs_dataverse_connect_query_single($collection, wbs_dataverse_connect_id_column($collection) . ' eq ' . $guid); 747 } else { 748 $slug = function_exists('get_query_var') ? (string) get_query_var('dvc', '') : ''; 749 $row = $slug !== '' ? wbs_dataverse_connect_get_record($collection, $slug) : null; 750 } 751 if (!is_array($row) || is_wp_error($row)) { 752 return ''; 753 } 754 return wbs_dataverse_connect_form_guid($row[wbs_dataverse_connect_id_column($collection)] ?? ''); 755 } 756 757 /** Initial input value of a field from an existing record, by type. */ 758 function wbs_dataverse_connect_form_prefill($field, $record) { 759 if ($field['type'] === 'hidden' || !is_array($record)) { 760 return $field['default']; 761 } 762 $column = $field['type'] === 'lookup' ? '_' . $field['column'] . '_value' : $field['column']; 763 if (!array_key_exists($column, $record) || $record[$column] === null) { 764 return ''; 765 } 766 $value = $record[$column]; 767 switch ($field['type']) { 768 case 'boolean': 769 return wbs_dataverse_connect_form_truthy($value) ? '1' : '0'; 770 case 'multichoice': 771 return array_map('intval', array_filter(explode(',', (string) $value), 'strlen')); 772 case 'choice': 773 return (string) (int) $value; 774 case 'date': 775 return substr((string) $value, 0, 10); 776 case 'datetime': 777 try { 778 $dt = new DateTime((string) $value, new DateTimeZone('UTC')); 779 } catch (Exception $e) { 780 return ''; 781 } 782 $dt->setTimezone(wp_timezone()); 783 return $dt->format('Y-m-d\TH:i'); 784 case 'lookup': 785 return wbs_dataverse_connect_form_guid($value); 786 } 787 return is_scalar($value) ? (string) $value : ''; 788 } 789 790 /** Turnstile site and secret key. */ 791 function wbs_dataverse_connect_turnstile_keys() { 792 return [ 793 'site_key' => trim((string) get_option('wbs_dataverse_connect_turnstile_site_key', '')), 794 'secret_key' => trim((string) get_option('wbs_dataverse_connect_turnstile_secret_key', '')), 795 ]; 796 } 797 798 /** Whether a form's Turnstile switch can take effect (both keys present). */ 799 function wbs_dataverse_connect_turnstile_active($config) { 800 if (empty($config['turnstile'])) { 801 return false; 802 } 803 $keys = wbs_dataverse_connect_turnstile_keys(); 804 return $keys['site_key'] !== '' && $keys['secret_key'] !== ''; 805 } 806 807 /** 808 * Verify a Turnstile token with Cloudflare's siteverify endpoint. True on 809 * success; false on a failed challenge or an unreachable endpoint. 810 */ 811 function wbs_dataverse_connect_turnstile_verify($token, $ip = '') { 812 $token = trim((string) $token); 813 $keys = wbs_dataverse_connect_turnstile_keys(); 814 if ($token === '' || $keys['secret_key'] === '') { 815 return false; 816 } 817 $body = ['secret' => $keys['secret_key'], 'response' => $token]; 818 if ($ip !== '') { 819 $body['remoteip'] = $ip; 820 } 821 // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Cloudflare Turnstile verification, only when a form has the challenge switched on; documented under External services. 822 $resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ 823 'timeout' => 10, 824 'body' => $body, 825 ]); 826 if (is_wp_error($resp)) { 827 return false; 828 } 829 $decoded = json_decode((string) wp_remote_retrieve_body($resp), true); 830 return is_array($decoded) && !empty($decoded['success']); 831 } 832 833 /** 834 * Everything templates/form.php prints, pre-resolved: fields with labels, 835 * options and initial values, the bound record in update mode, the 836 * Turnstile site key. Enqueues the form stylesheet and, when active, the 837 * Turnstile script. $record_id is the GUID the shortcode resolved, or ''. 838 * Returns ['error' => WP_Error] when the bound record cannot be read and 839 * ['config_error' => string] when the form is set up wrong. 840 */ 841 function wbs_dataverse_connect_form_view($config, $record_id = '') { 842 wp_enqueue_style('wbs-connect-for-dataverse-forms'); 843 844 $context = wbs_dataverse_connect_form_context($config); 845 $record = null; 846 $missing = false; 847 848 if ($config['mode'] === 'update') { 849 if (wbs_dataverse_connect_form_bind_collection($config) === null) { 850 return ['config_error' => sprintf( 851 /* translators: %s: form key. */ 852 __('Dataverse Connect: form "%s" is in update mode but its bind collection is missing or targets another table.', 'wbs-connect-for-dataverse'), 853 $config['key'] 854 )]; 855 } 856 $record_id = wbs_dataverse_connect_form_bound_record($config, $record_id); 857 $record = $record_id !== '' ? wbs_dataverse_connect_form_load_record($config, $record_id) : null; 858 if (is_wp_error($record)) { 859 return ['error' => $record]; 860 } 861 $missing = ($record === null); 862 } 863 864 $fields = []; 865 foreach ($config['fields'] as $field) { 866 $options = []; 867 if ($field['type'] === 'choice' || $field['type'] === 'multichoice') { 868 $options = wbs_dataverse_connect_form_field_options($config, $field); 869 } elseif ($field['type'] === 'lookup') { 870 $options = wbs_dataverse_connect_form_lookup_options($field, $context); 871 } 872 $fields[] = [ 873 'name' => $field['name'], 874 'type' => $field['type'], 875 'label' => wbs_dataverse_connect_form_field_display_label($config, $field), 876 'required' => wbs_dataverse_connect_form_field_required($config, $field), 877 'options' => $options, 878 'value' => wbs_dataverse_connect_form_prefill($field, $record), 879 ]; 880 } 881 882 $turnstile = wbs_dataverse_connect_turnstile_active($config); 883 if ($turnstile) { 884 wp_enqueue_script('wbs-connect-for-dataverse-turnstile'); 885 } 886 887 $view = [ 888 'uid' => 'dvc-form-' . substr(md5($config['key'] . $record_id . wp_rand()), 0, 8), 889 'mode' => $config['mode'], 890 'record_id' => $record_id, 891 'missing' => $missing, 892 'fields' => $fields, 893 'turnstile' => $turnstile ? wbs_dataverse_connect_turnstile_keys()['site_key'] : '', 894 'submit_label' => $config['mode'] === 'update' 895 ? __('Save changes', 'wbs-connect-for-dataverse') 896 : __('Send', 'wbs-connect-for-dataverse'), 897 ]; 898 899 /** 900 * The view model of a rendered form. 901 * 902 * @param array $view Fields, mode, record id, Turnstile site key. 903 * @param array $config The normalized form config. 904 * @param array|null $record The bound record in update mode. 905 */ 906 return apply_filters('wbs_dataverse_connect_form_view', $view, $config, $record); 907 } 908 909 /** Front-end assets of the typed forms; the base handles are registered in shortcodes.php. */ 910 function wbs_dataverse_connect_forms_register_assets() { 911 wp_register_style('wbs-connect-for-dataverse-forms', WBS_DATAVERSE_CONNECT_URL . 'assets/wbs-connect-for-dataverse-forms.css', ['wbs-connect-for-dataverse'], WBS_DATAVERSE_CONNECT_VERSION); 912 wp_register_script('wbs-connect-for-dataverse-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', [], null, true); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion,PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Cloudflare Turnstile widget, loaded only on pages whose form has the challenge switched on; documented under External services. 913 } 914 add_action('wp_enqueue_scripts', 'wbs_dataverse_connect_forms_register_assets'); 915 916 /** Turnstile key options; sanitized on every write path. */ 917 function wbs_dataverse_connect_forms_admin_init() { 918 register_setting('wbs_dataverse_connect_forms', 'wbs_dataverse_connect_turnstile_site_key', [ 919 'type' => 'string', 920 'sanitize_callback' => 'sanitize_text_field', 921 'default' => '', 922 ]); 923 register_setting('wbs_dataverse_connect_forms', 'wbs_dataverse_connect_turnstile_secret_key', [ 924 'type' => 'string', 925 'sanitize_callback' => 'wbs_dataverse_connect_sanitize_turnstile_secret', 926 'default' => '', 927 ]); 928 } 929 add_action('admin_init', 'wbs_dataverse_connect_forms_admin_init'); 930 931 /** The secret is never pre-filled; an empty submit keeps the stored one. */ 932 function wbs_dataverse_connect_sanitize_turnstile_secret($value) { 933 if (!is_string($value) || trim($value) === '') { 934 return (string) get_option('wbs_dataverse_connect_turnstile_secret_key', ''); 935 } 936 return trim($value); 937 } 938 939 /** Number of rows the field table shows: the existing fields plus room to grow. */ 940 function wbs_dataverse_connect_forms_row_count($editing) { 941 $count = ($editing !== null && !empty($editing['fields'])) ? count($editing['fields']) : 0; 942 return max(12, $count + 3); 943 } 944 945 /** The options textarea text of one field, mirroring how it is parsed. */ 946 function wbs_dataverse_connect_forms_options_text($field) { 947 $lines = []; 948 if ($field['type'] === 'lookup') { 949 $lines[] = 'table = ' . $field['lookup_table']; 950 $lines[] = 'entity_set = ' . $field['lookup_entity_set']; 951 $lines[] = 'name_column = ' . $field['lookup_name_column']; 952 $lines[] = 'nav = ' . $field['lookup_nav']; 953 } else { 954 foreach ((array) $field['options'] as $value => $label) { 955 $lines[] = $value . ' = ' . $label; 956 } 957 } 958 return implode("\n", $lines); 959 } 960 961 /** 962 * The 1.5 rows of the form editor: the typed field table, mode and bind 963 * collection, success message, redirect URL, Turnstile. Plain HTML, no 964 * script; more rows appear after saving. 965 */ 966 function wbs_dataverse_connect_forms_admin_rows($editing) { 967 $types = wbs_dataverse_connect_field_types(); 968 $rows = wbs_dataverse_connect_forms_row_count($editing); 969 $fields = ($editing !== null && !empty($editing['fields'])) ? $editing['fields'] : []; 970 $keys = wbs_dataverse_connect_turnstile_keys(); 971 $collections = wbs_dataverse_connect_collections(); 972 ?> 973 <tr> 974 <th scope="row"><?php esc_html_e('Fields', 'wbs-connect-for-dataverse'); ?></th> 975 <td> 976 <p class="description" style="margin-bottom:8px;"> 977 <?php esc_html_e('One row per input, in display order. Name is the submitted field name (defaults to the column), Column the logical name. Options: for choices one "value = label" per line (empty means the column\'s own choices); for lookups the lines "table = ...", "entity_set = ...", "name_column = ..." and "nav = <navigation property>". Default is the fixed value of a hidden field. Rows filled here take precedence over the field map above; the map still works for plain text forms.', 'wbs-connect-for-dataverse'); ?> 978 </p> 979 <table class="widefat striped dvc-admin-fields" style="max-width:1100px;"> 980 <thead> 981 <tr> 982 <th style="width:2em;">#</th> 983 <th><?php esc_html_e('Name', 'wbs-connect-for-dataverse'); ?></th> 984 <th><?php esc_html_e('Column', 'wbs-connect-for-dataverse'); ?></th> 985 <th><?php esc_html_e('Type', 'wbs-connect-for-dataverse'); ?></th> 986 <th><?php esc_html_e('Label', 'wbs-connect-for-dataverse'); ?></th> 987 <th><?php esc_html_e('Required', 'wbs-connect-for-dataverse'); ?></th> 988 <th><?php esc_html_e('Options', 'wbs-connect-for-dataverse'); ?></th> 989 <th><?php esc_html_e('Default', 'wbs-connect-for-dataverse'); ?></th> 990 </tr> 991 </thead> 992 <tbody> 993 <?php for ($i = 0; $i < $rows; $i++) : 994 $field = isset($fields[$i]) ? $fields[$i] : null; 995 $base = 'dvc_fields[' . $i . ']'; 996 ?> 997 <tr> 998 <td><?php echo (int) ($i + 1); ?></td> 999 <td><input type="text" name="<?php echo esc_attr($base . '[name]'); ?>" value="<?php echo esc_attr($field ? $field['name'] : ''); ?>" style="width:100%;"></td> 1000 <td><input type="text" name="<?php echo esc_attr($base . '[column]'); ?>" value="<?php echo esc_attr($field ? $field['column'] : ''); ?>" style="width:100%;"></td> 1001 <td> 1002 <select name="<?php echo esc_attr($base . '[type]'); ?>"> 1003 <?php foreach ($types as $type => $label) : ?> 1004 <option value="<?php echo esc_attr($type); ?>" <?php selected($field ? $field['type'] : 'text', $type); ?>><?php echo esc_html($label); ?></option> 1005 <?php endforeach; ?> 1006 </select> 1007 </td> 1008 <td><input type="text" name="<?php echo esc_attr($base . '[label]'); ?>" value="<?php echo esc_attr($field ? $field['label'] : ''); ?>" style="width:100%;"></td> 1009 <td style="text-align:center;"><input type="checkbox" name="<?php echo esc_attr($base . '[required]'); ?>" value="1" <?php checked($field && $field['required']); ?>></td> 1010 <td><textarea name="<?php echo esc_attr($base . '[options]'); ?>" rows="2" style="width:100%;"><?php echo esc_textarea($field ? wbs_dataverse_connect_forms_options_text($field) : ''); ?></textarea></td> 1011 <td><input type="text" name="<?php echo esc_attr($base . '[default]'); ?>" value="<?php echo esc_attr($field ? $field['default'] : ''); ?>" style="width:100%;"></td> 1012 </tr> 1013 <?php endfor; ?> 1014 </tbody> 1015 </table> 1016 </td> 1017 </tr> 1018 <tr> 1019 <th scope="row"><label for="dvc_mode"><?php esc_html_e('Mode', 'wbs-connect-for-dataverse'); ?></label></th> 1020 <td> 1021 <select id="dvc_mode" name="dvc_mode"> 1022 <option value="create" <?php selected($editing ? $editing['mode'] : 'create', 'create'); ?>><?php esc_html_e('Create a new record per submission', 'wbs-connect-for-dataverse'); ?></option> 1023 <option value="update" <?php selected($editing ? $editing['mode'] : 'create', 'update'); ?>><?php esc_html_e('Update the record the page is bound to', 'wbs-connect-for-dataverse'); ?></option> 1024 </select> 1025 <p class="description"><?php esc_html_e('Update mode prefills the inputs from the record selected by ?dvc=<slug> (or the record the form is attached to) and saves changes back with a PATCH. The application user needs Write on the table, and every visitor who can see the record can edit the mapped columns.', 'wbs-connect-for-dataverse'); ?></p> 1026 </td> 1027 </tr> 1028 <tr> 1029 <th scope="row"><label for="dvc_bind_to"><?php esc_html_e('Bind to collection', 'wbs-connect-for-dataverse'); ?></label></th> 1030 <td> 1031 <select id="dvc_bind_to" name="dvc_bind_to"> 1032 <option value=""><?php esc_html_e('(the "Relate to" collection)', 'wbs-connect-for-dataverse'); ?></option> 1033 <?php foreach ($collections as $key => $collection) : ?> 1034 <option value="<?php echo esc_attr($key); ?>" <?php selected($editing ? $editing['bind_to'] : '', $key); ?>><?php echo esc_html($key . ' (' . $collection['entity_set'] . ')'); ?></option> 1035 <?php endforeach; ?> 1036 </select> 1037 <p class="description"><?php esc_html_e('Collection of the same table whose status filter decides which records may be edited in update mode.', 'wbs-connect-for-dataverse'); ?></p> 1038 </td> 1039 </tr> 1040 <tr> 1041 <th scope="row"><label for="dvc_success_message"><?php esc_html_e('Success message', 'wbs-connect-for-dataverse'); ?></label></th> 1042 <td><input type="text" id="dvc_success_message" name="dvc_success_message" class="large-text" 1043 value="<?php echo esc_attr($editing ? $editing['success_message'] : ''); ?>"> 1044 <p class="description"><?php esc_html_e('Shown after a successful submission. Empty means the default thank-you text.', 'wbs-connect-for-dataverse'); ?></p></td> 1045 </tr> 1046 <tr> 1047 <th scope="row"><label for="dvc_redirect_url"><?php esc_html_e('Redirect after success', 'wbs-connect-for-dataverse'); ?></label></th> 1048 <td><input type="text" id="dvc_redirect_url" name="dvc_redirect_url" class="regular-text" 1049 value="<?php echo esc_attr($editing ? $editing['redirect_url'] : ''); ?>" placeholder="/thank-you"> 1050 <p class="description"><?php esc_html_e('Optional page on this site the visitor is sent to instead of the message. Other hosts are ignored.', 'wbs-connect-for-dataverse'); ?></p></td> 1051 </tr> 1052 <tr> 1053 <th scope="row"><?php esc_html_e('Cloudflare Turnstile', 'wbs-connect-for-dataverse'); ?></th> 1054 <td> 1055 <label> 1056 <input type="checkbox" name="dvc_turnstile" value="1" <?php checked($editing && $editing['turnstile']); ?>> 1057 <?php esc_html_e('Require a Turnstile challenge on this form.', 'wbs-connect-for-dataverse'); ?> 1058 </label> 1059 <p class="description"><?php esc_html_e('The keys below are shared by all forms on this site and are saved together with this form. Without both keys the challenge is skipped.', 'wbs-connect-for-dataverse'); ?></p> 1060 <p> 1061 <label for="dvc_turnstile_site_key"><?php esc_html_e('Site key', 'wbs-connect-for-dataverse'); ?></label><br> 1062 <input type="text" id="dvc_turnstile_site_key" name="dvc_turnstile_site_key" class="regular-text" autocomplete="off" 1063 value="<?php echo esc_attr($keys['site_key']); ?>"> 1064 </p> 1065 <p> 1066 <label for="dvc_turnstile_secret_key"><?php esc_html_e('Secret key', 'wbs-connect-for-dataverse'); ?></label><br> 1067 <input type="password" id="dvc_turnstile_secret_key" name="dvc_turnstile_secret_key" class="regular-text" autocomplete="off" value="" 1068 <?php if ($keys['secret_key'] !== '') : ?>placeholder="<?php esc_attr_e('Saved. Leave blank to keep the current secret.', 'wbs-connect-for-dataverse'); ?>"<?php endif; ?>> 1069 </p> 1070 </td> 1071 </tr> 1072 <?php 1073 } 1074 add_action('wbs_dataverse_connect_form_fields', 'wbs_dataverse_connect_forms_admin_rows'); 1075 1076 /** The posted field table rows, normalized, in order. */ 1077 function wbs_dataverse_connect_forms_posted_fields($post) { 1078 $fields = []; 1079 if (!isset($post['dvc_fields']) || !is_array($post['dvc_fields'])) { 1080 return $fields; 1081 } 1082 ksort($post['dvc_fields'], SORT_NUMERIC); 1083 foreach ($post['dvc_fields'] as $row) { 1084 $field = wbs_dataverse_connect_normalize_field($row); 1085 if ($field !== null && !isset($fields[$field['name']])) { 1086 $fields[$field['name']] = $field; 1087 } 1088 } 1089 return $fields; 1090 } 1091 1092 /** 1093 * Runs just before the plugin's own save handler. That handler builds the 1094 * config from the legacy field map textarea and rejects an empty map, so 1095 * the rows of the field table are folded into the map here; a form set up 1096 * entirely through the table then saves without touching the textarea. 1097 */ 1098 function wbs_dataverse_connect_forms_fold_fields() { 1099 if (!current_user_can('manage_options') || !isset($_POST['dvc_fields']) || !is_array($_POST['dvc_fields'])) { 1100 return; 1101 } 1102 if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'wbs_dataverse_connect_save_form')) { 1103 return; 1104 } 1105 $post = wp_unslash($_POST); 1106 $fields = wbs_dataverse_connect_forms_posted_fields($post); 1107 if (empty($fields)) { 1108 return; 1109 } 1110 $lines = []; 1111 foreach (preg_split('/[\r\n]+/', (string) ($post['dvc_field_map'] ?? '')) as $line) { 1112 $line = trim($line); 1113 if ($line !== '') { 1114 $lines[] = $line; 1115 } 1116 } 1117 foreach ($fields as $name => $field) { 1118 $lines[] = $name . ' = ' . $field['column']; 1119 } 1120 $_POST['dvc_field_map'] = wp_slash(implode("\n", array_unique($lines))); 1121 } 1122 add_action('admin_post_wbs_dataverse_connect_save_form', 'wbs_dataverse_connect_forms_fold_fields', 5); 1123 1124 /** 1125 * Attach the typed fields and the 1.5 options to the config being stored, 1126 * and store the site-wide Turnstile keys. 1127 */ 1128 function wbs_dataverse_connect_forms_save($config, $post) { 1129 $fields = wbs_dataverse_connect_forms_posted_fields($post); 1130 // Legacy map entries without a row keep working as upgraded text fields. 1131 foreach ($config['fields'] as $field) { 1132 if (!isset($fields[$field['name']])) { 1133 $fields[$field['name']] = $field; 1134 } 1135 } 1136 1137 $config['fields'] = array_values($fields); 1138 $config['mode'] = sanitize_key($post['dvc_mode'] ?? 'create'); 1139 $config['bind_to'] = sanitize_key($post['dvc_bind_to'] ?? ''); 1140 $config['success_message'] = sanitize_text_field((string) ($post['dvc_success_message'] ?? '')); 1141 $config['redirect_url'] = (string) ($post['dvc_redirect_url'] ?? ''); 1142 $config['turnstile'] = !empty($post['dvc_turnstile']); 1143 1144 $redirect = wbs_dataverse_connect_form_redirect_url($config['redirect_url']); 1145 $config['redirect_url'] = ($redirect !== '' && wp_http_validate_url($redirect)) ? $redirect : ''; 1146 1147 if (array_key_exists('dvc_turnstile_site_key', $post)) { 1148 update_option('wbs_dataverse_connect_turnstile_site_key', sanitize_text_field((string) $post['dvc_turnstile_site_key']), false); 1149 } 1150 if (array_key_exists('dvc_turnstile_secret_key', $post) && trim((string) $post['dvc_turnstile_secret_key']) !== '') { 1151 update_option('wbs_dataverse_connect_turnstile_secret_key', trim((string) $post['dvc_turnstile_secret_key']), false); 1152 } 1153 1154 // Re-run normalization so the stored config is exactly what runtime sees. 1155 $clean = wbs_dataverse_connect_normalize_form($config['key'], $config); 1156 return $clean !== null ? $clean : $config; 1157 } 1158 add_filter('wbs_dataverse_connect_save_form', 'wbs_dataverse_connect_forms_save', 10, 2); -
wbs-connect-for-dataverse/trunk/includes/rest.php
r3684633 r3684724 3 3 4 4 /** 5 * Submission endpoint: POST wbs-connect-for-dataverse/v1/submit creates a record 6 * in the Dataverse table mapped by the named form. Nonce-verified, 7 * honeypot-protected and rate limited per IP. 5 * Submission endpoint: POST wbs-connect-for-dataverse/v1/submit writes a 6 * record in the Dataverse table mapped by the named form: a create for 7 * forms in create mode, a PATCH of the bound record in update mode. 8 * Nonce-verified, honeypot-protected, rate limited per IP and, when the 9 * form asks for it, checked with Cloudflare Turnstile. Every value is 10 * typed and validated per field before it reaches the Web API. 8 11 */ 9 12 … … 21 24 } 22 25 26 /** A validation failure as the visitor sees it. */ 27 function wbs_dataverse_connect_rest_invalid($message) { 28 return new WP_REST_Response(['error' => 'validation_error', 'message' => $message], 400); 29 } 30 23 31 function wbs_dataverse_connect_handle_submit(WP_REST_Request $req) { 24 32 $nonce = $req->get_header('x_wp_nonce'); … … 48 56 return new WP_REST_Response(['error' => 'unknown_form'], 400); 49 57 } 50 51 // Only mapped fields are read from the payload, everything else is ignored. 52 $values = []; 53 foreach ($config['field_map'] as $field => $column) { 54 $raw = $payload[$field] ?? ''; 55 if (!is_scalar($raw)) { 56 $raw = ''; 57 } 58 $values[$field] = ($field === 'email') 59 ? sanitize_email((string) $raw) 60 : sanitize_textarea_field((string) $raw); 61 } 62 63 foreach ($config['required'] as $field) { 64 if (!isset($values[$field]) || $values[$field] === '') { 58 if (!wbs_dataverse_connect_can_view($config, 'form')) { 59 return new WP_REST_Response(['error' => 'forbidden', 'message' => __('Please sign in to use this form.', 'wbs-connect-for-dataverse')], 403); 60 } 61 62 if (wbs_dataverse_connect_turnstile_active($config) 63 && !wbs_dataverse_connect_turnstile_verify((string) ($payload['cf-turnstile-response'] ?? ''), wbs_dataverse_connect_client_ip())) { 64 return new WP_REST_Response([ 65 'error' => 'turnstile_failed', 66 'message' => __('Please complete the security check and try again.', 'wbs-connect-for-dataverse'), 67 ], 400); 68 } 69 70 $values = wbs_dataverse_connect_collect_values($config, $payload); 71 72 if ($config['require_consent'] && empty($payload['consent'])) { 73 return wbs_dataverse_connect_rest_invalid(__('Please confirm your consent.', 'wbs-connect-for-dataverse')); 74 } 75 76 $update = ($config['mode'] === 'update'); 77 $record_id = wbs_dataverse_connect_form_guid((string) ($payload['record'] ?? '')); 78 if ($update) { 79 // The bound record must be visible through the bind collection; 80 // a guessed id of a hidden record is refused. 81 $record_id = wbs_dataverse_connect_form_bound_record($config, $record_id); 82 if ($record_id === '') { 65 83 return new WP_REST_Response([ 66 'error' => ' validation_error',67 'message' => __(' Please fill in all required fields.', 'wbs-connect-for-dataverse'),84 'error' => 'unknown_record', 85 'message' => __('This record is no longer available.', 'wbs-connect-for-dataverse'), 68 86 ], 400); 69 87 } 70 88 } 71 if (isset($values['email']) && $values['email'] !== '' && !is_email($values['email'])) { 72 return new WP_REST_Response([ 73 'error' => 'validation_error', 74 'message' => __('Please enter a valid email address.', 'wbs-connect-for-dataverse'), 75 ], 400); 76 } 77 if ($config['require_consent'] && empty($payload['consent'])) { 78 return new WP_REST_Response([ 79 'error' => 'validation_error', 80 'message' => __('Please confirm your consent.', 'wbs-connect-for-dataverse'), 81 ], 400); 82 } 83 84 $record = wbs_dataverse_connect_build_submission($config, $values, (string) ($payload['record'] ?? '')); 85 if (empty($record)) { 86 return new WP_REST_Response([ 87 'error' => 'validation_error', 88 'message' => __('Please fill in all required fields.', 'wbs-connect-for-dataverse'), 89 ], 400); 89 90 $record = wbs_dataverse_connect_build_submission($config, $values, $record_id); 91 if (is_wp_error($record)) { 92 return wbs_dataverse_connect_rest_invalid($record->get_error_message()); 93 } 94 if (empty($record) && !$update) { 95 return wbs_dataverse_connect_rest_invalid(__('Please fill in all required fields.', 'wbs-connect-for-dataverse')); 90 96 } 91 97 92 98 /** 93 * Last stop before the create. Use this to map values onto typed94 * columns (choices, booleans, dates) or to add fixed values.99 * Last stop before the write. Use this to add fixed values or to 100 * adjust typed values; $config['mode'] tells a create from an update. 95 101 * 96 * @param array $record Column => value payload for the Web API create.102 * @param array $record Column => value payload for the Web API write. 97 103 * @param array $config The normalized form config. 98 104 * @param array $values The sanitized submitted values by field name. … … 100 106 $record = apply_filters('wbs_dataverse_connect_form_record', $record, $config, $values); 101 107 102 $result = wbs_dataverse_connect_post($config['entity_set'], $record, ['type' => 'form', 'config' => $config]); 108 $context = wbs_dataverse_connect_form_context($config); 109 if ($update) { 110 if (empty($record)) { 111 return wbs_dataverse_connect_rest_success($config, $record_id); 112 } 113 $context['headers'] = ['If-Match' => '*']; 114 $result = wbs_dataverse_connect_patch($config['entity_set'] . '(' . $record_id . ')', $record, $context); 115 } else { 116 $result = wbs_dataverse_connect_post($config['entity_set'], $record, $context); 117 } 118 103 119 if (is_wp_error($result)) { 104 120 // Log the Dataverse detail for the admin, keep the visitor message generic. 105 error_log('[wbs-connect-for-dataverse] form "' . $config['key'] . '" createfailed: ' . $result->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log121 error_log('[wbs-connect-for-dataverse] form "' . $config['key'] . '" ' . ($update ? 'update' : 'create') . ' failed: ' . $result->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 106 122 return new WP_REST_Response([ 107 123 'error' => 'send_error', … … 111 127 112 128 wbs_dataverse_connect_review_note_success(); 113 return new WP_REST_Response(['ok' => true, 'message' => wbs_dataverse_connect_form_success_message()], 200); 114 } 115 116 function wbs_dataverse_connect_form_success_message() { 129 130 $id = $update ? $record_id : (is_array($result) ? (string) ($result['id'] ?? '') : ''); 131 if ($update) { 132 /** 133 * Fires after a record was updated from a form submission. 134 * 135 * @param string $key Form key. 136 * @param string $entity_set Entity set of the record. 137 * @param string $id GUID of the updated record. 138 * @param array $record The payload that was patched. 139 */ 140 do_action('wbs_dataverse_connect_record_updated', $config['key'], $config['entity_set'], $id, $record); 141 } else { 142 /** 143 * Fires after a record was created from a form submission. 144 * 145 * @param string $key Form or bridge map key. 146 * @param string $entity_set Entity set the record was created in. 147 * @param string $id GUID of the new record, '' when unknown. 148 * @param array $record The payload that was posted. 149 */ 150 do_action('wbs_dataverse_connect_record_created', $config['key'], $config['entity_set'], $id, $record); 151 } 152 153 return wbs_dataverse_connect_rest_success($config, $id); 154 } 155 156 /** The success response: message, record id and the optional redirect. */ 157 function wbs_dataverse_connect_rest_success($config, $id) { 158 $body = [ 159 'ok' => true, 160 'message' => wbs_dataverse_connect_form_success_message($config), 161 'id' => $id, 162 ]; 163 if ($config['redirect_url'] !== '') { 164 $body['redirect'] = $config['redirect_url']; 165 } 166 return new WP_REST_Response($body, 200); 167 } 168 169 /** The form's own success message, or the default one. */ 170 function wbs_dataverse_connect_form_success_message($config = null) { 171 if (is_array($config) && !empty($config['success_message'])) { 172 return $config['success_message']; 173 } 174 if (is_array($config) && ($config['mode'] ?? 'create') === 'update') { 175 return __('Thank you. Your changes have been saved.', 'wbs-connect-for-dataverse'); 176 } 117 177 return __('Thank you. Your message has been sent, we will get back to you shortly.', 'wbs-connect-for-dataverse'); 118 178 } 119 179 120 180 /** 121 * Assemble the create payload: mapped non-empty values, plus the lookup 122 * bind when the form relates submissions to a collection record. Lookup 123 * binds use the schema-name casing of the navigation property. 181 * Read the configured fields from the payload, sanitized but untyped. 182 * Only fields the form knows are read; hidden fields carry their fixed 183 * value whatever was posted; keys absent from the payload stay absent so 184 * an update never blanks a column the form did not send. 185 */ 186 function wbs_dataverse_connect_collect_values($config, $payload) { 187 $values = []; 188 foreach ($config['fields'] as $field) { 189 $name = $field['name']; 190 if ($field['type'] === 'hidden') { 191 $values[$name] = $field['default']; 192 continue; 193 } 194 if (!is_array($payload) || !array_key_exists($name, $payload)) { 195 continue; 196 } 197 $raw = $payload[$name]; 198 if (is_array($raw)) { 199 $items = []; 200 foreach ($raw as $item) { 201 $item = is_scalar($item) ? sanitize_text_field((string) $item) : ''; 202 if ($item !== '') { 203 $items[] = $item; 204 } 205 } 206 $values[$name] = $items; 207 continue; 208 } 209 if (!is_scalar($raw)) { 210 $raw = ''; 211 } 212 switch ($field['type']) { 213 case 'email': 214 $values[$name] = sanitize_email((string) $raw); 215 break; 216 case 'textarea': 217 $values[$name] = sanitize_textarea_field((string) $raw); 218 break; 219 default: 220 $values[$name] = sanitize_text_field((string) $raw); 221 } 222 } 223 return $values; 224 } 225 226 /** 227 * Assemble the write payload: every field typed through 228 * wbs_dataverse_connect_typed_value, lookups as @odata.bind, plus the 229 * relate_to bind on creates. In update mode an emptied field clears its 230 * column (null); lookups and yes/no columns are left alone. Returns the 231 * column => value array or WP_Error with a visitor-facing message when a 232 * required field is empty or a value is invalid. 124 233 */ 125 234 function wbs_dataverse_connect_build_submission($config, $values, $record_id) { 126 $record = []; 127 foreach ($config['field_map'] as $field => $column) { 128 if (isset($values[$field]) && $values[$field] !== '') { 129 $record[$column] = $values[$field]; 130 } 131 } 132 133 if (is_array($config['relate_to'])) { 134 $guid = strtolower(preg_replace('/[^0-9a-fA-F\-]/', '', $record_id)); 135 if (strlen($guid) === 36) { 235 $context = wbs_dataverse_connect_form_context($config); 236 $update = (($config['mode'] ?? 'create') === 'update'); 237 $record = []; 238 239 foreach ($config['fields'] as $field) { 240 $name = $field['name']; 241 $present = array_key_exists($name, $values); 242 $typed = $present ? wbs_dataverse_connect_typed_value($field, $values[$name], $context) : null; 243 if (is_wp_error($typed)) { 244 return $typed; 245 } 246 247 $required = wbs_dataverse_connect_form_field_required($config, $field); 248 if ($typed === null) { 249 if ($required) { 250 return wbs_dataverse_connect_form_field_error('required', wbs_dataverse_connect_form_field_display_label($config, $field)); 251 } 252 if ($update && $present && !in_array($field['type'], ['lookup', 'hidden', 'boolean'], true)) { 253 $record[$field['column']] = null; 254 } 255 continue; 256 } 257 if ($field['type'] === 'boolean' && $field['required'] && $typed === false) { 258 return wbs_dataverse_connect_form_field_error('required', wbs_dataverse_connect_form_field_display_label($config, $field)); 259 } 260 261 if ($field['type'] === 'lookup') { 262 if ($field['lookup_entity_set'] === '') { 263 continue; // Nothing to bind against; the admin left the lookup facts out. 264 } 265 $record[$field['lookup_nav'] . '@odata.bind'] = '/' . $field['lookup_entity_set'] . '(' . $typed . ')'; 266 continue; 267 } 268 $record[$field['column']] = $typed; 269 } 270 271 if (!$update && is_array($config['relate_to'])) { 272 $guid = wbs_dataverse_connect_form_guid($record_id); 273 if ($guid !== '') { 136 274 $collection = wbs_dataverse_connect_collection($config['relate_to']['collection']); 137 275 if ($collection !== null) { … … 144 282 } 145 283 284 /** The visitor's address as seen by PHP, '' when unknown. */ 285 function wbs_dataverse_connect_client_ip() { 286 return isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; 287 } 288 146 289 /** Sliding per-IP counter in a transient. */ 147 290 function wbs_dataverse_connect_rate_limit_ok() { 148 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';291 $ip = wbs_dataverse_connect_client_ip(); 149 292 if ($ip === '') { 150 293 return true; -
wbs-connect-for-dataverse/trunk/includes/shortcodes.php
r3684633 r3684724 4 4 /** 5 5 * Shortcodes: 6 * [wbsdvc_collection name="<key>" limit="12" columns="3" detail_page="/path" form="<form key>"] 6 * [wbsdvc_collection name="<key>" per_page="12" columns="3" detail_page="/path" form="<form key>" 7 * search="1|0" filters="col1,col2|0" sort="col asc,col2 desc|0"] 8 * (limit is the older name of per_page and still wins when set) 7 9 * [wbsdvc_record name="<key>" slug="..." form="<form key>"] (or the page reads ?dvc=<slug-or-id>) 8 10 * [wbsdvc_form name="<form key>" record="<slug-or-id>"] 11 * 12 * The collection view reads its visitor state from the GET vars dvc_q, 13 * dvc_f[<column>], dvc_sort and dvc_page; the toolbar and pagination 14 * templates write them. One collection per page keeps that state 15 * unambiguous: two grids on one page share the same vars. 9 16 */ 10 17 … … 18 25 } 19 26 20 /** Normalize and clamp the [wbsdvc_collection] attributes. */ 27 /** 28 * Normalize and clamp the [wbsdvc_collection] attributes. per_page is 0 29 * when neither limit nor per_page was given, which lets the collection 30 * config decide; search, filters and sort are passed on as typed. 31 */ 21 32 function wbs_dataverse_connect_parse_collection_atts($atts) { 22 33 $atts = shortcode_atts([ 23 34 'name' => '', 24 'limit' => 12, 35 'limit' => '', 36 'per_page' => '', 25 37 'columns' => 3, 26 38 'detail_page' => '', 27 39 'form' => '', 40 'search' => '', 41 'filters' => '', 42 'sort' => '', 28 43 ], $atts, 'wbsdvc_collection'); 29 44 30 $limit = (int) $atts['limit']; 31 $limit = max(1, min(50, $limit ?: 12)); 45 $per_page = (int) $atts['limit']; 46 if ($per_page < 1) { 47 $per_page = (int) $atts['per_page']; 48 } 49 $per_page = $per_page < 1 ? 0 : wbs_dataverse_connect_per_page($per_page); 32 50 33 51 $columns = (int) $atts['columns']; … … 36 54 return [ 37 55 'name' => sanitize_key($atts['name']), 38 'limit' => $limit, 56 'limit' => $per_page, 57 'per_page' => $per_page, 39 58 'columns' => $columns, 40 59 'detail_page' => trim((string) $atts['detail_page']), 41 60 'form' => sanitize_key($atts['form']), 61 'search' => trim((string) $atts['search']), 62 'filters' => trim((string) $atts['filters']), 63 'sort' => trim((string) $atts['sort']), 42 64 ]; 43 65 } … … 97 119 } 98 120 return '<div class="dvc-notice dvc-notice--error">' . esc_html($message) . '</div>'; 121 } 122 123 /** 124 * Toolbar view model and markup: search box, one select per filter 125 * column, sort select, Apply button. The form GETs to the current page 126 * without its query string; the address's own args (page_id on plain 127 * permalinks) travel as hidden inputs, the dvc vars never do. '' when 128 * the collection offers no controls and no count. 129 */ 130 function wbs_dataverse_connect_collection_toolbar($config, $state, $records, $result, $base) { 131 static $instance = 0; 132 $instance++; 133 $prefix = 'dvc-' . $config['key'] . '-' . $instance; 134 135 $search = null; 136 if (!empty($config['search_columns'])) { 137 $search = [ 138 'name' => 'dvc_q', 139 'id' => $prefix . '-q', 140 'value' => $state['q'], 141 'label' => __('Search', 'wbs-connect-for-dataverse'), 142 ]; 143 } 144 145 $filters = []; 146 foreach ((array) $config['filter_columns'] as $column) { 147 $options = wbs_dataverse_connect_filter_options($config, $column, $records); 148 $current = (string) ($state['filters'][$column] ?? ''); 149 if ($current !== '' && !isset($options[$current])) { 150 $options[$current] = $current; 151 } 152 $filters[] = [ 153 'column' => $column, 154 'name' => 'dvc_f[' . $column . ']', 155 'id' => $prefix . '-f-' . $column, 156 'label' => wbs_dataverse_connect_field_label($config, $column), 157 'options' => $options, 158 'value' => $current, 159 ]; 160 } 161 162 $sort = null; 163 $choices = wbs_dataverse_connect_sort_choices($config); 164 if (!empty($choices)) { 165 $sort = [ 166 'name' => 'dvc_sort', 167 'id' => $prefix . '-sort', 168 'options' => $choices, 169 'value' => $state['sort'], 170 'label' => __('Sort by', 'wbs-connect-for-dataverse'), 171 ]; 172 } 173 174 $count = isset($result['count']) ? (int) $result['count'] : null; 175 if ($search === null && empty($filters) && $sort === null && $count === null) { 176 return ''; 177 } 178 179 $action = $base; 180 $hidden = []; 181 $query = (string) wp_parse_url($base, PHP_URL_QUERY); 182 if ($query !== '') { 183 $action = substr($base, 0, strpos($base, '?')); 184 parse_str($query, $pairs); 185 foreach ((array) $pairs as $name => $value) { 186 $name = (string) $name; 187 if (!is_scalar($value) || $name === 'dvc' || strpos($name, 'dvc_') === 0) { 188 continue; 189 } 190 $hidden[$name] = (string) $value; 191 } 192 } 193 194 return wbs_dataverse_connect_template('collection-toolbar.php', [ 195 'toolbar' => [ 196 'action' => $action, 197 'hidden' => $hidden, 198 'search' => $search, 199 'filters' => $filters, 200 'sort' => $sort, 201 'count' => $count, 202 'active' => wbs_dataverse_connect_state_active($state), 203 'reset_url' => $base, 204 ], 205 ]); 206 } 207 208 /** 209 * Pagination view model and markup: previous and next links that keep 210 * the search, filter and sort state. '' on a single page. 211 */ 212 function wbs_dataverse_connect_collection_pagination($config, $state, $result, $base) { 213 if (empty($result['has_prev']) && empty($result['has_next'])) { 214 return ''; 215 } 216 $page = max(1, (int) $result['page']); 217 $current = add_query_arg(wbs_dataverse_connect_state_args($state), $base); 218 219 return wbs_dataverse_connect_template('collection-pagination.php', [ 220 'pagination' => [ 221 'page' => $page, 222 'has_prev' => !empty($result['has_prev']), 223 'has_next' => !empty($result['has_next']), 224 'prev_url' => !empty($result['has_prev']) ? add_query_arg('dvc_page', $page - 1 > 1 ? $page - 1 : false, $current) : '', 225 'next_url' => !empty($result['has_next']) ? add_query_arg('dvc_page', $page + 1, $current) : '', 226 'count' => isset($result['count']) ? (int) $result['count'] : null, 227 ], 228 ]); 99 229 } 100 230 … … 122 252 ); 123 253 } 124 125 $records = wbs_dataverse_connect_get_records($config, $args['limit']); 126 if (is_wp_error($records)) { 127 return wbs_dataverse_connect_outage_notice($records); 128 } 254 if (!wbs_dataverse_connect_can_view($config, 'collection')) { 255 return wbs_dataverse_connect_denied_notice($config, 'collection'); 256 } 257 $config = wbs_dataverse_connect_collection_overrides($config, $args); 258 $state = wbs_dataverse_connect_collection_state($config, $args); 259 260 $result = wbs_dataverse_connect_collection_page($config, $state); 261 if (is_wp_error($result)) { 262 return wbs_dataverse_connect_outage_notice($result); 263 } 264 $records = $result['records']; 265 266 wbs_dataverse_connect_enqueue_style(); 267 268 $base = wbs_dataverse_connect_detail_base(''); 269 $toolbar = wbs_dataverse_connect_collection_toolbar($config, $state, $records, $result, $base); 270 $pagination = wbs_dataverse_connect_collection_pagination($config, $state, $result, $base); 271 129 272 if (empty($records)) { 130 return '<div class="dvc-notice">' . esc_html__('There is nothing to show at the moment.', 'wbs-connect-for-dataverse') . '</div>'; 131 } 132 wbs_dataverse_connect_review_note_success(); 133 134 wbs_dataverse_connect_enqueue_style(); 135 136 $detail_base = wbs_dataverse_connect_detail_base($args['detail_page']); 137 $cards = ''; 138 foreach ($records as $record) { 139 $vm = wbs_dataverse_connect_record_vm($config, $record, $detail_base); 140 $cards .= wbs_dataverse_connect_template('collection-card.php', ['vm' => $vm]); 141 } 142 143 return wbs_dataverse_connect_template('collection-list.php', [ 144 'cards' => $cards, 145 'columns' => $args['columns'], 146 ]); 273 $empty = wbs_dataverse_connect_state_active($state) 274 ? __('Nothing matches your search. Try different terms or clear the filters.', 'wbs-connect-for-dataverse') 275 : __('There is nothing to show at the moment.', 'wbs-connect-for-dataverse'); 276 $body = '<div class="dvc-notice">' . esc_html($empty) . '</div>'; 277 } else { 278 wbs_dataverse_connect_review_note_success(); 279 280 $detail_base = wbs_dataverse_connect_detail_base($args['detail_page']); 281 $cards = ''; 282 foreach ($records as $record) { 283 $vm = wbs_dataverse_connect_record_vm($config, $record, $detail_base); 284 $cards .= wbs_dataverse_connect_template('collection-card.php', ['vm' => $vm]); 285 } 286 $body = wbs_dataverse_connect_template('collection-list.php', [ 287 'cards' => $cards, 288 'columns' => $args['columns'], 289 ]); 290 } 291 292 return '<div class="dvc-collection">' . $toolbar . $body . $pagination . '</div>'; 147 293 } 148 294 … … 165 311 } 166 312 313 if (!wbs_dataverse_connect_can_view($config, 'record')) { 314 return wbs_dataverse_connect_denied_notice($config, 'record'); 315 } 167 316 $slug = trim((string) $atts['slug']); 168 317 if ($slug === '') { … … 217 366 } 218 367 368 if (!wbs_dataverse_connect_can_view($config, 'form')) { 369 return wbs_dataverse_connect_denied_notice($config, 'form'); 370 } 371 219 372 // Resolve a slug to the record id so the submission can be bound to it. 220 373 $record_id = ''; 221 374 $slug = trim((string) $atts['record']); 222 if ($slug !== '' && is_array($config['relate_to'])) { 223 $collection = wbs_dataverse_connect_collection($config['relate_to']['collection']); 375 if ($slug !== '') { 376 // relate_to names the collection for create forms; update forms may bind through bind_to alone. 377 $collection = is_array($config['relate_to']) 378 ? wbs_dataverse_connect_collection($config['relate_to']['collection']) 379 : wbs_dataverse_connect_form_bind_collection($config); 224 380 if ($collection !== null) { 225 381 $record = wbs_dataverse_connect_get_record($collection, $slug); -
wbs-connect-for-dataverse/trunk/readme.txt
r3684633 r3684724 3 3 Tags: dataverse, dynamics-365, microsoft, crm, integration 4 4 Requires at least: 6.0 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1. 4.07 Stable tag: 1.5.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 19 19 * **Collections.** Publish any Dataverse table by naming the table, the columns and an optional status filter. No code required; developers can also register collections via a filter. 20 20 * **Grid and detail on one page.** `[wbsdvc_collection]` renders the card grid, and the same page renders the detail view when a record is selected, so links stay short. 21 * **Search, filters, sort and paging.** Offer visitors a search box, filter dropdowns and sort choices per collection, and page through long tables with Previous and Next links that keep the current state. Column labels come from the table's display names in Dataverse. 22 * **Block editor.** Dataverse Collection, Dataverse Record and Dataverse Form blocks wrap the shortcodes with a dropdown of your configured collections and forms and a live preview. 23 * **Images and files.** Show a Dataverse Image column on cards and detail pages and offer File columns as downloads, served through your own site so nothing points at Dataverse directly. 21 24 * **Clean detail URLs.** Point a collection at a slug column and detail links use readable slugs (`?dvc=my-record`) instead of raw record ids. Id links keep working as a fallback. 22 * **Write-back forms.** Map form fields to columns of any table; submissions create records in Dataverse, optionally related to the record the form was shown on. Every submission is nonce-verified, honeypot-checked and rate limited. 25 * **Write-back forms.** Build a form as a list of typed fields (text, email, phone, multi-line, number, money, date, date and time, choice, multi-select choice, yes/no, lookup, hidden). Values are validated and converted on the server, table metadata fills in labels, options and required levels, and submissions create records in Dataverse, optionally related to the record the form was shown on. Every submission is nonce-verified, honeypot-checked and rate limited; Cloudflare Turnstile can be switched on per form. 26 * **Edit mode.** An update-mode form prefills from the record the page shows and saves changes back to it, limited to records visible through the bound collection. 27 * **Webhooks.** Post a signed JSON message to URLs of your choice when a form creates a record and when the Dataverse connection fails or recovers, for Power Automate flows or Teams alerts. 23 28 * **Server-side authentication.** The plugin signs in with an Entra ID app registration (client-credentials flow) and a Dataverse application user with a least-privilege role. Credentials never reach the browser; `wp-config.php` constants are the recommended home for them. 24 29 * **Caching and fail-open.** Reads are cached with a configurable TTL. When Dataverse is unreachable, pages show a friendly notice instead of failing; the site never goes blank. … … 33 38 **External services** 34 39 35 This plugin connects to two external services, both operated by Microsoft and both belonging to you, the site owner . Data flows only between your WordPress host and your own Microsoft tenant's endpoints; nothing is sent to WOODS Business Solutions or any other third party, and the plugin contains no telemetry.40 This plugin connects to two external services, both operated by Microsoft and both belonging to you, the site owner, plus one optional third-party service you switch on per form. Data flows only between your WordPress host and your own Microsoft tenant's endpoints; nothing is sent to WOODS Business Solutions or any other third party, and the plugin contains no telemetry. 36 41 37 42 1. **Microsoft Entra ID token endpoint** (`login.microsoftonline.com`). The plugin requests OAuth2 access tokens here using the tenant id, client id and client secret you configure. This happens whenever a cached token has expired. 38 43 2. **Your Microsoft Dataverse environment** (the `*.dynamics.com` URL you configure, for example `https://yourorg.crm4.dynamics.com`). The plugin reads the records you chose to publish and creates the records your visitors submit through configured forms (typically a name, email address, phone number and message). This happens on page views of pages using the shortcodes (subject to caching) and on form submissions. 39 44 40 These services are governed by Microsoft's terms and privacy statement: 45 3. **Cloudflare Turnstile** (optional). When you switch on the Turnstile toggle for a form and enter a site key and secret key, that form loads the Turnstile widget script from `challenges.cloudflare.com` in the visitor's browser and, on submit, your site sends the challenge token together with the visitor's IP address to Cloudflare's siteverify endpoint to confirm the challenge passed. Forms without the toggle never contact Cloudflare. Terms: [https://www.cloudflare.com/website-terms/](https://www.cloudflare.com/website-terms/), privacy: [https://www.cloudflare.com/privacypolicy/](https://www.cloudflare.com/privacypolicy/) 46 47 Webhooks you configure yourself post to the URLs you enter and nowhere else. 48 49 The Microsoft services are governed by Microsoft's terms and privacy statement: 41 50 42 51 * Terms: [https://www.microsoft.com/licensing/terms](https://www.microsoft.com/licensing/terms) … … 49 58 1. Install and activate the plugin. 50 59 2. In the Entra admin center, create an app registration (no redirect URI needed) and a client secret. 51 3. In the Power Platform admin center, create an application user for that app registration in your environment and assign it a least-privilege security role: Read on the tables you publish, Create on the tables your forms write to . Do not use System Administrator.60 3. In the Power Platform admin center, create an application user for that app registration in your environment and assign it a least-privilege security role: Read on the tables you publish, Create on the tables your forms write to, Write on tables used by update-mode forms. Do not use System Administrator. 52 61 4. Enter tenant id, client id, client secret and environment URL under Settings, Dataverse Connect, or define them as `WBS_DATAVERSE_CONNECT_TENANT_ID`, `WBS_DATAVERSE_CONNECT_CLIENT_ID`, `WBS_DATAVERSE_CONNECT_CLIENT_SECRET` and `WBS_DATAVERSE_CONNECT_ENV_URL` in `wp-config.php` (recommended). 53 62 5. Run "Test connection" on the settings page. 54 6. Add a collection on the Collections tab and put `[wbsdvc_collection name="<key>"]` on a page .63 6. Add a collection on the Collections tab and put `[wbsdvc_collection name="<key>"]` on a page, or insert the Dataverse Collection block. Optional shortcode attributes: `per_page`, `columns`, `detail_page`, `form`, `search`, `filters`, `sort`. 55 64 56 65 == Frequently Asked Questions == … … 80 89 Any table the application user can read, standard or custom. A collection needs the table's logical name, its entity set name, a name column and the columns to display. Writes work against any table the application user may create records in, with values mapped to text columns; typed columns can be handled via a developer filter. 81 90 91 = Can visitors edit records? = 92 93 Only through an update-mode form, and only records that are visible through the collection the form is bound to. The application user needs Write on that table. Records that fail the collection's status filter cannot be opened or changed. 94 82 95 = Are the templates customizable? = 83 96 … … 93 106 94 107 == Changelog == 108 109 = 1.5.0 = 110 * Search, filters and sort on collections: pick the columns on the Collections tab and visitors get a search box, filter dropdowns and sort choices that work without JavaScript. The [wbsdvc_collection] shortcode accepts per_page, search, filters and sort. 111 * Paging: set the records per page on the collection or in the shortcode; Previous and Next links keep the current search, filters and sort, and a result count is shown when Dataverse reports one. Each collection can also have its own cache lifetime. 112 * Column labels in cards, detail tables and the toolbar come from the table's display names in Dataverse instead of being guessed from logical names. 113 * Typed form fields: a form is now an ordered list of fields with a type, label, required flag, options and default each. Choices, dates, numbers, money, yes/no and lookups are validated and converted on the server. Forms saved with earlier versions keep working unchanged. 114 * Update mode for forms: prefill from the record the page shows and save changes back to it. Only records visible through the bound collection can be edited. 115 * Cloudflare Turnstile per form, plus a custom success message and an optional redirect after a successful submission. 116 * Block editor: Dataverse Collection, Dataverse Record and Dataverse Form blocks with a dropdown of configured collections and forms and a live preview. 117 * Image and File columns: pictures on cards and detail pages and file downloads, served through your own site. Only published records are served; files up to 5 MB are cached for a day. 118 * Webhooks tab: post a JSON message, optionally signed with a shared secret, when a form creates a record and when the connection fails or recovers. Every webhook has a Test link. 119 * Developers: wbs_dataverse_connect_patch() and wbs_dataverse_connect_get_raw() in the client, a wbs_dataverse_connect_typed_value() helper, the actions wbs_dataverse_connect_record_created, wbs_dataverse_connect_record_updated and wbs_dataverse_connect_webhook_dispatched, and filters for the toolbar, the block dropdowns, webhook payloads and media handling. Two new templates, collection-toolbar.php and collection-pagination.php, can be overridden from the theme. 95 120 96 121 = 1.4.0 = … … 116 141 == Upgrade Notice == 117 142 143 = 1.5.0 = 144 Search, filters, sort and paging on collections, typed form fields with an edit mode, block editor support, image and file columns, and webhooks. Existing collections and forms keep working unchanged; the application user needs Write on a table only if you switch a form to update mode. 145 118 146 = 1.4.0 = 119 147 Adds a one-time, snoozable request to rate the plugin plus permanent Rate and Support links. No functional changes to collections, forms or the connection. -
wbs-connect-for-dataverse/trunk/templates/form.php
r3620392 r3684724 3 3 * Write-back form. Override by copying to <theme>/wbs-connect-for-dataverse/. 4 4 * Available: $config (normalized form config), $record_id (GUID of the 5 * related recordor ''), $rest_url, $nonce.5 * related or bound record, or ''), $rest_url, $nonce. 6 6 * 7 * The submit handler lives in assets/wbs-connect-for-dataverse.js and reads the 8 * data-dvc-* attributes; no inline script is printed. 7 * The view model comes from wbs_dataverse_connect_form_view(): every field 8 * carries its type, label, required flag, options and initial value, so 9 * this file only decides on markup. The submit handler lives in 10 * assets/wbs-connect-for-dataverse.js and reads the data-dvc-* attributes; 11 * no inline script is printed. 9 12 */ 10 13 if (!defined('ABSPATH')) { exit; } 11 14 12 $wbs_dataverse_connect_form_uid = 'dvc-form-' . substr(md5($config['key'] . $record_id . wp_rand()), 0, 8); 15 $wbs_dataverse_connect_view = wbs_dataverse_connect_form_view($config, $record_id); 16 17 if (isset($wbs_dataverse_connect_view['error'])) { 18 echo wbs_dataverse_connect_outage_notice($wbs_dataverse_connect_view['error']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by the notice builder 19 return; 20 } 21 if (isset($wbs_dataverse_connect_view['config_error'])) { 22 echo wbs_dataverse_connect_config_notice($wbs_dataverse_connect_view['config_error']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by the notice builder 23 return; 24 } 25 if (!empty($wbs_dataverse_connect_view['missing'])) { 26 echo '<div class="dvc-notice">' . esc_html__('This record is no longer available.', 'wbs-connect-for-dataverse') . '</div>'; 27 return; 28 } 29 30 $wbs_dataverse_connect_form_uid = $wbs_dataverse_connect_view['uid']; 13 31 ?> 14 <form class="dvc-form " id="<?php echo esc_attr($wbs_dataverse_connect_form_uid); ?>" method="post"32 <form class="dvc-form dvc-form--<?php echo esc_attr($wbs_dataverse_connect_view['mode']); ?>" id="<?php echo esc_attr($wbs_dataverse_connect_form_uid); ?>" method="post" 15 33 action="<?php echo esc_url($rest_url); ?>" 16 34 data-dvc-rest="<?php echo esc_url($rest_url); ?>" 17 35 data-dvc-nonce="<?php echo esc_attr($nonce); ?>" 36 data-dvc-mode="<?php echo esc_attr($wbs_dataverse_connect_view['mode']); ?>" 18 37 data-dvc-sending="<?php echo esc_attr__('Sending...', 'wbs-connect-for-dataverse'); ?>" 19 38 data-dvc-error="<?php echo esc_attr__('Your message could not be sent. Please try again later.', 'wbs-connect-for-dataverse'); ?>"> 20 39 <input type="hidden" name="form" value="<?php echo esc_attr($config['key']); ?>"> 21 <input type="hidden" name="record" value="<?php echo esc_attr($ record_id); ?>">40 <input type="hidden" name="record" value="<?php echo esc_attr($wbs_dataverse_connect_view['record_id']); ?>"> 22 41 <p class="dvc-form__hp" aria-hidden="true"> 23 42 <label><?php echo esc_html__('Website', 'wbs-connect-for-dataverse'); ?><input type="text" name="_dvc_website" tabindex="-1" autocomplete="off"></label> 24 43 </p> 25 44 26 <?php foreach (array_keys($config['field_map']) as $wbs_dataverse_connect_field) : 27 $wbs_dataverse_connect_required = in_array($wbs_dataverse_connect_field, $config['required'], true); 28 $wbs_dataverse_connect_field_id = $wbs_dataverse_connect_form_uid . '-' . $wbs_dataverse_connect_field; 29 $wbs_dataverse_connect_label = wbs_dataverse_connect_form_field_label($wbs_dataverse_connect_field); 45 <?php foreach ($wbs_dataverse_connect_view['fields'] as $wbs_dataverse_connect_field) : 46 $wbs_dataverse_connect_name = $wbs_dataverse_connect_field['name']; 47 $wbs_dataverse_connect_type = $wbs_dataverse_connect_field['type']; 48 $wbs_dataverse_connect_value = $wbs_dataverse_connect_field['value']; 49 $wbs_dataverse_connect_required = !empty($wbs_dataverse_connect_field['required']); 50 $wbs_dataverse_connect_field_id = $wbs_dataverse_connect_form_uid . '-' . $wbs_dataverse_connect_name; 51 $wbs_dataverse_connect_marker = $wbs_dataverse_connect_required ? ' <span class="dvc-form__required" aria-hidden="true">*</span>' : ''; 52 53 if ($wbs_dataverse_connect_type === 'hidden') : ?> 54 <input type="hidden" name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>" value="<?php echo esc_attr((string) $wbs_dataverse_connect_value); ?>"> 55 <?php continue; 56 endif; 30 57 ?> 58 <?php if ($wbs_dataverse_connect_type === 'boolean') : ?> 59 <p class="dvc-form__row dvc-form__row--check"> 60 <input type="hidden" name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>" value="0"> 61 <label class="dvc-form__check" for="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>"> 62 <input type="checkbox" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>" value="1" 63 <?php checked((string) $wbs_dataverse_connect_value, '1'); ?><?php echo $wbs_dataverse_connect_required ? ' required' : ''; ?>> 64 <span><?php echo esc_html($wbs_dataverse_connect_field['label']); ?><?php echo $wbs_dataverse_connect_marker; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup ?></span> 65 </label> 66 </p> 67 <?php continue; ?> 68 <?php endif; ?> 69 70 <?php if ($wbs_dataverse_connect_type === 'multichoice') : ?> 71 <fieldset class="dvc-form__row dvc-form__fieldset"> 72 <legend class="dvc-form__label"><?php echo esc_html($wbs_dataverse_connect_field['label']); ?><?php echo $wbs_dataverse_connect_marker; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup ?></legend> 73 <input type="hidden" name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>[]" value=""> 74 <?php foreach ($wbs_dataverse_connect_field['options'] as $wbs_dataverse_connect_option => $wbs_dataverse_connect_option_label) : ?> 75 <label class="dvc-form__choice"> 76 <input type="checkbox" name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>[]" value="<?php echo esc_attr((string) $wbs_dataverse_connect_option); ?>" 77 <?php checked(is_array($wbs_dataverse_connect_value) && in_array((int) $wbs_dataverse_connect_option, $wbs_dataverse_connect_value, true)); ?>> 78 <span><?php echo esc_html($wbs_dataverse_connect_option_label); ?></span> 79 </label> 80 <?php endforeach; ?> 81 </fieldset> 82 <?php continue; ?> 83 <?php endif; ?> 84 31 85 <p class="dvc-form__row"> 32 <label class="dvc-form__label" for="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>"><?php echo esc_html($wbs_dataverse_connect_ label); ?><?php echo $wbs_dataverse_connect_required ? ' *' : '';?></label>33 <?php if ($wbs_dataverse_connect_ field === 'message') : ?>86 <label class="dvc-form__label" for="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>"><?php echo esc_html($wbs_dataverse_connect_field['label']); ?><?php echo $wbs_dataverse_connect_marker; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup ?></label> 87 <?php if ($wbs_dataverse_connect_type === 'textarea') : ?> 34 88 <textarea class="dvc-form__input dvc-form__textarea" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" 35 name="<?php echo esc_attr($wbs_dataverse_connect_field); ?>" rows="5" <?php echo $wbs_dataverse_connect_required ? 'required' : ''; ?>></textarea> 36 <?php elseif ($wbs_dataverse_connect_field === 'email') : ?> 37 <input class="dvc-form__input" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" type="email" 38 name="<?php echo esc_attr($wbs_dataverse_connect_field); ?>" <?php echo $wbs_dataverse_connect_required ? 'required' : ''; ?>> 39 <?php elseif ($wbs_dataverse_connect_field === 'phone') : ?> 40 <input class="dvc-form__input" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" type="tel" 41 name="<?php echo esc_attr($wbs_dataverse_connect_field); ?>" <?php echo $wbs_dataverse_connect_required ? 'required' : ''; ?>> 42 <?php else : ?> 43 <input class="dvc-form__input" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" type="text" 44 name="<?php echo esc_attr($wbs_dataverse_connect_field); ?>" <?php echo $wbs_dataverse_connect_required ? 'required' : ''; ?>> 89 name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>" rows="5"<?php echo $wbs_dataverse_connect_required ? ' required' : ''; ?>><?php echo esc_textarea((string) $wbs_dataverse_connect_value); ?></textarea> 90 <?php elseif ($wbs_dataverse_connect_type === 'choice' || ($wbs_dataverse_connect_type === 'lookup' && !empty($wbs_dataverse_connect_field['options']))) : ?> 91 <select class="dvc-form__input dvc-form__select" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" 92 name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>"<?php echo $wbs_dataverse_connect_required ? ' required' : ''; ?>> 93 <option value=""><?php echo esc_html__('Please choose', 'wbs-connect-for-dataverse'); ?></option> 94 <?php foreach ($wbs_dataverse_connect_field['options'] as $wbs_dataverse_connect_option => $wbs_dataverse_connect_option_label) : ?> 95 <option value="<?php echo esc_attr((string) $wbs_dataverse_connect_option); ?>" <?php selected((string) $wbs_dataverse_connect_option, (string) $wbs_dataverse_connect_value); ?>><?php echo esc_html($wbs_dataverse_connect_option_label); ?></option> 96 <?php endforeach; ?> 97 </select> 98 <?php else : 99 $wbs_dataverse_connect_input = ['type' => 'text', 'extra' => '']; 100 switch ($wbs_dataverse_connect_type) { 101 case 'email': 102 $wbs_dataverse_connect_input = ['type' => 'email', 'extra' => '']; 103 break; 104 case 'phone': 105 $wbs_dataverse_connect_input = ['type' => 'tel', 'extra' => '']; 106 break; 107 case 'number': 108 $wbs_dataverse_connect_input = ['type' => 'number', 'extra' => ' step="any"']; 109 break; 110 case 'money': 111 $wbs_dataverse_connect_input = ['type' => 'number', 'extra' => ' step="0.01"']; 112 break; 113 case 'date': 114 $wbs_dataverse_connect_input = ['type' => 'date', 'extra' => '']; 115 break; 116 case 'datetime': 117 $wbs_dataverse_connect_input = ['type' => 'datetime-local', 'extra' => '']; 118 break; 119 } 120 ?> 121 <input class="dvc-form__input" id="<?php echo esc_attr($wbs_dataverse_connect_field_id); ?>" type="<?php echo esc_attr($wbs_dataverse_connect_input['type']); ?>" 122 name="<?php echo esc_attr($wbs_dataverse_connect_name); ?>" value="<?php echo esc_attr((string) $wbs_dataverse_connect_value); ?>"<?php echo $wbs_dataverse_connect_input['extra']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static attribute ?><?php echo $wbs_dataverse_connect_required ? ' required' : ''; ?>> 45 123 <?php endif; ?> 46 124 </p> … … 56 134 <?php endif; ?> 57 135 136 <?php if ($wbs_dataverse_connect_view['turnstile'] !== '') : ?> 137 <div class="dvc-form__row dvc-form__turnstile cf-turnstile" data-sitekey="<?php echo esc_attr($wbs_dataverse_connect_view['turnstile']); ?>"></div> 138 <?php endif; ?> 139 58 140 <p class="dvc-form__row"> 59 <button class="dvc-form__submit" type="submit"><?php echo esc_html __('Send', 'wbs-connect-for-dataverse'); ?></button>141 <button class="dvc-form__submit" type="submit"><?php echo esc_html($wbs_dataverse_connect_view['submit_label']); ?></button> 60 142 </p> 61 143 <p class="dvc-form__status" role="status" aria-live="polite"></p> -
wbs-connect-for-dataverse/trunk/templates/record-detail.php
r3620392 r3684724 2 2 /** 3 3 * Record detail view. Override by copying to <theme>/wbs-connect-for-dataverse/. 4 * Available: $vm (view model array, see wbs_dataverse_connect_record_vm()), 5 * $form (pre-rendered form HTML, empty when no form is attached). 4 * Available: $vm (view model array, see wbs_dataverse_connect_record_vm(); 5 * $vm['files'] lists downloadable file columns as ['column', 'label', 6 * 'url', 'name'] when the collection has any), $form (pre-rendered form 7 * HTML, empty when no form is attached). 6 8 */ 7 9 if (!defined('ABSPATH')) { exit; } … … 33 35 <?php endif; ?> 34 36 37 <?php if (!empty($vm['files']) && is_array($vm['files'])) : ?> 38 <section class="dvc-detail__files"> 39 <h2 class="dvc-detail__heading"><?php echo esc_html__('Downloads', 'wbs-connect-for-dataverse'); ?></h2> 40 <ul class="dvc-files"> 41 <?php foreach ($vm['files'] as $wbs_dataverse_connect_file) : 42 if (!is_array($wbs_dataverse_connect_file) || empty($wbs_dataverse_connect_file['url'])) { 43 continue; 44 } 45 $wbs_dataverse_connect_file_name = (string) ($wbs_dataverse_connect_file['name'] ?? ''); 46 if ($wbs_dataverse_connect_file_name === '') { 47 $wbs_dataverse_connect_file_name = (string) ($wbs_dataverse_connect_file['label'] ?? $wbs_dataverse_connect_file['column'] ?? ''); 48 } 49 ?> 50 <li class="dvc-files__item"> 51 <a class="dvc-files__link" href="<?php echo esc_url($wbs_dataverse_connect_file['url']); ?>"><?php echo esc_html($wbs_dataverse_connect_file_name); ?></a> 52 <?php if (!empty($wbs_dataverse_connect_file['label']) && $wbs_dataverse_connect_file['label'] !== $wbs_dataverse_connect_file_name) : ?> 53 <span class="dvc-files__label"><?php echo esc_html($wbs_dataverse_connect_file['label']); ?></span> 54 <?php endif; ?> 55 </li> 56 <?php endforeach; ?> 57 </ul> 58 </section> 59 <?php endif; ?> 60 35 61 <?php if (!empty($form)) : ?> 36 62 <section class="dvc-detail__form"> -
wbs-connect-for-dataverse/trunk/uninstall.php
r3684633 r3684724 23 23 'wbsdvc_health', 24 24 'wbsdvc_review', 25 'wbsdvc_webhooks', 26 'wbs_dataverse_connect_turnstile_site_key', 27 'wbs_dataverse_connect_turnstile_secret_key', 25 28 ]; 26 29 foreach ($wbs_dataverse_connect_options as $wbs_dataverse_connect_option) { -
wbs-connect-for-dataverse/trunk/wbs-connect-for-dataverse.php
r3684633 r3684724 4 4 * Plugin URI: https://woods.consulting/wbs-dataverse-connect-for-wordpress/ 5 5 * Description: Publish Microsoft Dataverse records as lists and detail views on your WordPress site, and write form submissions straight back into Dataverse tables. 6 * Version: 1. 4.06 * Version: 1.5.0 7 7 * Author: WOODS Business Solutions 8 8 * Author URI: https://woods.consulting … … 16 16 if (!defined('ABSPATH')) { exit; } 17 17 18 define('WBS_DATAVERSE_CONNECT_VERSION', '1. 4.0');18 define('WBS_DATAVERSE_CONNECT_VERSION', '1.5.0'); 19 19 define('WBS_DATAVERSE_CONNECT_PATH', plugin_dir_path(__FILE__)); 20 20 define('WBS_DATAVERSE_CONNECT_URL', plugin_dir_url(__FILE__)); … … 23 23 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/health.php'; 24 24 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/dataverse.php'; 25 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/metadata.php'; 25 26 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/collections.php'; 26 27 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/forms.php'; … … 28 29 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/shortcodes.php'; 29 30 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/rest.php'; 31 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/media.php'; 32 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/webhooks.php'; 33 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/blocks.php'; 30 34 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/admin.php'; 31 35 require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/review.php'; … … 50 54 add_action('admin_init', 'wbs_dataverse_connect_admin_init'); 51 55 wbs_dataverse_connect_review_boot(); 56 wbs_dataverse_connect_blocks_boot(); 52 57 add_action('admin_post_wbs_dataverse_connect_flush', 'wbs_dataverse_connect_handle_flush'); 53 58 add_action('admin_post_wbs_dataverse_connect_test', 'wbs_dataverse_connect_handle_test');
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)