@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
JavaScript
// --- 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;