-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGoogle_Address_Autocomplete.php
More file actions
1098 lines (991 loc) · 46.9 KB
/
Copy pathGoogle_Address_Autocomplete.php
File metadata and controls
1098 lines (991 loc) · 46.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php namespace johnbarrett\Google_Address_Autocomplete;
use ExternalModules\AbstractExternalModule;
// Required explicitly rather than relying on the framework. REDCap derives the
// MAIN class file name from the namespace, but whether it autoloads additional
// classes in that namespace is not documented — and a wrong guess is a fatal on
// every survey page, so this does not rely on finding out.
require_once __DIR__ . '/AddressComponent.php';
require_once __DIR__ . '/AddressFieldSet.php';
class Google_Address_Autocomplete extends AbstractExternalModule
{
// Every value emitted into an inline <script> goes through json_encode with these
// flags. JSON_HEX_TAG is the one that matters most: it stops a "</script>" sequence
// inside any setting from closing the block early. The rest cover the quote
// characters, so the result is always a safe JS string literal.
private const JSON_FLAGS = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
// What the participant types is relayed to Google to generate the predictions, which
// has to be disclosed to them on the form rather than only to the project
// administrator in the README. The notice therefore shows by DEFAULT; an
// administrator can reword it, or suppress it only if their consent form already
// covers the disclosure.
private const DEFAULT_PRIVACY_NOTICE = 'Address suggestions come from Google. What you type in this box is sent to Google Maps to generate them.';
// Hook methods must be named exactly after the REDCap hook they implement. From
// framework version 12 onward these run automatically and config.json carries no
// "permissions" block; the legacy hook_* names no longer fire at all.
public function redcap_survey_page($project_id, $record, $instrument, $event_id, $group_id, $survey_hash, $response_id, $repeat_instance) {
$this->addAddressAutoCompletion($project_id, $instrument);
}
public function redcap_data_entry_form($project_id, $record, $instrument, $event_id, $group_id, $repeat_instance) {
$this->addAddressAutoCompletion($project_id, $instrument);
}
/**
* Emit the widget for every address field set that applies to this page.
*
* The API key, the bootstrap loader and the privacy notice are project-wide and are
* emitted at most ONCE. Everything else is per-set: each set gets its own IIFE, its
* own element-id prefix, and its own copy of the behaviour. Two sets on one page
* therefore share nothing but the Google Maps API itself.
*/
private function addAddressAutoCompletion($project_id, $instrument): void {
$key = $this->getProjectSetting('google-api-key', $project_id);
if (!$key) { return; }
$sets = $this->getActiveSets($project_id, $instrument);
if (!$sets) { return; }
if ($this->getProjectSetting('import-google-api', $project_id)) {
$this->emitBootstrapLoader($key);
}
$this->emitStyles();
$privacyNoticeText = $this->resolvePrivacyNotice($project_id);
foreach ($sets as $set) {
$this->emitSetScript($set, $privacyNoticeText);
}
}
/**
* The configured address field sets that should run on this instrument.
*
* A misconfigured set is skipped and logged, never fatal: one bad set must not cost
* the participant the other address boxes on the form.
*
* Note the defensive reads. Sub-settings are stored flat, one parallel array per
* child key, so a child added to config.json after a project was configured simply
* does not appear in the returned instance array — every child must be existence
* checked rather than assumed.
*
* @return AddressFieldSet[]
*/
private function getActiveSets($project_id, $instrument): array {
$sets = $this->getSubSettings('address-set', $project_id);
if (!is_array($sets)) { return []; }
$active = [];
$claimedSources = [];
$claimedDestinations = [];
foreach ($sets as $index => $raw) {
if (!is_array($raw)) { continue; }
// The CONFIGURED position, not the position among the active sets, so a set's
// element ids stay the same no matter which other sets qualify on a page.
$set = AddressFieldSet::fromSubSetting($raw, $index);
if (!$set->isActive()) { continue; }
// Instrument scope. Blank means "any form containing the source field", which
// the client-side guard in the emitted IIFE still enforces.
if (!$set->appliesTo((string)$instrument)) { continue; }
// Two sets cannot share a source field: both would wrap and hide the same
// input, and only one widget could survive. Skip the later one.
$source = $set->sourceKey();
if (isset($claimedSources[$source])) {
$this->log(sprintf(
'Address Autocomplete: skipped set #%d on instrument "%s" because its '
. 'Autocomplete Field "%s" is already used by set #%d.',
$index + 1, $instrument, $source, $claimedSources[$source] + 1
));
continue;
}
$claimedSources[$source] = $index;
// A shared DESTINATION field is bad configuration but not fatal — the element
// simply ends up owned by whichever set writes last. Warn and carry on rather
// than silently dropping a set the administrator can see is configured.
foreach ($this->destinationFieldNames($set) as $fieldName) {
if (isset($claimedDestinations[$fieldName])) {
$this->log(sprintf(
'Address Autocomplete: set #%d and set #%d both write to field "%s" on '
. 'instrument "%s". One of them will overwrite the other.',
$index + 1, $claimedDestinations[$fieldName] + 1, $fieldName, $instrument
));
} else {
$claimedDestinations[$fieldName] = $index;
}
}
$active[] = $set;
}
return $active;
}
/**
* Google component type => REDCap field name, for the set's mapped components.
* Latitude and longitude are excluded; they are looked up by name.
*
* @return array<string,string>
*/
private function destinationFields(AddressFieldSet $set): array {
$fields = [];
foreach (AddressComponent::cases() as $component) {
$fieldName = $set->{$component->property()};
if ($fieldName !== '') { $fields[$component->value] = $fieldName; }
}
return $fields;
}
/**
* Every REDCap field this set writes to, including latitude and longitude — used to
* detect two sets fighting over one field.
*
* @return string[]
*/
private function destinationFieldNames(AddressFieldSet $set): array {
$names = [];
$properties = array_map(
static fn(AddressComponent $component): string => $component->property(),
AddressComponent::cases()
);
// Lat/lng are not AddressComponent cases — they are looked up by field name
// rather than by googleSearch_* id — but they are still written to.
$properties = array_merge($properties, ['latitude', 'longitude']);
foreach ($properties as $property) {
$fieldName = $set->$property;
if ($fieldName !== '') { $names[$fieldName] = true; }
}
return array_keys($names);
}
/**
* The disclosure shown under every widget. Empty string means "emit no notice".
*/
private function resolvePrivacyNotice($project_id): string {
if ($this->getProjectSetting('hide-privacy-notice', $project_id)) { return ''; }
$custom = trim((string)$this->getProjectSetting('privacy-notice', $project_id));
return ($custom !== '') ? $custom : self::DEFAULT_PRIVACY_NOTICE;
}
/**
* Encode a single value as a JS literal. Never returns an empty string: emitting
* "var x = ;" is a syntax error that kills the whole inline script, so json_encode()
* failure falls back to an empty string literal.
*/
private function jsValue($value): string {
$json = json_encode((string)$value, self::JSON_FLAGS);
return ($json === false) ? '""' : $json;
}
/**
* Turn a comma-separated setting into a JS array literal. Same contract as above —
* json_encode() failure falls back to an empty array, never to "".
*/
private function jsArray($csv): string {
$parts = array_map(trim(...), explode(',', (string)$csv));
$parts = array_values(array_filter($parts, static fn(string $part): bool => $part !== ''));
$json = json_encode($parts, self::JSON_FLAGS);
return ($json === false) ? '[]' : $json;
}
/**
* Same contract again, falling back to an empty object.
*/
private function jsObject($map): string {
$json = json_encode((object)$map, self::JSON_FLAGS);
return ($json === false) ? '{}' : $json;
}
/**
* Load Google Maps using the official inline bootstrap. This defines
* google.maps.importLibrary immediately (synchronously) and defers the actual network
* load until importLibrary() is called. It is safe even if another module has already
* loaded the API, and is emitted at most once per page however many sets are active.
*/
private function emitBootstrapLoader($key): void {
// json_encode, NOT htmlspecialchars: this lands in a JavaScript string context,
// and HTML entities are not decoded inside <script>, so an html-escaped key would
// arrive at Google corrupted rather than safe. json_encode supplies the
// surrounding quotes itself.
$keyJs = $this->jsValue($key);
// The loader body is a nowdoc (<<<'SCRIPT') so PHP does NOT interpolate JS
// template literals like ${c} as PHP variables. A nowdoc cannot carry the key, so
// it arrives as an IIFE ARGUMENT rather than on a global. That matters: module
// JavaScript must not add anything to global scope, or it can collide with
// another module running on the same page. (The window.google namespace the
// loader creates is Google's own, not ours.)
echo '<script>(function(__addressAutoKey){';
echo <<<'SCRIPT'
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({key:__addressAutoKey,v:"weekly"});
SCRIPT;
echo '})(' . $keyJs . ');</script>';
}
/**
* Styles for the wrapper each search box is placed in. Emitted once per page.
*
* These are keyed on a CLASS, never an id: a page can carry several wrappers, and a
* repeated id is invalid HTML that would make the first match win every lookup.
*/
private function emitStyles(): void {
?>
<style>
.gaa-location-field { position: relative; }
.gaa-location-field gmp-place-autocomplete {
width: 100%;
font-size: 13px;
}
.gaa-location-field .gaa-privacy-notice {
font-size: 11px;
line-height: 1.4;
color: #666;
margin-top: 3px;
}
</style>
<?php
}
/**
* Emit one self-contained IIFE for one address field set.
*
* Settings are baked in at emit time, not read at runtime, and optional features are
* compiled out entirely — an unconfigured feature emits no code and cannot misfire.
*
* Nothing here may carry a fixed DOM id. Every id the script creates or looks up is
* built from autocompletePrefix, which is unique to this set; that is the whole
* mechanism by which two sets on one page stay out of each other's way.
*/
private function emitSetScript(AddressFieldSet $set, string $privacyNoticeText): void {
$destinationFields = $this->destinationFields($set);
?>
<script>
(function() {
// Unique to this address field set. Every element id the script assigns or
// looks up is built from it, which is what keeps two sets on one page from
// writing into each other's fields.
var autocompletePrefix = <?php echo $this->jsValue($set->elementPrefix()); ?>;
var autocompleteFieldName = <?php echo $this->jsValue($set->autocomplete); ?>;
// Console identity for this set. Purely diagnostic.
var logPrefix = '[Address Autocomplete ' + <?php echo $this->jsValue($set->label()); ?> + '] ';
// REDCap field names for the address components, keyed by Google
// component type. Emitted as one JSON object rather than interpolated
// into a selector per field, so no setting value ever lands in JS
// unescaped.
var destinationFields = <?php echo $this->jsObject($destinationFields); ?>;
var latitudeFieldName = <?php echo $this->jsValue($set->latitude); ?>;
var longitudeFieldName = <?php echo $this->jsValue($set->longitude); ?>;
// Disclosure shown under the search box. Empty when the administrator has
// suppressed it. See addPrivacyNotice().
var privacyNoticeText = <?php echo $this->jsValue($privacyNoticeText); ?>;
/**
* Look a field up by its REDCap name.
*
* The quote/backslash escape matters: a field name is interpolated into
* an attribute-equals selector, where an unescaped quote would end the
* selector string and throw a jQuery syntax error, taking the whole
* IIFE down with it.
*/
function byName(name) {
if (!name) { return $(); }
return $('[name="' + String(name).replace(/["\\]/g, '\\$&') + '"]');
}
/**
* Enable or disable every field this set writes to.
*
* Disabled on load, then re-enabled per component as a selection fills
* it in. showAutocompleteError() re-enables the whole set instead: when
* the widget never loads nothing will ever fill these in, and a
* disabled input is not submitted, so leaving them disabled would make
* manual entry save blank.
*
* Iterates destinationFields rather than componentForm: place_name is
* a destination but is deliberately absent from componentForm (it is
* read from place.displayName, not from addressComponents), and would
* otherwise be left stuck disabled.
*/
function setDestinationFieldsDisabled(disabled) {
$.each(destinationFields, function(componentType, fieldName) {
byName(fieldName).prop('disabled', disabled);
});
// Unmapped lat/lng are '', and byName('') is an empty set — a no-op.
byName(latitudeFieldName).prop('disabled', disabled);
byName(longitudeFieldName).prop('disabled', disabled);
}
// Raw text the user typed into the search box, kept so the unit /
// apartment number can be recovered when Google omits it. See
// recoverUnitFromText().
var lastTypedText = '';
// Whether the address field currently holds a formatted address that
// THIS session's autocomplete wrote, rather than one saved against the
// record earlier. The degrade path needs the distinction: a value from
// a previous save is stale the moment the participant starts typing a
// new address, whereas one written by a selection this session is the
// good address and must not be replaced by a half-typed fragment.
var fieldHoldsSelectedAddress = false;
// Component mapping: Google address type -> format preference
//
// Do NOT add subpremise here. This object doubles as the registry of
// "which components have a destination field", and every entry is
// cleared through updateValue(autocompletePrefix + type) on each
// selection. No googleSearch_*subpremise element is ever created, so an
// entry here would only log "Could not find the element" every time.
// The unit is captured by extractUnitParts() instead.
// The keys are Google address component TYPE names; the values are the
// Place API property to read off the component.
var componentForm = {
<?php foreach (AddressComponent::addressComponents() as $component): ?>
<?php echo ($set->{$component->property()} !== '' ? $component->value . ": '" . $component->format() . "'," : ""); ?>
<?php endforeach; ?>
};
$(document).ready(function() {
// Guard — only proceed if this set's autocomplete target field exists
// on this particular instrument / form page. The set may also have
// been scoped to specific instruments server-side; this covers the
// case where it was not.
var $autocompleteField = byName(autocompleteFieldName);
if ($autocompleteField.length === 0) {
return; // Field not on this form; do nothing.
}
// Set up component destination fields: assign the ids updateValue()
// looks them up by. Lat/lng get no googleSearch_* id — they are
// found by name instead.
$.each(destinationFields, function(componentType, fieldName) {
byName(fieldName).attr('id', autocompletePrefix + componentType);
});
// Disable every destination field, lat/lng included, until a
// prediction is chosen or autocomplete fails to load.
setDestinationFieldsDisabled(true);
// Wrap original field and hide it; the PlaceAutocompleteElement will
// replace it visually. The wrapper is identified by CLASS, not id —
// several sets can be wrapped on one page.
$autocompleteField.wrap('<div class="gaa-location-field"></div>');
$autocompleteField.hide();
// Initialize the autocomplete once the Google Maps API is available
initAutocomplete($autocompleteField);
});
/**
* Polls until google.maps.importLibrary exists, rejecting after the
* timeout (default 15 s).
*
* The bootstrap loader defines importLibrary synchronously, so this
* resolves immediately when "Import Google API" is enabled. The polling is
* for the other case: another module supplies the API, possibly after
* $(document).ready has already run.
*/
function waitForImportLibrary(timeoutMs) {
timeoutMs = timeoutMs || 15000;
return new Promise(function(resolve, reject) {
function ready() {
return typeof google !== 'undefined' && google.maps &&
typeof google.maps.importLibrary === 'function';
}
if (ready()) { resolve(); return; }
var elapsed = 0;
var interval = 150;
var poll = setInterval(function() {
elapsed += interval;
if (ready()) {
clearInterval(poll);
resolve();
} else if (elapsed >= timeoutMs) {
clearInterval(poll);
reject(new Error(
'Google Maps did not become available within ' +
(timeoutMs / 1000) + 's. A browser extension (ad blocker) ' +
'may be blocking requests to googleapis.com.'
));
}
}, interval);
});
}
/**
* Load the Places library. This is the only Google library the module
* imports — see initWithNewApi() and applyGeolocationBias(), which are
* deliberately written to need nothing from `maps` or `core`.
*/
function loadPlacesLibrary() {
return waitForImportLibrary().then(function() {
return google.maps.importLibrary('places');
});
}
/**
* Initialise autocomplete on the given field using PlaceAutocompleteElement
* (Places API New). If that class is absent the API key almost certainly
* does not have Places API (New) enabled — show the error rather than
* degrading to something that looks broken but reports nothing.
*/
function initAutocomplete($field) {
loadPlacesLibrary()
.then(function(placesLib) {
if (typeof placesLib.PlaceAutocompleteElement !== 'function') {
showAutocompleteError($field,
'PlaceAutocompleteElement is not available. Check that ' +
'Places API (New) is enabled for this API key.'
);
return;
}
console.log(logPrefix + 'Using Places API (New) — PlaceAutocompleteElement');
initWithNewApi(placesLib.PlaceAutocompleteElement, $field);
})
.catch(function(err) {
console.error(logPrefix + 'Failed to initialise.', err);
// initWithNewApi() runs inside this promise chain, so a throw
// AFTER the widget was inserted lands here too. In that case
// the widget is on the form and must be torn down — otherwise
// un-hiding $field would leave two address boxes stacked.
if (liveAutocomplete) {
degradeToManualEntry($field,
'Falling back to manual entry after the widget failed post-insertion: ' +
(err.message || 'unknown error'));
return;
}
showAutocompleteError($field, err.message || 'Could not load Google Maps.');
});
}
// Set once the widget has been given up on, so the degrade path runs at
// most once however many times it is reached. gmp-error in particular
// can fire repeatedly, and a second run would stack another banner.
var autocompleteFailed = false;
// The live widget, once it is actually in the DOM — NOT merely
// constructed. Failure handling has to tell those apart: a widget that
// was inserted has to be torn down and may hold typed text, whereas one
// that never got that far leaves nothing behind to clean up.
var liveAutocomplete = null;
// How many denied requests in a row to tolerate before giving up on
// the widget. A permanent cause — bad key, referrer restriction,
// billing off — denies every request, and requests go out per
// keystroke, so this is reached within about a second of typing and
// the participant is told almost as fast as before. What it buys is
// that a momentary denial no longer costs them autocomplete for the
// rest of the page.
var MAX_CONSECUTIVE_ERRORS = 3;
// Denials further apart than this are not the same burst. Without the
// window the count is "consecutive" in name only: gmp-select is the
// only other reset and it needs the participant to actually pick a
// prediction, so three unrelated blips minutes apart would add up to
// a degrade.
var ERROR_BURST_WINDOW_MS = 10000;
var consecutiveErrors = 0;
var lastErrorAt = 0;
// Shown when the API never loaded at all — the usual cause is a blocked
// request, which reloading after allow-listing does fix.
var LOAD_FAILURE_MESSAGE =
'⚠ Address autocomplete could not load. ' +
'If you have an ad blocker, please allow <b>googleapis.com</b> and reload. ' +
'You can still type the address manually.';
// Shown when Google loaded but denied the request. Deliberately does NOT
// mention ad blockers or reloading: the cause is server-side (API key,
// billing, referrer restriction, an invalid filter value) and reloading
// would only send the participant chasing a browser problem they do not
// have.
var REQUEST_DENIED_MESSAGE =
'⚠ Address suggestions are unavailable right now. ' +
'Please type your address manually.';
/**
* Degrade to plain manual entry and tell the participant.
*
* Every failure path reaches here — the missing PlaceAutocompleteElement
* branch, the load/timeout catch in initAutocomplete(), and the
* gmp-error rejection — so the handling lives in this one place. The
* participant-facing message varies by cause and is passed in; `detail`
* is developer-facing and only ever goes to the console.
*/
function showAutocompleteError($field, detail, message) {
if (autocompleteFailed) { return; }
autocompleteFailed = true;
$field.show(); // un-hide the original text input so the user can still type
$field.attr('placeholder', 'Address autocomplete unavailable — type manually');
// Nothing will ever fill these in now, so hand them back to the
// participant. Without this they stay disabled, and a disabled
// input is not submitted — manual entry would silently save blank.
setDestinationFieldsDisabled(false);
$field.closest('.gaa-location-field').prepend(
'<div style="color:#c00;font-size:12px;margin-bottom:4px;">' +
(message || LOAD_FAILURE_MESSAGE) +
'</div>'
);
console.warn(logPrefix + detail);
}
/**
* Disclose to the participant that what they type goes to Google.
*
* Called only from the success path in initWithNewApi(): if the widget
* never loads, nothing is sent to Google and there is nothing to disclose.
*
* The text is inserted with .text(), not .html(), so administrator-supplied
* wording is treated as text and can never inject markup — stronger than
* escaping, since no HTML parsing happens at all.
*/
function addPrivacyNotice($field) {
if (!privacyNoticeText) { return; }
var $wrapper = $field.closest('.gaa-location-field');
if ($wrapper.find('.gaa-privacy-notice').length) { return; }
$('<div></div>')
.addClass('gaa-privacy-notice')
.text(privacyNoticeText)
.appendTo($wrapper);
}
/**
* Build the PlaceAutocompleteElement and wire up its events.
*/
function initWithNewApi(PlaceAutocompleteElement, $field) {
// The prediction filters are assigned as properties inside try/catch so
// that a bad setting value degrades to unfiltered predictions instead of
// aborting initialisation and leaving a plain text input on the form.
var placeAutocomplete = new PlaceAutocompleteElement();
try {
var regionCodes = <?php echo $this->jsArray($set->regionCodes); ?>;
var primaryTypes = <?php echo $this->jsArray($set->primaryTypes); ?>;
if (regionCodes.length) { placeAutocomplete.includedRegionCodes = regionCodes; }
if (primaryTypes.length) { placeAutocomplete.includedPrimaryTypes = primaryTypes; }
} catch (e) {
console.warn(logPrefix + 'Could not apply prediction filters; predictions will be unfiltered.', e);
}
placeAutocomplete.id = autocompletePrefix + 'autocomplete';
placeAutocomplete.setAttribute('placeholder', 'Enter your address here');
// Backend rejection (bad API key, billing off, referrer restriction,
// an invalid filter value, an exhausted quota, a transient 5xx).
//
// The teardown below is what stops the failure being INVISIBLE and
// total: without it the widget stays on the form looking usable,
// gmp-select never fires, nothing is ever written to the source
// field and the destination fields stay disabled — the participant
// fills the form in and saves nothing at all.
//
// But it used to happen on the FIRST error, which made a momentary
// denial permanent: the widget was gone for the rest of the page
// even though the next request would have succeeded. So a short
// burst is tolerated first. The event carries no documented, stable
// indication of WHY the request was denied, so a permanent cause
// cannot be told from a transient one here — every error is counted
// the same way and the raw event is logged for whoever is
// debugging. Do NOT branch on a guessed detail property: a
// condition that is silently never true reads like working code
// forever.
placeAutocomplete.addEventListener('gmp-error', function(e) {
var now = Date.now();
if (now - lastErrorAt > ERROR_BURST_WINDOW_MS) { consecutiveErrors = 0; }
lastErrorAt = now;
consecutiveErrors++;
if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) {
console.warn(logPrefix + 'Google denied the request (' + consecutiveErrors +
' of ' + MAX_CONSECUTIVE_ERRORS + '); leaving the widget in place.', e);
return;
}
console.error(logPrefix + 'Google denied ' + consecutiveErrors +
' consecutive requests. Check the API key and any prediction filter values.', e);
degradeToManualEntry($field,
'Falling back to manual entry after Google denied ' +
consecutiveErrors + ' consecutive requests.');
});
// Insert the new element into the wrapper, before the hidden original
// field. Recorded as live from this point on: anything that fails
// after this must tear the widget down, not just un-hide $field.
$field.before(placeAutocomplete);
liveAutocomplete = placeAutocomplete;
// Disclose the transfer to Google now that the widget is really live.
addPrivacyNotice($field);
// Record what the user actually types, for unit recovery.
// The widget's shadow root is closed, but `input` events are composed
// so they cross it and retarget to the host element, and `value` is a
// documented public property. isTrusted filters out the value the
// widget writes back itself once a prediction is chosen.
placeAutocomplete.addEventListener('input', function(e) {
if (!e.isTrusted) { return; }
var typed = placeAutocomplete.value || '';
lastTypedText = typed;
// Emptying the box clears every destination field, so a cleared
// search can never leave the previous address behind.
if (typed === '') { fillInAddress(null, $field); }
});
// Apply geolocation bias to improve relevance
applyGeolocationBias(placeAutocomplete);
// The modern event is "gmp-select"; the event carries a placePrediction
// which must be converted to a Place via .toPlace(), then fetched.
placeAutocomplete.addEventListener('gmp-select', async function(event) {
// A prediction was served and chosen, so whatever denied the
// earlier requests has cleared. Reset before the fetch:
// fetchFields() failing is a different request's problem, and
// tying the reset to it would leave the count armed after a
// prediction request that demonstrably worked. Typing is not
// evidence Google answered, so the input listener does not
// reset it.
consecutiveErrors = 0;
var place = null;
try {
var prediction = event.placePrediction;
if (prediction && typeof prediction.toPlace === 'function') {
place = prediction.toPlace();
} else if (event.place) {
place = event.place;
}
if (place) {
await place.fetchFields({
fields: ['addressComponents', 'location', 'formattedAddress', 'displayName']
});
}
} catch (e) {
console.warn(logPrefix + 'Could not process place.', e);
place = null;
}
fillInAddress(place, $field);
});
}
/**
* Retire a widget that made it onto the form but cannot be used, handing
* the form back to the participant as plain manual entry.
*
* Two callers, both with a widget already inserted: the gmp-error
* rejection, and a throw from initWithNewApi() after insertion. Unlike
* the never-loaded paths there is live state to deal with — a widget in
* the DOM, and text the participant has typed into it that exists
* NOWHERE else — so both are handled before the shared degrade path runs.
*
* The privacy notice is deliberately left in place. Requests may already
* have gone to Google, so the disclosure is still accurate —
* addPrivacyNotice()'s reasoning about "nothing was sent" covers the
* never-loaded case, which is not this one.
*/
function degradeToManualEntry($field, detail) {
// GUARD ONLY — do not set autocompleteFailed here. The flag is set
// by showAutocompleteError() at the end of this function; setting it
// up front would make that call early-return, and the whole degrade
// would silently do nothing.
if (autocompleteFailed) { return; }
var placeAutocomplete = liveAutocomplete;
// Rescue whatever the participant typed. It only exists inside the
// widget: $field is written on the gmp-select path, which never
// ran.
//
// The guard used to be "$field is empty". That held on a new form
// and failed on an edit form, where $field arrives pre-populated
// with the address saved last time: a failure mid-typing threw the
// typed text away and left the stale address on screen, looking
// like the participant's own entry. The question is not whether
// $field holds something, it is whether it holds something this
// session's autocomplete put there — which is the one case worth
// protecting, because a half-typed fragment is worse than a
// formatted address the participant already chose.
var typed = '';
try {
typed = (placeAutocomplete && placeAutocomplete.value) || lastTypedText || '';
} catch (e) {
typed = lastTypedText || '';
}
if (typed && !fieldHoldsSelectedAddress) {
$field.val(typed);
$field.change();
}
// Remove rather than hide, so the dead widget cannot sit above the
// text input as a second address box, and so its input/gmp-select
// listeners cannot fire on an element that is no longer in play.
try {
if (placeAutocomplete) { placeAutocomplete.remove(); }
} catch (e) {
console.warn(logPrefix + 'Could not remove the autocomplete widget.', e);
}
liveAutocomplete = null;
showAutocompleteError($field, detail, REQUEST_DENIED_MESSAGE);
}
/**
* Bias the autocomplete results toward the user's current location.
*
* locationBias accepts a CircleLiteral ({center, radius}) directly, so no
* google.maps.Circle is constructed. That matters: Circle belongs to the
* `maps` library, which this module never imports, so referencing it here
* would throw inside the geolocation callback where nothing catches it.
*/
function applyGeolocationBias(placeAutocomplete) {
if (!navigator.geolocation) { return; }
navigator.geolocation.getCurrentPosition(function(position) {
placeAutocomplete.locationBias = {
center: {
lat: position.coords.latitude,
lng: position.coords.longitude
},
radius: position.coords.accuracy
};
}, function(err) {
console.log(logPrefix + 'Geolocation unavailable; predictions will not be location-biased.', err);
});
}
/**
* Resolve an updateValue() id to the element it refers to.
*
* Latitude and longitude are the only two destinations with no
* googleSearch_* id — they are looked up by field NAME instead, which
* is exactly why they were the two the re-enable was missed on.
* Resolving both kinds here is what lets updateAndEnable() work for
* either without having to know which it was handed.
*/
function fieldElement(id) {
if (id === 'latitude') { return byName(latitudeFieldName); }
if (id === 'longitude') { return byName(longitudeFieldName); }
return $('#' + id);
}
/**
* Write a value and hand the field back to REDCap.
*
* Destination fields load disabled and a disabled input is not
* submitted, so a write that does not also enable is a value the
* participant can see and REDCap never receives. That is precisely
* what happened to every coordinate this module has ever written.
*
* Enabling through jQuery rather than element.disabled is deliberate:
* .prop() on an empty set is an inert no-op, so a field mapped in the
* settings but absent from this instrument costs nothing here.
*/
function updateAndEnable(id, value) {
updateValue(id, value);
fieldElement(id).prop('disabled', false);
}
/**
* Blank a field, enabling it ONLY if it actually held something.
*
* A blank has to reach the record only when it is overwriting a value
* — on an edit form, the one saved last time. A field that was already
* empty has nothing to submit, so it stays disabled and stays
* uneditable, which is the entire point of loading the set disabled.
* Enabling every mapped field on every fill made the whole address
* hand-editable after one selection, quietly retiring that guard.
*
* Read BEFORE the clear, and read through .val(): that is the value
* REDCap submits for all three kinds updateValue() handles — the
* hidden input behind a .hiddenradio, the <select> behind an
* .rc-autocomplete, and a plain text input. String(… || '') covers a
* <select> with nothing selected, where .val() is null.
*/
function clearAndEnable(id) {
var element = fieldElement(id);
var hadValue = element.length > 0 && String(element.val() || '') !== '';
updateValue(id, '');
if (hadValue) { element.prop('disabled', false); }
}
/**
* Helper: update a REDCap field value, handling radios, selects,
* and rc-autocomplete dropdowns.
*
* Does NOT enable the field — callers go through updateAndEnable().
*/
function updateValue(id, value) {
var element = fieldElement(id);
if (element.length === 0) {
console.log(logPrefix + 'Could not find the element with the following id:', id);
return;
}
var eleType = element.prop('type');
element.val(value);
// Handle special REDCap field types
var eleName = element.attr('name');
if (element.hasClass('hiddenradio')) {
$('input[name="'+eleName+'___radio"][value="'+value+'"]').prop('checked', true);
} else if (eleType.indexOf("select") >= 0) {
if ($('#'+id+' option[value="'+value+'"]').length > 0) {
$('#'+id+' option[value="'+value+'"]').prop('selected', true);
} else {
var valUnderscore = value.replace(/\s+/g,"_");
if ($('#'+id+' option[value="'+valUnderscore+'"]').length > 0) {
$('#'+id+' option[value="'+valUnderscore+'"]').prop('selected', true);
} else if ($('#'+id+' option[value="Other"]').length > 0) {
$('#'+id+' option[value="Other"]').prop('selected', true);
} else {
var optionsWithMatchingContent = $('#'+id+' option').filter(function(){
return $(this).html() === value;
});
if (optionsWithMatchingContent.length === 1) {
optionsWithMatchingContent.prop('selected', true);
} else {
console.warn(logPrefix + "The value '" + value + "' is not a valid value for the '" + eleName + "' field; leaving it blank.");
$('#'+id+' option[value=""]').prop('selected', true);
}
}
}
}
element.change();
if (element.hasClass('rc-autocomplete')) {
var autocompleteField = element.closest('td').find('.ui-autocomplete-input');
autocompleteField.val(element.find('option:selected').text());
autocompleteField.change();
}
}
/**
* Pick the unit (subpremise) and street number out of the raw component
* list, independently of componentForm — which has no subpremise entry
* and would otherwise skip it.
*/
function extractUnitParts(components) {
var parts = { unit: '', streetNumber: '' };
if (!components || !components.length) { return parts; }
for (var i = 0; i < components.length; i++) {
var comp = components[i];
if (!comp || !comp.types) { continue; }
var type = comp.types[0];
var val = comp.shortText || comp.longText || '';
if (type === 'subpremise' && !parts.unit) {
parts.unit = String(val).trim();
} else if (type === 'street_number' && !parts.streetNumber) {
parts.streetNumber = String(val).trim();
}
}
return parts;
}
function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Recover the unit / apartment number from the text the user typed.
*
* Google frequently omits the subpremise component for AU/UK style unit
* addresses: "3/27 Harris St" comes back as street number 27 with no
* subpremise at all. This parses the unit out of the typed text, anchored
* to the street number Google DID return, and returns '' rather than
* guessing whenever the text does not clearly contain a unit.
*/
function recoverUnitFromText(typed, streetNumber) {
if (!typed || !streetNumber) { return ''; }
var text = String(typed).trim();
var sn = String(streetNumber).trim();
if (!text || !sn) { return ''; }
// Locate the street number on a word boundary, so a street number of
// "7" is not matched inside "27".
var snMatch = new RegExp('(^|[^0-9A-Za-z])' + escapeRegExp(sn) + '(?![0-9A-Za-z])').exec(text);
if (!snMatch) { return ''; }
var snIndex = snMatch.index + snMatch[1].length;
if (snIndex <= 0) { return ''; } // nothing precedes it, so no unit
var prefix = text.slice(0, snIndex);
// "27-29 Harris St" is a street number range, not a unit.
if (/[-–—]\s*$/.test(prefix)) { return ''; }
// A unit prefix is short; anything longer is a building or place name.
if (prefix.replace(/\s+/g, ' ').trim().length > 24) { return ''; }
// The prefix must END with a unit token, optionally introduced by a
// unit word and optionally followed by "/" or ",". That anchoring is
// what rejects "Harris St 27" and "The Old Rectory, 27 Harris St".
var unitMatch = /(?:^|[\s,])(?:(?:unit|apt|apartment|flat|suite|ste|shop|villa|lot|level|lvl|room|rm)\.?\s*)?([0-9]{1,5}[A-Za-z]?)\s*[\/,]?\s*$/i.exec(prefix);
return unitMatch ? unitMatch[1].toUpperCase() : '';
}
/**
* Write "3/27" into the Street Number field.
*
* Goes through updateAndEnable() so radios, selects and rc-autocomplete
* dropdowns keep working and the field is re-enabled — disabled inputs
* are not submitted, so REDCap would otherwise never save the value.
*/
function applyUnitToStreetNumber(unit, streetNumber) {
var id = autocompletePrefix + 'street_number';
if (!document.getElementById(id) || !unit || !streetNumber) { return; }
updateAndEnable(id, unit + '/' + streetNumber);
}
/**
* Keep the full address stored in the search field consistent with the
* components, by rewriting a leading bare street number to "3/27".
* No-ops when Google supplied the subpremise, because formattedAddress
* already contains the unit in that case.
*/
function patchFormattedAddress($field, unit, streetNumber) {
var current = $field.val();
if (!current || !unit || !streetNumber) { return; }
var leading = new RegExp('^\\s*' + escapeRegExp(streetNumber) + '(?![0-9A-Za-z])');
if (leading.test(current)) {
$field.val(current.replace(leading, unit + '/' + streetNumber));
$field.change();
}
}
/**
* Apply the unit / sub-premise to the Street Number field. Called after the
* components have been written, so that it overwrites the bare street
* number they just stored.
*
* Does nothing unless a Street Number Field is mapped — that field is the
* only destination for the unit.
*/
function applyUnitFromComponents(components, $field) {
var parts = extractUnitParts(components);
var unit = parts.unit;
<?php if ($set->recoverUnit): ?>
// Google omitted subpremise — fall back to parsing the typed text.
if (!unit) { unit = recoverUnitFromText(lastTypedText, parts.streetNumber); }
<?php endif; ?>
lastTypedText = ''; // consume, so a later selection cannot reuse it
if (!unit || !parts.streetNumber) { return; }
if (!document.getElementById(autocompletePrefix + 'street_number')) { return; }
applyUnitToStreetNumber(unit, parts.streetNumber);
patchFormattedAddress($field, unit, parts.streetNumber);
}
/**
* Populate (or clear) all address component fields from the selected Place.
* Uses the NEW Places API property names: addressComponents[].longText / shortText.
*/
function fillInAddress(place, $field) {
// Clear all component fields first: the fields are only filled per