diff --git a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js index 3073fbbef..62d0f6a7e 100644 --- a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js +++ b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js @@ -1,6 +1,6 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-01-23T01:14:57.364Z +// Last generated: 2026-05-16T12:40:22.983Z (function () { // Enhanced API availability detection and recovery @@ -8,8 +8,7 @@ // Check multiple locations where the API might exist return ( globalThis.formulus || - window.formulus || - (typeof formulus !== 'undefined' ? formulus : undefined) + (typeof window !== 'undefined' ? window.formulus : undefined) ); } @@ -108,60 +107,6 @@ document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); - // Helper to filter observations using a limited subset of the SQL-like whereClause - // produced by queryHelpers.buildWhereClause (json_extract(data, '$.field') = 'value' AND ...) - function filterObservationsByWhereClause(observations, whereClause) { - if (!whereClause || whereClause === '1=1') { - return observations; - } - - try { - const conditionStrings = whereClause.split(/\s+AND\s+/i); - const conditions = []; - - const regex = - /json_extract\(data,\s*'\$\.(.+?)'\)\s*=\s*'(.*)'/; - - for (const cond of conditionStrings) { - const match = cond.match(regex); - if (match) { - const path = match[1]; - const rawValue = match[2]; - const value = rawValue.replace(/''/g, "'"); - conditions.push({ path, value }); - } - } - - if (conditions.length === 0) { - return observations; - } - - const getNested = (obj, path) => { - const parts = path.split('.'); - let cur = obj; - for (const p of parts) { - if (!cur || typeof cur !== 'object') return undefined; - cur = cur[p]; - } - return cur; - }; - - return observations.filter(obs => - conditions.every(({ path, value }) => { - const actual = getNested(obs.data || {}, path); - return actual !== undefined && String(actual) === String(value); - }), - ); - } catch (e) { - console.warn( - 'filterObservationsByWhereClause: Failed to apply whereClause filter, returning unfiltered observations.', - whereClause, - e, - ); - return observations; - } - } - // Initialize the formulus interface globalThis.formulus = { // getVersion: => Promise @@ -284,7 +229,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -411,36 +356,68 @@ }); }, - // getObservationsByQuery: options: { formType: string; whereClause?: string; isDraft?: boolean; includeDeleted?: boolean } => Promise - // NOTE: This is implemented entirely in the WebView layer by calling getObservations - // and then applying a lightweight filter based on the generated whereClause string. - // This avoids additional native bridge work while still supporting dynamic choice lists. + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise getObservationsByQuery: function (options) { - try { - const formType = options?.formType; - const whereClause = options?.whereClause || '1=1'; - const isDraft = - typeof options?.isDraft === 'boolean' ? options.isDraft : false; - const includeDeleted = - typeof options?.includeDeleted === 'boolean' - ? options.includeDeleted - : false; - - return globalThis.formulus - .getObservations(formType, isDraft, includeDeleted) - .then(observations => - filterObservationsByWhereClause(observations, whereClause), - ); - } catch (e) { - console.error( - 'getObservationsByQuery: Failed to execute query, returning empty list.', - e, + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getObservationsByQuery', + messageId, + options: options, + }), ); - return Promise.resolve([]); - } + }); }, - // submitObservation: formType: string, finalData: Record => Promise + // submitObservation: formType: string, finalData: Record => Promise submitObservation: function (formType, finalData) { return new Promise((resolve, reject) => { const messageId = @@ -502,7 +479,7 @@ }); }, - // updateObservation: observationId: string, formType: string, finalData: Record => Promise + // updateObservation: observationId: string, formType: string, finalData: Record => Promise updateObservation: function (observationId, formType, finalData) { return new Promise((resolve, reject) => { const messageId = @@ -626,7 +603,7 @@ }); }, - // requestLocation: fieldId: string => Promise + // requestLocation: fieldId: string => Promise requestLocation: function (fieldId) { return new Promise((resolve, reject) => { const messageId = @@ -748,7 +725,7 @@ }); }, - // launchIntent: fieldId: string, intentSpec: Record => Promise + // launchIntent: fieldId: string, intentSpec: Record => Promise launchIntent: function (fieldId, intentSpec) { return new Promise((resolve, reject) => { const messageId = @@ -810,7 +787,7 @@ }); }, - // callSubform: fieldId: string, formType: string, options: Record => Promise + // callSubform: fieldId: string, formType: string, options: Record => Promise callSubform: function (fieldId, formType, options) { return new Promise((resolve, reject) => { const messageId = @@ -934,8 +911,8 @@ }); }, - // requestSignature: fieldId: string => Promise - requestSignature: function (fieldId) { + // requestVideo: fieldId: string => Promise + requestVideo: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -950,18 +927,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('requestSignature callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'requestSignature callback: Received response with unexpected data type. Raw: ' + + 'requestVideo callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'requestSignature_response' && + data.type === 'requestVideo_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -973,7 +950,7 @@ } } catch (e) { console.error( - "'requestSignature' callback: Error processing response:", + "'requestVideo' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -987,7 +964,7 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'requestSignature', + type: 'requestVideo', messageId, fieldId: fieldId, }), @@ -1237,7 +1214,7 @@ }); }, - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise runLocalModel: function (fieldId, modelId, input) { return new Promise((resolve, reject) => { const messageId = @@ -1300,7 +1277,7 @@ }); }, - // getCurrentUser: => Promise<{ username: string; displayName?: string; }> + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> getCurrentUser: function () { return new Promise((resolve, reject) => { const messageId = @@ -1360,21 +1337,24 @@ }); }, - // getThemeMode: () => Promise<'light' | 'dark' | 'system'> + // getThemeMode: => Promise<"light" | "dark" | "system"> getThemeMode: function () { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener reject( new Error( 'getThemeMode callback: Received response with unexpected data type. Raw: ' + @@ -1401,12 +1381,13 @@ 'Raw event.data:', event.data, ); - window.removeEventListener('message', callback); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'getThemeMode', @@ -1415,6 +1396,247 @@ ); }); }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getAttachmentUri', + messageId, + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getAttachmentsUri', + messageId, + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCustomAppUri', + messageId, + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getFormSpecsUri', + messageId, + }), + ); + }); + }, }; // Register the callback handler with the window object @@ -1436,6 +1658,7 @@ ); } } + globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { diff --git a/formulus/android/app/src/main/assets/webview/formulus-api.js b/formulus/android/app/src/main/assets/webview/formulus-api.js index 9ed7cc3d0..c46263561 100644 --- a/formulus/android/app/src/main/assets/webview/formulus-api.js +++ b/formulus/android/app/src/main/assets/webview/formulus-api.js @@ -5,7 +5,7 @@ * that's available in the WebView context as `globalThis.formulus`. * * This file is auto-generated from FormulusInterfaceDefinition.ts - * Last generated: 2026-04-09T07:22:42.291Z + * Last generated: 2026-05-16T12:40:24.252Z * * @example * // In your JavaScript file: @@ -97,10 +97,10 @@ const FormulusAPI = { requestCamera: function (fieldId) {}, /** - * Request location for a field + * Request location for a field (captures into the form GPS field). * / * @param {string} fieldId - The ID of the field - * @returns {Promise} + * @returns {Promise} Promise that resolves with location result or rejects on error */ requestLocation: function (fieldId) {}, @@ -139,6 +139,14 @@ const FormulusAPI = { */ requestAudio: function (fieldId) {}, + /** + * Request video recording for a field (camera / picker — host-defined). + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo: function (fieldId) {}, + /** * Request QR code scanning for a field * / @@ -195,18 +203,30 @@ const FormulusAPI = { getThemeMode: function () {}, /** - * Resolve a synced or camera-saved attachment to a WebView-loadable `file://` URL. - * Checks `{DocumentDirectory}/attachments/` and `pending_upload/`. Pass the basename only - * (e.g. `photo.filename` from observation data); path segments and ".." are rejected. + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). * / - * @returns {Promise} `file://` URL if the file exists, otherwise `null` + * @returns {Promise} Display URL, or `null` if none */ getAttachmentUri: function (fileName) {}, /** - * Base `file://` URL for the attachments directory (trailing slash). + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. * / - * @returns {Promise} e.g. `file:///.../attachments/` + * @returns {Promise} e.g. `file:///.../attachments/synced/` */ getAttachmentsUri: function () {}, diff --git a/formulus/src/services/FormService.ts b/formulus/src/services/FormService.ts index cfd0048a0..e0ab0537e 100644 --- a/formulus/src/services/FormService.ts +++ b/formulus/src/services/FormService.ts @@ -5,6 +5,11 @@ import { UpdateObservationInput, } from '../database/models/Observation'; import RNFS from 'react-native-fs'; +import { + resolveSharedChoiceRefs, + SHARED_CHOICE_SCHEMA_ID, + type SharedChoiceSchemaDoc, +} from '../utils/sharedChoiceSchema'; /** * Interface representing a form type @@ -27,6 +32,10 @@ export class FormService { private formSpecs: FormSpec[] = []; private static initializationPromise: Promise | null = null; private cacheInvalidationCallbacks: Set<() => void> = new Set(); + private sharedChoiceSchemaByDir = new Map< + string, + SharedChoiceSchemaDoc | null + >(); private constructor() { console.log( @@ -51,8 +60,49 @@ export class FormService { } } + private async loadSharedChoiceSchema( + formsDir: string, + ): Promise { + if (this.sharedChoiceSchemaByDir.has(formsDir)) { + return this.sharedChoiceSchemaByDir.get(formsDir) ?? null; + } + const filePath = `${formsDir}/shared-choice-defs.schema.json`; + try { + const exists = await RNFS.exists(filePath); + if (!exists) { + this.sharedChoiceSchemaByDir.set(formsDir, null); + return null; + } + const raw = await RNFS.readFile(filePath, 'utf8'); + const doc = JSON.parse(raw) as SharedChoiceSchemaDoc; + if (!doc.$defs || typeof doc.$defs !== 'object') { + console.warn( + 'FormService: shared-choice-defs.schema.json missing $defs', + ); + this.sharedChoiceSchemaByDir.set(formsDir, null); + return null; + } + if (!doc.$id) { + doc.$id = SHARED_CHOICE_SCHEMA_ID; + } + this.sharedChoiceSchemaByDir.set(formsDir, doc); + console.log( + `FormService: loaded shared choice defs (${Object.keys(doc.$defs).length} lists) from ${filePath}`, + ); + return doc; + } catch (error) { + console.warn( + 'FormService: failed to load shared-choice-defs.schema.json', + error, + ); + this.sharedChoiceSchemaByDir.set(formsDir, null); + return null; + } + } + private async loadFormspec( formDir: RNFS.ReadDirItem, + formsParentDir: string, ): Promise { if (!formDir.isDirectory()) { console.log('Skipping non-directory:', formDir.name); @@ -72,6 +122,23 @@ export class FormService { ); return null; } + + const sharedChoice = await this.loadSharedChoiceSchema(formsParentDir); + if (sharedChoice && schema && typeof schema === 'object') { + try { + schema = resolveSharedChoiceRefs( + schema as Record, + sharedChoice, + ); + } catch (resolveError) { + console.error( + 'Failed to resolve shared choice refs for form:', + formDir.name, + resolveError, + ); + return null; + } + } let uiSchema: unknown; try { const uiSchemaPath = formDir.path + '/ui.json'; @@ -127,7 +194,7 @@ export class FormService { for (const formDir of formDirs) { if (seenIds.has(formDir.name)) continue; - const spec = await this.loadFormspec(formDir); + const spec = await this.loadFormspec(formDir, formSpecsDir); if (spec) { allFormSpecs.push(spec); seenIds.add(spec.id); diff --git a/formulus/src/utils/sharedChoiceSchema.ts b/formulus/src/utils/sharedChoiceSchema.ts new file mode 100644 index 000000000..b45e1bef8 --- /dev/null +++ b/formulus/src/utils/sharedChoiceSchema.ts @@ -0,0 +1,75 @@ +/** + * Resolves external $ref to forms/shared-choice-defs.schema.json for Formulus / Formplayer. + */ + +export const SHARED_CHOICE_SCHEMA_ID = 'forms/shared-choice-defs.schema.json'; +export const SHARED_CHOICE_REF_PREFIX = `${SHARED_CHOICE_SCHEMA_ID}#/$defs/`; + +const SHARED_REF_RE = new RegExp( + `^${SHARED_CHOICE_SCHEMA_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}#\\/\\$defs\\/(.+)$`, +); + +export interface SharedChoiceSchemaDoc { + $id?: string; + $schema?: string; + $defs: Record; +} + +export function extractSharedChoiceDefName(ref: string): string | null { + const m = String(ref || '') + .trim() + .match(SHARED_REF_RE); + return m ? m[1] : null; +} + +/** Deep-clone schema and inline shared choice $ref into $defs for AJV / JSON Forms. */ +export function resolveSharedChoiceRefs( + formSchema: Record, + sharedDoc: SharedChoiceSchemaDoc, +): Record { + if (!sharedDoc?.$defs) { + return formSchema; + } + + const resolved = JSON.parse(JSON.stringify(formSchema)) as Record< + string, + unknown + >; + if (!resolved.$defs || typeof resolved.$defs !== 'object') { + resolved.$defs = {}; + } + + const needed = new Set(); + + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + const obj = node as Record; + if (typeof obj.$ref === 'string') { + const name = extractSharedChoiceDefName(obj.$ref); + if (name) { + needed.add(name); + obj.$ref = `#/$defs/${name}`; + } + } + Object.values(obj).forEach(walk); + }; + + walk(resolved); + + const defs = resolved.$defs as Record; + for (const name of needed) { + const def = sharedDoc.$defs[name]; + if (!def) { + throw new Error( + `Missing shared choice def "${name}" in shared-choice-defs.schema.json`, + ); + } + defs[name] = JSON.parse(JSON.stringify(def)); + } + + return resolved; +}