Plugin Directory

Changeset 3684724


Ignore:
Timestamp:
09/07/2026 11:19:00 AM (7 weeks ago)
Author:
ztwoods
Message:

Release 1.5.0

Location:
wbs-connect-for-dataverse/trunk
Files:
16 added
13 edited

Legend:

Unmodified
Added
Removed
  • wbs-connect-for-dataverse/trunk/assets/wbs-connect-for-dataverse.css

    r3620392 r3684724  
    8484.dvc-form__status--error { color: #a02222; }
    8585
     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
    86160.dvc-notice {
    87161    padding: 0.9rem 1.1rem;
  • wbs-connect-for-dataverse/trunk/assets/wbs-connect-for-dataverse.js

    r3620392 r3684724  
    22(function () {
    33    '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    }
    442
    543    function init(form) {
    … …  
    1048        }
    1149        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        }
    1259
    1360        form.addEventListener('submit', function (e) {
    1461            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             }
    2162            button.disabled = true;
    2263            button.textContent = form.getAttribute('data-dvc-sending') || submitLabel;
     64            status.textContent = '';
     65            status.className = 'dvc-form__status';
    2366            fetch(form.getAttribute('data-dvc-rest') || form.action, {
    2467                method: 'POST',
    … …  
    2770                    'X-WP-Nonce': form.getAttribute('data-dvc-nonce') || ''
    2871                },
    29                 body: JSON.stringify(data)
     72                body: JSON.stringify(collect(form))
    3073            }).then(function (r) {
    3174                return r.json().then(function (j) { return { ok: r.ok, body: j }; });
    3275            }).then(function (res) {
    3376                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                    }
    3588                    status.textContent = res.body.message || '';
    3689                    status.className = 'dvc-form__status dvc-form__status--ok';
    3790                } 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 : '');
    4292                }
    4393            }).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('');
    4895            });
    4996        });
  • wbs-connect-for-dataverse/trunk/includes/bridge-wbs-forms.php

    r3684633 r3684724  
    150150    wbs_dataverse_connect_review_note_success();
    151151
     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
    152162    // Mark this form's submission as captured for wbs_forms_submission_handled.
    153163    $GLOBALS['wbs_dataverse_connect_bridge_handled'][(int) $form_id] = true;
  • wbs-connect-for-dataverse/trunk/includes/collections.php

    r3624546 r3684724  
    1515 *   status_filter    optional OData filter, e.g. statecode eq 0
    1616 *   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.
    1735 *
    1836 * Collections registered in code via the wbs_dataverse_connect_collections
    … …  
    2038 * merged over them, so the admin can override a code-registered key.
    2139 */
     40
     41const WBS_DATAVERSE_CONNECT_PER_PAGE_DEFAULT = 12;
     42const WBS_DATAVERSE_CONNECT_PER_PAGE_MAX     = 100;
     43const WBS_DATAVERSE_CONNECT_SEARCH_MAXLEN    = 100;
     44const WBS_DATAVERSE_CONNECT_PAGE_MAX         = 1000;
     45const WBS_DATAVERSE_CONNECT_PAGE_HOPS        = 10;
     46const WBS_DATAVERSE_CONNECT_METADATA_RETRY   = 300;
    2247
    2348/** All collections, keyed by collection key. */
    … …  
    6186
    6287/**
     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 */
     91function 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 */
     115function 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 */
     129function 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. */
     140function 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. */
     161function 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. */
     170function 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/**
    63181 * Validate and normalize one collection config. Returns the clean config
    64182 * array or null when a required field is missing.
    … …  
    67185    if (!is_array($config)) {
    68186        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         }
    79187    }
    80188
    … …  
    85193        'name_column'      => wbs_dataverse_connect_logical_name($config['name_column'] ?? ''),
    86194        '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'] ?? []),
    88196        'image_url_column' => wbs_dataverse_connect_logical_name($config['image_url_column'] ?? ''),
    89197        'status_filter'    => sanitize_text_field($config['status_filter'] ?? ''),
    90198        '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'] ?? ''),
    91204    ];
    92205
    … …  
    111224}
    112225
    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 */
     232function 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 */
     244function 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 */
    114258function wbs_dataverse_connect_collection_select($config) {
    115259    $columns = array_merge(
    116260        [wbs_dataverse_connect_id_column($config), $config['name_column']],
    117         $config['columns']
     261        $config['columns'],
     262        (array) ($config['filter_columns'] ?? [])
    118263    );
    119264    if ($config['slug_column'] !== '') {
    … …  
    123268        $columns[] = $config['image_url_column'];
    124269    }
    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. */
     278function 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. */
     283function 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 */
     296function 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. */
     324function 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 */
     336function 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 */
     358function 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 */
     404function 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. */
     451function 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 */
     459function 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. */
     474function 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 */
     488function 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 */
     542function 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 */
     579function 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 */
     596function 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 */
     621function 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. */
     642function 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 */
     653function 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. */
     661function 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 */
     679function 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    ];
    126756}
    127757
    128758/**
    129759 * 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 */
     763function 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'];
    148766}
    149767
    … …  
    161779    }
    162780
    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);
    167782
    168783    if ($config['slug_column'] !== '') {
    … …  
    190805        $filter = '(' . $config['status_filter'] . ') and ' . $filter;
    191806    }
     807    $access = wbs_dataverse_connect_access_filter($config, 'single');
     808    if ($access !== '') {
     809        $filter = '(' . $access . ') and ' . $filter;
     810    }
    192811
    193812    $path = $config['entity_set']
    … …  
    196815        . '&$top=1';
    197816
    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));
    199818    if (is_wp_error($result)) {
    200819        return $result;
    … …  
    212831 */
    213832function 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;
    215835    if (is_string($formatted) && $formatted !== '') {
    216836        return $formatted;
    217837    }
    218     $value = $record[$column] ?? '';
     838    $value = $record[$key] ?? '';
    219839    if (is_bool($value)) {
    220840        return $value ? __('Yes', 'wbs-connect-for-dataverse') : __('No', 'wbs-connect-for-dataverse');
    … …  
    224844
    225845/**
    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.
    230851 */
    231852function 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     */
    241875    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 */
     886function 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. */
     924function 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;
    242936}
    243937
    … …  
    293987    return apply_filters('wbs_dataverse_connect_record_vm', $vm, $config, $record);
    294988}
     989
     990/** The visitor-state query vars WordPress should recognize. */
     991function 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}
     999add_filter('query_vars', 'wbs_dataverse_connect_reading_query_vars');
     1000
     1001/** Extra rows of the collection edit form: search, filters, sort, paging, cache. */
     1002function 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}
     1038add_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. */
     1041function 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}
     1049add_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 */
     1059function 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 */
     1077function 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. */
     1089function 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  
    4848 * wbs_dataverse_connect_active_connection). Sets Prefer so responses
    4949 * 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.
    5156 */
    5257function 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');
    5460    $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'] ?? []));
    5662
    5763    if ($ttl > 0) {
    … …  
    6975    $headers = wbs_dataverse_connect_default_headers($token);
    7076    $headers['Prefer'] = 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"';
     77    $headers = array_merge($headers, (array) ($context['headers'] ?? []));
    7178
    7279    $resp = wp_remote_get(wbs_dataverse_connect_api_base($conn) . $path, [
    … …  
    8491            )
    8592        );
    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        }
    8796        return $error;
    8897    }
    … …  
    97106    if ($code >= 400 || !is_array($body)) {
    98107        $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)) {
    100109            wbs_dataverse_connect_health_failure('api', $detail);
    101110        }
    … …  
    103112    }
    104113
    105     wbs_dataverse_connect_health_success();
     114    if ($track) {
     115        wbs_dataverse_connect_health_success();
     116    }
    106117    if ($ttl > 0) {
    107118        set_transient($key, $body, $ttl);
    … …  
    118129 */
    119130function 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 */
     141function 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. */
     146function wbs_dataverse_connect_write($method, $path, $body, $context = []) {
    120147    $conn = wbs_dataverse_connect_active_connection($context);
    121148    $where = (($context['type'] ?? '') === 'form' && isset($context['config']['key']))
    … …  
    130157    $headers = wbs_dataverse_connect_default_headers($token);
    131158    $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,
    134163        'timeout' => 15,
    135164        'headers' => $headers,
  • wbs-connect-for-dataverse/trunk/includes/forms.php

    r3624546 r3684724  
    33
    44/**
    5  * Forms: the generalized write path. A form is a named mapping from
    6  * 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:
    77 *
    88 *   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
    1325 *   require_consent whether the consent checkbox is shown and enforced
    1426 *   relate_to       optional ['nav' => lookup navigation property (schema
    1527 *                   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
    1736 *
    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.
    2141 *
    2242 * Same merge order as collections: the wbs_dataverse_connect_forms filter
    2343 * 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
     51const WBS_DATAVERSE_CONNECT_LOOKUP_CAP = 200;
    2552
    2653/** All forms, keyed by form key. */
    … …  
    6390}
    6491
     92/** Field types a form can render and type, type => label. */
     93function 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 */
     115function 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 */
     138function 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. */
     207function 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 */
     223function 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
    65240/**
    66241 * Validate and normalize one form config. Returns the clean config array
    … …  
    72247    }
    73248
    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 = [];
    86250    if (isset($config['required']) && is_array($config['required'])) {
    87251        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;
    92289        }
    93290    }
    … …  
    102299    }
    103300
     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
    104307    $clean = [
    105308        'key'             => sanitize_key(isset($config['key']) ? $config['key'] : $key),
    106309        'table'           => wbs_dataverse_connect_logical_name($config['table'] ?? ''),
    107310        'entity_set'      => wbs_dataverse_connect_logical_name($config['entity_set'] ?? ''),
     311        'fields'          => array_values($fields),
    108312        'field_map'       => $field_map,
    109         'required'        => array_values(array_unique($required)),
     313        'required'        => $required,
    110314        'require_consent' => !empty($config['require_consent']),
    111315        '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']),
    112321    ];
    113322
    114     if ($clean['key'] === '' || $clean['entity_set'] === '' || empty($clean['field_map'])) {
     323    if ($clean['key'] === '' || $clean['entity_set'] === '' || empty($clean['fields'])) {
    115324        return null;
    116325    }
    … …  
    144353    return ucwords(str_replace(['_', '-'], ' ', $field));
    145354}
     355
     356/** Client context of a form's reads and writes. */
     357function 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 */
     368function 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. */
     383function 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. */
     395function 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. */
     407function 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. */
     416function 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. */
     428function 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. */
     434function 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 */
     444function 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 */
     478function 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. */
     498function 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. */
     509function 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 */
     525function 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 */
     544function 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. */
     562function 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 */
     599function 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 */
     716function 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 */
     739function 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. */
     758function 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. */
     791function 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). */
     799function 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 */
     811function 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 */
     841function 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. */
     910function 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}
     914add_action('wp_enqueue_scripts', 'wbs_dataverse_connect_forms_register_assets');
     915
     916/** Turnstile key options; sanitized on every write path. */
     917function 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}
     929add_action('admin_init', 'wbs_dataverse_connect_forms_admin_init');
     930
     931/** The secret is never pre-filled; an empty submit keeps the stored one. */
     932function 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. */
     940function 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. */
     946function 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 */
     966function 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}
     1074add_action('wbs_dataverse_connect_form_fields', 'wbs_dataverse_connect_forms_admin_rows');
     1075
     1076/** The posted field table rows, normalized, in order. */
     1077function 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 */
     1098function 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}
     1122add_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 */
     1128function 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}
     1158add_filter('wbs_dataverse_connect_save_form', 'wbs_dataverse_connect_forms_save', 10, 2);
  • wbs-connect-for-dataverse/trunk/includes/rest.php

    r3684633 r3684724  
    33
    44/**
    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.
    811 */
    912
    … …  
    2124}
    2225
     26/** A validation failure as the visitor sees it. */
     27function wbs_dataverse_connect_rest_invalid($message) {
     28    return new WP_REST_Response(['error' => 'validation_error', 'message' => $message], 400);
     29}
     30
    2331function wbs_dataverse_connect_handle_submit(WP_REST_Request $req) {
    2432    $nonce = $req->get_header('x_wp_nonce');
    … …  
    4856        return new WP_REST_Response(['error' => 'unknown_form'], 400);
    4957    }
    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 === '') {
    6583            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'),
    6886            ], 400);
    6987        }
    7088    }
    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'));
    9096    }
    9197
    9298    /**
    93      * Last stop before the create. Use this to map values onto typed
    94      * 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.
    95101     *
    96      * @param array $record  Column => value payload for the Web API create.
     102     * @param array $record  Column => value payload for the Web API write.
    97103     * @param array $config  The normalized form config.
    98104     * @param array $values  The sanitized submitted values by field name.
    … …  
    100106    $record = apply_filters('wbs_dataverse_connect_form_record', $record, $config, $values);
    101107
    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
    103119    if (is_wp_error($result)) {
    104120        // Log the Dataverse detail for the admin, keep the visitor message generic.
    105         error_log('[wbs-connect-for-dataverse] form "' . $config['key'] . '" create failed: ' . $result->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
     121        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
    106122        return new WP_REST_Response([
    107123            'error'   => 'send_error',
    … …  
    111127
    112128    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. */
     157function 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. */
     170function 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    }
    117177    return __('Thank you. Your message has been sent, we will get back to you shortly.', 'wbs-connect-for-dataverse');
    118178}
    119179
    120180/**
    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 */
     186function 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.
    124233 */
    125234function 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 !== '') {
    136274            $collection = wbs_dataverse_connect_collection($config['relate_to']['collection']);
    137275            if ($collection !== null) {
    … …  
    144282}
    145283
     284/** The visitor's address as seen by PHP, '' when unknown. */
     285function wbs_dataverse_connect_client_ip() {
     286    return isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
     287}
     288
    146289/** Sliding per-IP counter in a transient. */
    147290function 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();
    149292    if ($ip === '') {
    150293        return true;
  • wbs-connect-for-dataverse/trunk/includes/shortcodes.php

    r3684633 r3684724  
    44/**
    55 * 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)
    79 *   [wbsdvc_record name="<key>" slug="..." form="<form key>"]   (or the page reads ?dvc=<slug-or-id>)
    810 *   [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.
    916 */
    1017
    … …  
    1825}
    1926
    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 */
    2132function wbs_dataverse_connect_parse_collection_atts($atts) {
    2233    $atts = shortcode_atts([
    2334        'name'        => '',
    24         'limit'       => 12,
     35        'limit'       => '',
     36        'per_page'    => '',
    2537        'columns'     => 3,
    2638        'detail_page' => '',
    2739        'form'        => '',
     40        'search'      => '',
     41        'filters'     => '',
     42        'sort'        => '',
    2843    ], $atts, 'wbsdvc_collection');
    2944
    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);
    3250
    3351    $columns = (int) $atts['columns'];
    … …  
    3654    return [
    3755        'name'        => sanitize_key($atts['name']),
    38         'limit'       => $limit,
     56        'limit'       => $per_page,
     57        'per_page'    => $per_page,
    3958        'columns'     => $columns,
    4059        'detail_page' => trim((string) $atts['detail_page']),
    4160        'form'        => sanitize_key($atts['form']),
     61        'search'      => trim((string) $atts['search']),
     62        'filters'     => trim((string) $atts['filters']),
     63        'sort'        => trim((string) $atts['sort']),
    4264    ];
    4365}
    … …  
    97119    }
    98120    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 */
     130function 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 */
     212function 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    ]);
    99229}
    100230
    … …  
    122252        );
    123253    }
    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
    129272    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>';
    147293}
    148294
    … …  
    165311    }
    166312
     313    if (!wbs_dataverse_connect_can_view($config, 'record')) {
     314        return wbs_dataverse_connect_denied_notice($config, 'record');
     315    }
    167316    $slug = trim((string) $atts['slug']);
    168317    if ($slug === '') {
    … …  
    217366    }
    218367
     368    if (!wbs_dataverse_connect_can_view($config, 'form')) {
     369        return wbs_dataverse_connect_denied_notice($config, 'form');
     370    }
     371
    219372    // Resolve a slug to the record id so the submission can be bound to it.
    220373    $record_id = '';
    221374    $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);
    224380        if ($collection !== null) {
    225381            $record = wbs_dataverse_connect_get_record($collection, $slug);
  • wbs-connect-for-dataverse/trunk/readme.txt

    r3684633 r3684724  
    33Tags: dataverse, dynamics-365, microsoft, crm, integration
    44Requires at least: 6.0
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.4.0
     7Stable tag: 1.5.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1919* **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.
    2020* **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.
    2124* **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.
    2328* **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.
    2429* **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.
    … …  
    3338**External services**
    3439
    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.
     40This 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.
    3641
    37421. **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.
    38432. **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.
    3944
    40 These services are governed by Microsoft's terms and privacy statement:
     453. **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
     47Webhooks you configure yourself post to the URLs you enter and nowhere else.
     48
     49The Microsoft services are governed by Microsoft's terms and privacy statement:
    4150
    4251* Terms: [https://www.microsoft.com/licensing/terms](https://www.microsoft.com/licensing/terms)
    … …  
    49581. Install and activate the plugin.
    50592. 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.
     603. 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.
    52614. 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).
    53625. 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.
     636. 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`.
    5564
    5665== Frequently Asked Questions ==
    … …  
    8089Any 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.
    8190
     91= Can visitors edit records? =
     92
     93Only 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
    8295= Are the templates customizable? =
    8396
    … …  
    93106
    94107== 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.
    95120
    96121= 1.4.0 =
    … …  
    116141== Upgrade Notice ==
    117142
     143= 1.5.0 =
     144Search, 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
    118146= 1.4.0 =
    119147Adds 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  
    33 * Write-back form. Override by copying to <theme>/wbs-connect-for-dataverse/.
    44 * Available: $config (normalized form config), $record_id (GUID of the
    5  * related record or ''), $rest_url, $nonce.
     5 * related or bound record, or ''), $rest_url, $nonce.
    66 *
    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.
    912 */
    1013if (!defined('ABSPATH')) { exit; }
    1114
    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
     17if (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}
     21if (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}
     25if (!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'];
    1331?>
    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"
    1533      action="<?php echo esc_url($rest_url); ?>"
    1634      data-dvc-rest="<?php echo esc_url($rest_url); ?>"
    1735      data-dvc-nonce="<?php echo esc_attr($nonce); ?>"
     36      data-dvc-mode="<?php echo esc_attr($wbs_dataverse_connect_view['mode']); ?>"
    1837      data-dvc-sending="<?php echo esc_attr__('Sending...', 'wbs-connect-for-dataverse'); ?>"
    1938      data-dvc-error="<?php echo esc_attr__('Your message could not be sent. Please try again later.', 'wbs-connect-for-dataverse'); ?>">
    2039    <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']); ?>">
    2241    <p class="dvc-form__hp" aria-hidden="true">
    2342        <label><?php echo esc_html__('Website', 'wbs-connect-for-dataverse'); ?><input type="text" name="_dvc_website" tabindex="-1" autocomplete="off"></label>
    2443    </p>
    2544
    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;
    3057    ?>
     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
    3185        <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') : ?>
    3488                <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' : ''; ?>>
    45123            <?php endif; ?>
    46124        </p>
    … …  
    56134    <?php endif; ?>
    57135
     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
    58140    <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>
    60142    </p>
    61143    <p class="dvc-form__status" role="status" aria-live="polite"></p>
  • wbs-connect-for-dataverse/trunk/templates/record-detail.php

    r3620392 r3684724  
    22/**
    33 * 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).
    68 */
    79if (!defined('ABSPATH')) { exit; }
    … …  
    3335    <?php endif; ?>
    3436
     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
    3561    <?php if (!empty($form)) : ?>
    3662        <section class="dvc-detail__form">
  • wbs-connect-for-dataverse/trunk/uninstall.php

    r3684633 r3684724  
    2323    'wbsdvc_health',
    2424    'wbsdvc_review',
     25    'wbsdvc_webhooks',
     26    'wbs_dataverse_connect_turnstile_site_key',
     27    'wbs_dataverse_connect_turnstile_secret_key',
    2528];
    2629foreach ($wbs_dataverse_connect_options as $wbs_dataverse_connect_option) {
  • wbs-connect-for-dataverse/trunk/wbs-connect-for-dataverse.php

    r3684633 r3684724  
    44 * Plugin URI: https://woods.consulting/wbs-dataverse-connect-for-wordpress/
    55 * 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.0
     6 * Version: 1.5.0
    77 * Author: WOODS Business Solutions
    88 * Author URI: https://woods.consulting
    … …  
    1616if (!defined('ABSPATH')) { exit; }
    1717
    18 define('WBS_DATAVERSE_CONNECT_VERSION', '1.4.0');
     18define('WBS_DATAVERSE_CONNECT_VERSION', '1.5.0');
    1919define('WBS_DATAVERSE_CONNECT_PATH', plugin_dir_path(__FILE__));
    2020define('WBS_DATAVERSE_CONNECT_URL',  plugin_dir_url(__FILE__));
    … …  
    2323require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/health.php';
    2424require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/dataverse.php';
     25require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/metadata.php';
    2526require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/collections.php';
    2627require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/forms.php';
    … …  
    2829require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/shortcodes.php';
    2930require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/rest.php';
     31require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/media.php';
     32require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/webhooks.php';
     33require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/blocks.php';
    3034require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/admin.php';
    3135require_once WBS_DATAVERSE_CONNECT_PATH . 'includes/review.php';
    … …  
    5054add_action('admin_init', 'wbs_dataverse_connect_admin_init');
    5155wbs_dataverse_connect_review_boot();
     56wbs_dataverse_connect_blocks_boot();
    5257add_action('admin_post_wbs_dataverse_connect_flush', 'wbs_dataverse_connect_handle_flush');
    5358add_action('admin_post_wbs_dataverse_connect_test', 'wbs_dataverse_connect_handle_test');
Note: See TracChangeset for help on using the changeset viewer.