Setting the file. One moment.
Regional Capture · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
function inferTimeZone
— line 126
This file
Number 39.26
Position 26 of 92
Type JavaScript
Size 14 KB
Lines 282 lib/ regional-capture.js
JavaScript · 282 lines · 14 KB
14 // `gmt_offset` (hours, e.g. `"0"`, `5.5`).
15 //
16 // A store on a fixed UTC offset has no named zone, and Wix needs one. Discovery then picks one
17 // from the store country (owner decision, 2026-11-03, option A): a country with one zone gets that
18 // zone even when the offset disagrees (UTC+0 is the WordPress default, i.e. usually never set); a
19 // country with several zones gets the single one whose standard or summer offset equals the fixed
20 // offset. The pick is written as `supplied` by `discovery-inference` with the offset and the reason.
21 // When no single zone fits, the field stays unknown but carries the same `inference` record
22 // (`basis: unresolved`); setup then keeps the site's own zone. Either way setup raises a cutover
23 // task to confirm the site time zone. Never asked mid-run.
24 //
25 // A read that is denied, absent or malformed is an explicit unknown with a reason. It is never a
26 // discovery failure and never a guess: the currency is never taken from an order.
27
28 const { readStoreCurrency , resolveOrderCurrency } = require ( './store-currency.js' );
29 const { buildSourceRegional , knownTimeZone , knownCountry } = require ( '../../../lib/source-regional.js' );
30
31 const GENERAL_ROUTE = '/wc/v3/settings/general' ;
32 const SITE_ROUTE = '/wp/v2/settings' ;
33 const ORDERS_ROUTE = '/wc/v3/orders' ;
34 const INDEX_ROUTE = '/wp-json/' ;
35
36 // 401/403: the credential cannot read it. 404: the route does not exist on this site.
37 function readFailureReason ( status ) {
38 if (status === 401 || status === 403 ) return 'auth-gated' ;
39 if (status === 404 ) return 'not-exposed' ;
40 return 'unreadable' ;
41 }
42
43 // One bounded GET per route; `fetchJson(baseUrl, route, options)` is wp-http's. Never throws.
44 async function readOne ( fetchJson , baseUrl , route , options ) {
45 try {
46 const response = await fetchJson (baseUrl, route, { ... options, method: 'GET' });
47 if (response && response.ok && response.json !== undefined && response.json !== null ) return { ok: true , body: response.json };
48 return { ok: false , reason: readFailureReason (response && response.status), route };
49 } catch {
50 return { ok: false , reason: 'unreadable' , route };
51 }
52 }
53
54 // `capturedGeneral` is the array the store-settings capture already wrote (reused, not re-read).
55 async function readRegionalSettings ({ baseUrl , headers , fetchJson , timeoutMs , progress = null , capturedGeneral = null }) {
56 const options = { headers, timeoutMs, progress, progressContext: { step: 'regional-settings' } };
57 const general = Array. isArray (capturedGeneral)
58 ? { ok: true , body: capturedGeneral }
59 : await readOne (fetchJson, baseUrl, GENERAL_ROUTE , options);
60 const site = await readOne (fetchJson, baseUrl, SITE_ROUTE , options);
61 return { general, site };
62 }
63
64 const settingEntry = ( entries , id ) => (Array. isArray (entries) ? entries. find (( entry ) => entry && entry.id === id) : undefined );
65 const text = ( value ) => ( typeof value === 'string' ? value. trim () : '' );
66
67 function currencyInput ( general ) {
68 const evidence = `${ GENERAL_ROUTE } woocommerce_currency` ;
69 if ( ! general || ! general.ok) return { reason: general ? general.reason : 'not-exposed' , evidence };
70 const code = readStoreCurrency (general.body);
71 if (code) return { value: code, evidence };
72 const entry = settingEntry (general.body, 'woocommerce_currency' );
73 // Present but not a code: pass the raw value so the library records it as unreadable.
74 return text (entry && entry.value) ? { value: text (entry.value), evidence } : { reason: 'not-exposed' , evidence };
75 }
76
77 function countryInput ( general ) {
78 const evidence = `${ GENERAL_ROUTE } woocommerce_default_country` ;
79 if ( ! general || ! general.ok) return { reason: general ? general.reason : 'not-exposed' , evidence };
80 const entry = settingEntry (general.body, 'woocommerce_default_country' );
81 const country = text (entry && entry.value). split ( ':' )[ 0 ]. toUpperCase ();
82 return country ? { value: country, evidence } : { reason: 'not-exposed' , evidence };
83 }
84
85 // The UTC offsets (hours) a zone uses in the given year: standard and summer time.
86 function zoneOffsets ( zone , year ) {
87 const offsets = new Set ();
88 for ( const month of [ 0 , 6 ]) {
89 try {
90 const name = new Intl. DateTimeFormat ( 'en' , { timeZone: zone, timeZoneName: 'longOffset' })
91 . formatToParts ( new Date (Date. UTC (year, month, 15 ))). find (( part ) => part.type === 'timeZoneName' ).value;
92 const match = / ^ GMT(?:( [+-] )( \d {2} ):( \d {2} )) ?$ / . exec (name);
93 if (match) offsets. add (match[ 1 ] ? (match[ 1 ] === '-' ? - 1 : 1 ) * ( Number (match[ 2 ]) + Number (match[ 3 ]) / 60 ) : 0 );
94 } catch { /* runtime does not know the zone */ }
95 }
96 return [ ... offsets];
97 }
98
99 // The named zones the runtime lists for a country (`IL` -> [`Asia/Jerusalem`]); [] when it cannot say.
100 function countryZones ( country ) {
101 try {
102 const locale = new Intl. Locale ( `und-${ country }` );
103 const zones = typeof locale.getTimeZones === 'function' ? locale. getTimeZones () : locale.timeZones;
104 return Array. isArray (zones) ? zones. filter (( zone ) => knownTimeZone (zone)) : [];
105 } catch {
106 return [];
107 }
108 }
109
110 function parseGmtOffset ( value ) {
111 if (value === undefined || value === null || value === '' ) return null ;
112 const hours = Number (value);
113 return Number. isFinite (hours) && Math. abs (hours) <= 14 ? hours : null ;
114 }
115
116 const formatOffset = ( hours ) => {
117 if (hours === 0 ) return 'UTC+0' ;
118 const minutes = Math. round (Math. abs (hours) * 60 );
119 return `UTC${ hours < 0 ? '-' : '+'}${ Math . floor ( minutes / 60 ) }${ minutes % 60 ? `:${ String ( minutes % 60 ). padStart ( 2 , '0' ) }` : ''}` ;
120 };
121
122 // A named zone for a store with no named zone: from the store country, then the fixed offset.
123 // Returns the source-regional input: `supplied` + `inference` when one zone fits, else `unknown`
124 // (`ambiguous` / `not-exposed`) + `inference` with `basis: unresolved`, which setup turns into
125 // "keep the site's zone and confirm it at cutover" instead of a question.
126 function inferTimeZone ({ country , gmtOffset , evidence , now = new Date () }) {
127 const zones = country ? countryZones (country) : [];
128 const offsetText = gmtOffset === null ? 'no fixed offset readable' : `fixed offset ${ formatOffset ( gmtOffset ) }` ;
129 let value = null ;
130 let basis = null ;
131 let matching = [];
132 if (zones. length === 1 ) {
133 value = zones[ 0 ];
134 basis = 'single-zone-country' ;
135 } else if (zones. length > 1 && gmtOffset !== null ) {
136 matching = zones. filter (( zone ) => zoneOffsets (zone, now. getUTCFullYear ()). includes (gmtOffset));
137 if (matching. length === 1 ) { value = matching[ 0 ]; basis = 'country-zone-matching-offset' ; }
138 }
139 if ( ! value) {
140 const why = ! country ? 'store country unknown'
141 : zones. length === 0 ? `no time zone listed for store country ${ country }`
142 : gmtOffset === null ? `store country ${ country } has ${ zones . length } time zones and no offset to choose by`
143 : matching. length === 0 ? `no ${ country } time zone uses ${ formatOffset ( gmtOffset ) }`
144 : `${ matching . length } ${ country } time zones use ${ formatOffset ( gmtOffset ) }` ;
145 return {
146 reason: country && zones. length > 1 ? 'ambiguous' : 'not-exposed' ,
147 evidence: `${ evidence }; ${ offsetText }, no named zone; ${ why }` ,
148 inference: { basis: 'unresolved' , country: country || null , gmtOffset, reason: why, confirm: 'cutover' },
149 };
150 }
151 const why = basis === 'single-zone-country'
152 ? `store country ${ country } has one time zone`
153 : `the only ${ country } time zone that uses ${ formatOffset ( gmtOffset ) }` ;
154 return {
155 state: 'supplied' ,
156 value,
157 suppliedBy: 'discovery-inference' ,
158 evidence: `${ evidence }; ${ offsetText }, no named zone; ${ value }: ${ why }` ,
159 inference: {
160 basis,
161 country,
162 gmtOffset,
163 offsetMatches: gmtOffset === null ? null : zoneOffsets (value, now. getUTCFullYear ()). includes (gmtOffset),
164 confirm: 'cutover' ,
165 },
166 };
167 }
168
169 // `index` is the public REST index body (`timezone_string`, `gmt_offset`), or null.
170 function timeZoneInput ( site , { index = null , country = null } = {}) {
171 const evidence = `${ SITE_ROUTE } timezone` ;
172 const body = site && site.ok ? (site.body || {}) : {};
173 // `timezone_string` is the option's own name; the REST field is `timezone`. A site on a fixed
174 // offset has neither, and an offset is not a zone.
175 const zone = text (body.timezone_string !== undefined ? body.timezone_string : body.timezone);
176 if (zone) return { value: zone, evidence };
177 const indexZone = text (index && index.timezone_string);
178 if (indexZone) return { value: indexZone, evidence: `${ INDEX_ROUTE } timezone_string` };
179 const gmtOffset = parseGmtOffset (body.gmt_offset !== undefined ? body.gmt_offset : index && index.gmt_offset);
180 // Nothing readable at all (settings denied and no index): an ordinary unknown.
181 if (( ! site || ! site.ok) && ! index) return { reason: site ? site.reason : 'not-exposed' , evidence };
182 return inferTimeZone ({ country, gmtOffset, evidence: `${ site && site . ok ? `${ SITE_ROUTE } timezone=""` : `${ SITE_ROUTE } ${ site ? site . reason : 'not-exposed'}`}; ${ INDEX_ROUTE } gmt_offset` });
183 }
184
185 // A locale tag (`he_IL`, `en-GB`, `de_DE_formal`) -> language + the language-COUNTRY locale when
186 // the tag carries a region. A 3-letter language is passed on as-is and refused by the library.
187 function parseLanguageTag ( tag ) {
188 const match = / ^ ( [A-Za-z] {2,3} )(?: [_-] ( [A-Za-z] {2} )) ? (?: [_-]. * ) ?$ / . exec ( text (tag));
189 if ( ! match) return null ;
190 const value = match[ 1 ]. toLowerCase ();
191 return { value, ... (match[ 2 ] ? { locale: `${ value }-${ match [ 2 ]. toUpperCase () }` } : {}) };
192 }
193
194 function languageInput ( site ) {
195 const evidence = `${ SITE_ROUTE } language` ;
196 if ( ! site || ! site.ok) return { reason: site ? site.reason : 'not-exposed' , evidence };
197 const raw = text (site.body && site.body.language);
198 if ( ! raw) return { reason: 'not-exposed' , evidence };
199 const parsed = parseLanguageTag (raw);
200 return parsed ? { ... parsed, evidence } : { value: raw, evidence };
201 }
202
203 // Counts of the currency on every order -- only when the sampled orders ARE every order and each
204 // one resolves; a sample is not a census and a count that skips an order would understate it.
205 function orderCurrencyCounts ( ordersEntity , storeCurrency ) {
206 if ( ! ordersEntity || ! Array. isArray (ordersEntity.sampleRecords)) return null ;
207 if ( ! Number. isInteger (ordersEntity.recordCount) || ordersEntity.sampleRecords. length !== ordersEntity.recordCount) return null ;
208 const counts = {};
209 for ( const order of ordersEntity.sampleRecords) {
210 const { currency } = resolveOrderCurrency (order, storeCurrency);
211 if ( ! currency) return null ;
212 counts[currency] = (counts[currency] || 0 ) + 1 ;
213 }
214 return counts;
215 }
216
217 // The country a zone is picked from: the store's default country, else the language tag's region.
218 function inferenceCountry ( country , language ) {
219 if (country && knownCountry (country.value)) return country.value;
220 const region = language && typeof language.locale === 'string' ? language.locale. slice ( 3 ) : '' ;
221 return knownCountry (region) ? region : null ;
222 }
223
224 function buildWordPressRegional ({ general , site , index = null , ordersEntity = null , generatedAt } = {}) {
225 const currency = currencyInput (general);
226 const country = countryInput (general);
227 const language = languageInput (site);
228 return buildSourceRegional ({
229 adapter: 'wordpress' ,
230 generatedAt,
231 currency,
232 timeZone: timeZoneInput (site, { index, country: inferenceCountry (country, language) }),
233 primaryLanguage: language,
234 country,
235 });
236 }
237
238 // --- quick WooCommerce (public reads only) -------------------------------------------------------
239
240 const STORE_API_PRODUCTS_ROUTE = '/wp-json/wc/store/v1/products' ;
241
242 // `<html lang="he-IL">` -> { value: 'he', locale: 'he-IL' }; null when there is no usable tag.
243 function htmlLanguage ( html ) {
244 const match = /<html \b [ ^ >] *\b lang \s * = \s * ["'] ( [ ^ "'] + ) ["'] / i . exec ( typeof html === 'string' ? html. slice ( 0 , 65536 ) : '' );
245 return match ? parseLanguageTag (match[ 1 ]) : null ;
246 }
247
248 // `products` is the first Store API page (array) or null when it could not be read; `page` is
249 // `{ html }` or null. Time zone and country are not public, so they stay unknown.
250 function buildQuickWooCommerceRegional ({ products = null , page = null , generatedAt } = {}) {
251 const currencyEvidence = `${ STORE_API_PRODUCTS_ROUTE } prices.currency_code` ;
252 const first = Array. isArray (products) ? products. find (( product ) => product && product.prices && product.prices.currency_code) : null ;
253 const currency = first
254 ? { value: text (first.prices.currency_code). toUpperCase (), evidence: currencyEvidence }
255 : { reason: Array. isArray (products) ? 'not-exposed' : 'unreadable' , evidence: currencyEvidence };
256 const languageEvidence = 'storefront page <html lang>' ;
257 const language = htmlLanguage (page && page.html);
258 return buildSourceRegional ({
259 adapter: 'quick-woocommerce' ,
260 generatedAt,
261 currency,
262 primaryLanguage: language
263 ? { ... language, evidence: languageEvidence }
264 : { reason: page ? 'not-exposed' : 'unreadable' , evidence: languageEvidence },
265 });
266 }
267
268 module . exports = {
269 GENERAL_ROUTE,
270 SITE_ROUTE,
271 ORDERS_ROUTE,
272 INDEX_ROUTE,
273 readFailureReason,
274 inferTimeZone,
275 countryZones,
276 readRegionalSettings,
277 parseLanguageTag,
278 orderCurrencyCounts,
279 buildWordPressRegional,
280 htmlLanguage,
281 buildQuickWooCommerceRegional,
282 };