UNPKG

@360works/fmpromise

Version:

A modern JS toolkit for FileMaker Web Viewers, including a dev server and type generation.

305 lines (304 loc) 13.7 kB
// --- Type Definitions --- class FMPromiseError extends Error { code; constructor({ message = 'Unknown error', code }) { super(message); this.name = 'FMPromiseError'; this.code = code; } toString() { return this.code ? `${this.message} (${this.code})` : this.message; } } // --- Private Variables --- let lastPromiseId = 0; const callbacksById = {}; const fmProxy = Promise.race([ new Promise((resolve) => { // @ts-ignore if (window.FileMaker) { // @ts-ignore resolve(window.FileMaker); } else { let _fileMaker; Object.defineProperty(window, 'FileMaker', { get: () => _fileMaker, set: (v) => resolve(_fileMaker = v), }); } }), new Promise((_, reject) => setTimeout(() => reject(new FMPromiseError({ message: 'FileMaker object not found within 5 seconds.' })), 5000)), ]); // --- Main fmPromise Object --- export class FMPromiseService { /** The name of the web viewer object in FileMaker. */ get webViewerName() { return window.FMPROMISE_WEB_VIEWER_NAME || new URLSearchParams(window.location.search).get('webViewerName') || 'fmPromiseWebViewer'; } /** * Performs a FileMaker script and returns a Promise. * @template T The expected type of the script result. * @param {string} scriptName - The name of the FileMaker script to perform. * @param {any} [scriptParameter=null] - The parameter to pass to the script. Non-string values will be JSON stringified. * @param {PerformScriptOptions} [options={}] - Options for the script call. * @returns {Promise<T>} A promise that resolves with the script result, or rejects with an FMPromiseError. */ async performScript(scriptName, scriptParameter = null, options = {}) { const promiseId = ++lastPromiseId; console.log(`[fmPromise] #${promiseId}: Calling script "${scriptName}"`, scriptParameter); if (scriptParameter && typeof scriptParameter !== 'string') { scriptParameter = JSON.stringify(scriptParameter); } const fm = await fmProxy; let result = await new Promise((resolve, reject) => { callbacksById[promiseId] = { resolve, reject }; const meta = JSON.stringify({ scriptName, promiseId, webViewerName: this.webViewerName, ignoreResult: options?.ignoreResult || undefined }); const comboParam = meta + '\n' + (scriptParameter || ''); const option = options.runningScript || 0; if (option === 0) { fm.PerformScript('fmPromise', comboParam); } else { fm.PerformScriptWithOption('fmPromise', comboParam, option.toString()); } }); if (!options.alwaysReturnString && typeof result === 'string' && (result.startsWith('{') || result.startsWith('['))) { try { result = JSON.parse(result); } catch (e) { console.warn(`[fmPromise] #${promiseId}: Unable to parse JSON result.`, { result, error: e }); } } console.log(`[fmPromise] #${promiseId}: Received result.`, result); return result; } /** * Evaluates an expression in FileMaker, optionally within the context of `Let` variables. * @template T The expected type of the evaluated result. * @param {string} expression - The calculation expression to evaluate. * @param {Object<string, any>} [letVars={}] - Key-value pairs for a `Let()` function. * @param {PerformScriptOptions} [options={}] - Options for the script call. * @returns {Promise<T>} A promise that resolves with the evaluated result. */ evaluate(expression, letVars = {}, options = {}) { const letEx = Object.entries(letVars || {}).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(';'); const stmt = `Let([${letEx}] ; ${expression})`; return this.performScript('fmPromise.evaluate', stmt, options); } /** * Creates a new record in a FileMaker layout. * @param params The complete request object, including `action: 'create'`. * @returns A promise that resolves with the new record's recordId and modId. */ dataCreate(params) { // The overload resolution of the original function handles the types, // but we cast here to ensure this wrapper has a strict, non-union return type. return this.executeFileMakerDataAPI(params); } /** * Finds records in a FileMaker layout. * @template T The expected type shape of the records' fieldData. * @param params The complete request object, including `action: 'read'`. * @returns A promise that resolves with the find response, including a `.toRecords()` helper. */ dataRead(params) { return this.executeFileMakerDataAPI(params); } /** * Updates an existing record in a FileMaker layout. * @param params The complete request object, including `action: 'update'`. * @returns A promise that resolves with the record's new modId. */ dataUpdate(params) { return this.executeFileMakerDataAPI(params); } /** * Deletes a record from a FileMaker layout. * @param params The complete request object, including `action: 'delete'`. * @returns A promise that resolves with an empty response object upon success. */ dataDelete(params) { return this.executeFileMakerDataAPI(params); } /** * Retrieves metadata about layouts or tables. * @param params The complete request object, including `action: 'metaData'`. * @returns A promise that resolves with the requested metadata. */ dataMeta(params) { return this.executeFileMakerDataAPI(params); } /** * The original, overloaded method for executing any FileMaker Data API command. * * **Note:** For a superior developer experience with better autocompletion and type-checking in modern IDEs, * it is **highly recommended** to use the more specific methods instead: * - `fmPromise.dataRead()` * - `fmPromise.dataCreate()` * - `fmPromise.dataUpdate()` * - `fmPromise.dataDelete()` * - `fmPromise.dataMeta()` * * This method is preserved for backwards compatibility and for advanced cases where the * `action` property is determined dynamically at runtime. * * @template T The expected type shape of the records' `fieldData` when performing a 'read' action. * @param {DataAPIRequest} params The complete Data API request object. The `action` property within this object determines which Data API type is returned. * @returns {Promise<DataAPIResponse<T>>} A promise that resolves with a response object specific to the request's `action`. * @throws {FMPromiseError} If the Data API returns an error message. * @see {@link dataRead} * @see {@link dataCreate} * @see {@link dataUpdate} * @see {@link dataDelete} * @see {@link dataMeta} */ async executeFileMakerDataAPI(params) { const result = await this.performScript('fmPromise.executeFileMakerDataAPI', params); if (!result || !result.messages || !result.messages.length) { throw new FMPromiseError({ code: -1, message: 'Empty data API response' }); } if (result.messages[0].code !== '0') { throw new FMPromiseError(result.messages[0]); } if ((params.action === 'read' || !params.action)) { const readResponse = result; const self = this; readResponse.toRecords = function () { const responseData = this.response.data || []; const arr = responseData.map((record) => { const cleanedPortalData = {}; for (const portalKey in record.portalData) { cleanedPortalData[portalKey] = record.portalData[portalKey]; } return { ...record.fieldData, ...cleanedPortalData, recordId: record.recordId, modId: record.modId, }; }); const dataInfo = this.response.dataInfo || { totalRecordCount: 0 }; Object.defineProperties(arr, { totalRecordCount: { value: dataInfo.totalRecordCount, enumerable: false }, }); return arr; }; return readResponse; } return result; } /** * A convenience method which calls `fmPromise.dataRead({ action: 'read', ... })` and the `.toRecords()` method on the response. */ async executeFileMakerDataAPIRecords(params) { if (params.action && params.action !== 'read') { throw new FMPromiseError({ message: 'executeFileMakerDataAPIRecords only supports the \'read\' action.' }); } const response = await this.dataRead(params); return response.toRecords(); } /** * Executes a SQL query using FileMaker's `ExecuteSQL` function. * Can be called as a standard function or as a tagged template literal. * @param {TemplateStringsArray | string} sqlOrStrings - The SQL query string or template literal strings. * @param {...any} bindings - Values to bind to the `?` placeholders. * @returns {Promise<string[][]>} A promise resolving to an array of rows, where each row is an array of strings. */ async executeSql(sqlOrStrings, ...bindings) { let sql; let finalBindings; if (Array.isArray(sqlOrStrings) && Array.isArray(sqlOrStrings.raw)) { if (bindings.length !== sqlOrStrings.length - 1) { throw new FMPromiseError({ code: -1, message: 'Invalid template literal for executeSql' }); } sql = sqlOrStrings.join('?').replace(/\n\s*/g, ' '); finalBindings = bindings; } else if (typeof sqlOrStrings === 'string') { sql = sqlOrStrings; finalBindings = bindings; } else { throw new FMPromiseError({ code: -1, message: 'Invalid arguments: executeSql must be called with a SQL string, or as a template literal.' }); } const p = finalBindings.map((o) => ` ; ${JSON.stringify(o)}`).join(''); const colDelim = `|${Math.random()}|`; const rowDelim = `~${Math.random()}~`; const rawData = await this.evaluate(`ExecuteSQLe(${JSON.stringify(sql)} ; "${colDelim}" ; "${rowDelim}"${p})`, undefined, { alwaysReturnString: true }); if (rawData === '' || rawData === null || rawData === undefined) { return []; } if (rawData.startsWith('? ERROR')) { throw new Error(rawData); } return rawData.split(rowDelim).map((r) => r.split(colDelim)); } /** * Calls a FileMaker script to perform an "Insert from URL" script step. * @param {string} url - The URL to fetch/post to. * @param {string} [curlOptions=''] - cURL options for the request. * @returns {Promise<string>} The response body. */ insertFromUrl(url, curlOptions = '') { return this.performScript('fmPromise.insertFromURL', { url, curlOptions }); } /** * Calls a FileMaker script to set a field's value by its fully qualified name. * @param {string} fmFieldNameToSet - The name of the field (e.g., "MyTable::MyField"). * @param {any} value - The value to set. * @returns {Promise<any>} */ setFieldByName(fmFieldNameToSet, value) { return this.performScript('fmPromise.setFieldByName', { fmFieldNameToSet, value }); } /** * Shows a custom dialog in FileMaker. * @param {string} title - The dialog title. * @param {string} body - The dialog message. * @param {string} [btn1='OK'] - The label for the first button (default). * @param {string} [btn2=''] - The label for the second button (optional). * @param {string} [btn3=''] - The label for the third button (optional). * @returns {Promise<number>} A promise resolving to the 1-based index of the button clicked. */ async showCustomDialog(title, body, btn1 = 'OK', btn2 = '', btn3 = '') { const result = await this.performScript('fmPromise.showCustomDialog', { title, body, btn1, btn2, btn3 }); return parseInt(result, 10) || 0; // Ensure it returns a number, defaulting to 0 } /** @internal */ _resolve(promiseId, result) { if (callbacksById[promiseId]) { callbacksById[promiseId].resolve(result); delete callbacksById[promiseId]; } } /** @internal */ _reject(promiseId, errorString) { if (callbacksById[promiseId]) { let errorObj; try { errorObj = JSON.parse(errorString); } catch (e) { errorObj = { message: errorString }; } console.warn(`[fmPromise] #${promiseId}: Rejected.`, errorObj); callbacksById[promiseId].reject(new FMPromiseError(errorObj)); delete callbacksById[promiseId]; } } } ; const fmPromise = new FMPromiseService(); // @ts-ignore globalThis.fmPromise = fmPromise; // @ts-ignore globalThis.fmPromise_Resolve = fmPromise._resolve; // @ts-ignore globalThis.fmPromise_Reject = fmPromise._reject; export default fmPromise;