server.js

/**
 *
 * @class Provides various methods to access the server-side data API.
 * ## Specifying Column Names
 * **Many server calls support filtering, grouping and sorting. In order to be compatible with the API's data Sieve, best-practice is to wrap any variable names in braces: <code>{var}</code>.**
 *
 * ## Filter format
 * The <code>filter</code> argument is a string that supports the same expressions as the underlying database engine's <code>WHERE</code> clause.
 * Note that the specific keyword format depends on the SQL dialect support by the backend engine.
 * The API includes some helper methods to make this easier e.g. [<code>getSqlDateFormat</code>](#getSqlDateFormat)
 *
 * ```sql
 * {var1} > 5 AND {var2} BETWEEN 1 AND 5
 * ```
 *
 * ## Sort format
 * The <code>sort</code> argument is a string that supports the same expressions as the underlying database engine's <code>ORDER BY</code> clause.
 * The default sort order is <code>ASC</code>.
 *
 * ```sql
 * {var1}, {var2} DESC, {var3} ASC
 * ```
 *
 * ## Group format
 * A comma-delimited set of variable names to group the result by.
 * Generating sub-total results from groupings is controlled via the <code>rollup</code> and <code>cube</code> options.
 *
 * ```sql
 * {var1},{var2}
 * ```
 *
 * @example
 * const server = new Server("api/v1");
 *
 * @param {string} api Path to the API handler's relative path, excluding the server's host name.
 *
 * @returns A new Server instance.
 *
 * @copyright (c) 2021 TLF Research Ltd.
 *
 */
function Server(api) {

    const self = this;

    this.debugger = null;

    /**
     * When used on a method that supports it will disable the client-side cache.
     *
     * @memberof Server
     * @constant {bool} NO_CACHE
     */
    Server.NO_CACHE = false;

    /**
     * Path to the API handler's relative path, excluding the server's host name (as provided to the Server's constructor).
     *
     * @memberof Server
     * @constant {string} API_ENDPOINT
     */
    Server.API_ENDPOINT = api;

    /**
     * Store for any Global objects
     * @type {object}
     */
    this.Globals = {};

    this.unauthorizedhandler = function () { };

    /**
     * Sets a handler that is triggered if an API call is unauthorized.
     * May be used to update the UI to indicate to the user that the API is not available.
     * Set to null to disable.
     *
     * @param {function|null} callback Function to be invoked if the IDP rejects an API call
     */
    this.setUnauthorizedHandler = function (callback) {
        if (typeof callback !== null && typeof callback !== "function") {
            console.error("Error handler must be a function or null");
            return;
        }
        this.unauthorizedhandler = callback;
    };

    /**
     * Asynchronously fetches metadata about the API server, including the maximum number of result rows that will be returned,
     * the backend database engine used, and a list of supported aggregator functions that may be used with
     * the [getResponsesAggregate](#getResponsesAggregate) method.
     *
     * @example
     * server.getInfo().done(info => {
     *     // info in here
     * }}.catch(function() {
     *     // something went wrong
     * })
     *
     * @example
     * info = {
     *    "databaseType": "sqlite3",
     *    "maxResults": 10000,
     *    "aggregators": [
     *      {
     *      "name": "Average",
     *      "function": "avg",
     *      "type": "float",
     *      "description": "Calculates the average for each of the given columns"
     *      },
     *      {
     *      "name": "Count",
     *      "function": "count",
     *      "type": "int",
     *      "description": "Counts all non-null responses for the given columns"
     *      },
     *      ...
     *    ]};
     * });
     *
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching by the client.
     *
     * @returns {jQuery.Deferred}
     */
    this.getInfo = function (cache) {
        cache = cache !== false;
        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/server",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches information about the currently logged-in user, including name, email, groups, roles and visible projects.
     *
     * @example
     * server.getUser().done(user => {
     *     // user in here
     * }}.catch(function() {
     *     // something went wrong
     * })
     *
     * @example
     * user = {
     *    "username": "demouser@example.com",
     *    "name": "Demo User",
     *    "given_name": "Demo",
     *    "family_name": "User",
     *    "email": "demouser@example.com",
     *    "email_verified": true,
     *    "o": "TLF Research",
     *    "access_control": "none",
     *    "groups": [
     *       "/TLF Research"
     *    ],
     *    "roles": [
     *       "manage_users_basic",
     *       "manage_users",
     *       "offline_access",
     *       "uma_authorization"
     *    ],
     *    "projects": [
     *    {
     *       "client": "Building Client",
     *       "id": 1,
     *       "name": "Building Demo",
     *       "stopwords": "Building"
     *    },
     *    {
     *       "client": "Catalyst Perception",
     *       "id": 2,
     *       "name": "Catalyst Perception",
     *       "stopwords": ""
     *    }]
     * };
     *
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getUser = function (cache) {
        cache = cache !== false;
        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/user",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches metadata about one or more Projects.
     * The response also includes information about all the Variables associated with the Project.
     *
     * @example
     * server.getProjects([1]).done(projects => {
     *     // projects in here
     * }).catch(function() {
     *     // something went wrong
     * });
     *
     * @example
     * projects = {
     *    "client": "Project Client",
     *    "id": 1,
     *    "name": "Project Demo",
     *    "stopwords": "Project,Demo",
     *    "vars": {
     *       "var1": {
     *       "categories": [
     *          "respondent"
     *       ],
     *       "comments": "",
     *       "exportable": true,
     *       "falsevalue": "No",
     *       "id": 1,
     *       "label": "Project Variable 1",
     *       "name": "var1",
     *       "project": 1,
     *       "truevalue": "Yes",
     *       "type": "int"
     *       },
     *       ...
     *    ]
     *    };
     * });
     *
     * @param {number[]} ids IDs of the Project(s) to fetch
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getProjects = function (ids, cache) {
        cache = cache !== false;

        const requests = ids.reduce(function (acc, id) {
            acc.push(
                jQuery.get({
                    dataType: "json",
                    cache: cache,
                    url: Server.API_ENDPOINT + "/project/" + id,
                    crossDomain: true,
                    headers: {
                        "X-Requested-With": "XMLHttpRequest",
                    },
                    xhrFields: {
                        withCredentials: true,
                    },
                    error: this.errorhandler,
                    statusCode: {
                        401: function () {
                            self.unauthorizedhandler();
                        },
                    },
                })
            );
            return acc;
        }, []);

        return $.when.apply($, requests);
    };

    /**
     * Asynchronously fetches information about any available AI models.
     *
     * @example
     * server.getAIModels().done(models => {
     *     // models in here
     * }).catch(function() {
     *     // something went wrong
     * });
     *
     * @example
     * models = "[
     *  {
     *      "ModelArn": "arn:aws:bedrock:eu-west-2::foundation-model/amazon.titan-text-lite-v1:0:4k",
     *      "ModelId": "amazon.titan-text-lite-v1:0:4k",
     *      "CustomizationsSupported": [],
     *      "InferenceTypesSupported": [
     *          "PROVISIONED"
     *      ],
     *      "InputModalities": [
     *      "TEXT"
     *      ],
     *      "ModelLifecycle": {
     *          "Status": "ACTIVE"
     *      },
     *      "ModelName": "Titan Text G1 - Lite",
     *      "OutputModalities": [
     *          "TEXT"
     *      ],
     *   "ProviderName": "Amazon",
     *   "ResponseStreamingSupported": true
     *  },
     *  ...
     * ];
     *
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getAIModels = function (cache = true) {
        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/ai/models",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Fetches a Retrieval Augmentation Generation (RAG) response from a model via a knowledge base.
     *
     * @example
     * server.getAIRAGResponse('my-model', 'my-knowledge-base', 'hello world', 0.6).done(resp => {
     *     // resp in here
     * }).catch(function() {
     *     // something went wrong
     * });
     *
     * @example
     * resp = ""
     *
     * @param {string} kbID The ID of the knowledge base to query.
     * @param {string} modelID The ID of the foundation model used to process the RAG request.
     * @param {string} prompt The prompt to use.
     * @param {number} temperature The LLM temperature.
     * @param {string} sessionID The chat session ID (used to maintain context).
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getAIRAGResponse = function (kbID, modelID, prompt, temperature = null, sessionID = null, cache = true) {

        modelID = encodeURIComponent(modelID);
        kbID = encodeURIComponent(kbID);
        prompt = encodeURIComponent(prompt);

        const session = sessionID ? `/session/${encodeURIComponent(sessionID)}` : '';

        const url = temperature ? `/ai/kb/${kbID}/model/${modelID}/temp/${temperature}${session}/prompt/${prompt}` : `/ai/kb/${kbID}/model/${modelID}/prompt/${prompt}`;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + url,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Fetches a response from an LLM using a POST request via the native fetch API.
     * Note: This returns the EventSource instance directly, allowing the caller to manage it.
     *
     * @param {string} prompt The prompt to send in the request body.
     * @param {object} promptData Contains configuration for the request.
     * @param {string[]} [promptData.attachments] IDs of files attached to this turn, from postAIAttachment.
     *   Only new attachments need listing - those sent on earlier turns stay bound to the session.
     * @param {object} options An object containing callbacks and an optional AbortSignal.
     * @param {function(object)} options.onMessage Called for each data chunk received.
     * @param {function(Error)} options.onError Called if an error occurs.
     * @param {function()} options.ondone Called when the stream is successfully closed by the server.
     * @param {AbortSignal} [options.signal] An optional signal to abort the request.
     * @param {string} sessionID The chat session ID.
     * @param {boolean} [cache=true] If false, the query will include a timestamp to override caching.
     *
     * @returns {EventSource} The EventSource instance. The caller must add event listeners.
     */
    this.postAIResponseStream = async function (prompt, promptData, options, sessionID = null, cache = true) {

        const {
            onmessage,
            onerror = (err) => console.error("AI stream error:", err),
            ondone = () => { },
            signal
        } = options || {};

        if (typeof onmessage !== 'function') {
            throw new Error("The 'options' object must include an 'onmessage' function.");
        }

        if (!promptData || !promptData.project) {
            throw new Error("Missing 'project' in promptData");
        }

        const { project, lightweightModel, tools, config, attachments } = promptData;
        if (!project.id) {
            throw new Error("Missing 'project.id' in promptData");
        }

        const params = new URLSearchParams();

        if (sessionID) {
            params.append('sessionID', sessionID);
        }

        params.append('lightweightModel', lightweightModel || false);

        params.append('tools', tools !== false);

        if (!cache) {
            params.append('t', Date.now());
        }

        const url = `${Server.API_ENDPOINT}/ai/stream/project/${encodeURIComponent(project.id)}?${params.toString()}`;

        try {
            const response = await fetch(url, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ prompt, config, attachments }),
                signal: signal,
            });

            if (!response.ok) {
                const errorText = await response.text();
                throw new Error(`HTTP error ${response.status}: ${errorText}`);
            }

            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            let buffer = '';

            while (true) {
                const { value, done } = await reader.read();
                if (done) {
                    break; // Stream finished
                }

                buffer += decoder.decode(value, { stream: true });

                // SSE messages are separated by a blank line
                const parts = buffer.split(/\r?\n\r?\n/);
                buffer = parts.pop(); // The last part might be incomplete, keep it in the buffer

                for (const part of parts) {
                    // Take the data: lines only - comments, event: lines and stray
                    // leading newlines must not throw the whole message away
                    const dataString = part
                        .split(/\r?\n/)
                        .filter(line => line.startsWith('data:'))
                        .map(line => line.substring(5).trim())
                        .join('\n')
                        .trim();

                    if (!dataString) continue;

                    try {
                        onmessage(JSON.parse(dataString));
                    } catch (e) {
                        console.error("Failed to parse JSON from stream chunk:", dataString, e);
                        onerror(e);
                    }
                }
            }

            ondone(); // Signal that the stream has completed successfully

        } catch (err) {
            onerror(err);
        }
    };

    /**
     * Fetches a non-streaming AI response for a project prompt.
     * Responses are cached server-side per project, tier, and prompt.
     *
     * @example
     * server.getAIResponse(123, 'Summarise this project').done(resp => {
     *     console.log(resp);
     * }).catch(function() {
     *     // something went wrong
     * });
     *
     * @param {number} projectID The project ID.
     * @param {string} prompt The prompt to send.
     * @param {string} [tier='balanced'] Model tier: 'fast', 'balanced', or 'powerful'.
     * @param {bool} [cache=true] If false, the query will include a timestamp to bypass caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getAIResponse = function (projectID, prompt, tier = 'balanced', cache = true) {

        const params = new URLSearchParams({ prompt, tier });
        if (!cache) {
            params.append('nocache', '1');
        }

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(projectID)}?${params.toString()}`,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Restores a chat history for a given project.
     *
     * @param {object} project
     * @param {object} history
     * @returns
     */
    this.restoreChat = function (project, history) {
        try {
            const h = JSON.stringify(history);
            return jQuery.post({
                url: `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/restore-chat`,
                data: `{"history": ${h}}`,
                contentType: "application/json",
                crossDomain: true,
                headers: {
                    "X-Requested-With": "XMLHttpRequest",
                },
                xhrFields: {
                    withCredentials: true,
                },
                error: this.errorhandler,
                statusCode: {
                    401: function () {
                        self.unauthorizedhandler();
                    },
                },
            });
        } catch (err) {
            return $.Deferred().reject(new Error(`Failed to restore chat: ${err.message}`));
        }
    };

    /**
     * Fetches what the AI assistant will accept as an attachment, and the limits that apply.
     *
     * Use this to build a file dialog's accept filter and to refuse a hopeless file before
     * uploading it. The 'vision' and 'documents' flags depend on the model in use, and the accept
     * lists are filtered to match - a model that cannot read images returns an empty accept.image.
     *
     * When 'enabled' is false, attachments are switched off on the server and no attach control
     * should be offered at all.
     *
     * @example
     * server.getAIAssistantCapabilities().done(caps => {
     *     if (!caps.enabled) return;
     *     input.accept = [...caps.accept.image, ...caps.accept.document, ...caps.accept.text].join(',');
     * });
     *
     * @param {string} [tier] Model tier to report on - 'fast', 'balanced' or 'powerful'.
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getAIAssistantCapabilities = function (tier = null, cache = true) {

        const params = new URLSearchParams();
        if (tier) {
            params.append('tier', tier);
        }

        const query = params.toString();

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: `${Server.API_ENDPOINT}/ai/capabilities${query ? '?' + query : ''}`,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Uploads a file to attach to an AI chat session and resolves with its metadata.
     *
     * The returned 'id' is passed in the attachments array of postAIResponseStream. The 'inline'
     * flag says whether the file's bytes will travel with the turn or whether the model will read
     * it from the server a portion at a time, which is worth showing to the user: it explains why
     * a large spreadsheet behaves differently from a screenshot.
     *
     * Uses fetch rather than jQuery so that upload progress and cancellation are available. Note
     * that 'credentials: include' is required here: fetch does not honour the xhrFields setting
     * that the jQuery calls in this file rely on.
     *
     * @example
     * const controller = new AbortController();
     * const meta = await server.postAIAttachment(project, file, {signal: controller.signal});
     *
     * @param {object} project The project the chat session belongs to.
     * @param {File} file The file to upload.
     * @param {object} [options] Optional settings.
     * @param {AbortSignal} [options.signal] Signal to abort the upload.
     *
     * @returns {Promise<object>} Resolves with {id, name, mime, size, kind, inline, readable}.
     */
    this.postAIAttachment = async function (project, file, options = {}) {

        if (!project || !project.id) {
            throw new Error("Missing 'project.id'");
        }

        if (!file) {
            throw new Error("No file to attach");
        }

        const body = new FormData();
        body.append('file', file, file.name);

        const response = await fetch(`${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment`, {
            method: 'POST',
            body: body,
            credentials: 'include',
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            signal: options.signal,
        });

        if (!response.ok) {

            // Apache turns an over-sized upload into a 502 rather than passing the 413 through, so
            // both mean the same thing to the person who chose the file.
            if (response.status === 413 || response.status === 502) {
                throw new Error('That file is too large to upload.');
            }

            // The server's rejections here are written for the user - the wrong file type, past
            // their allowance - so prefer its message to a generic one. SendJSONError shapes these
            // as {status, message}.
            let message = `Upload failed (${response.status})`;
            try {
                const problem = await response.json();
                if (problem && problem.message) {
                    message = problem.message;
                }
            } catch (err) {
                // No JSON body; the status-based message above will have to do.
            }

            throw new Error(message);
        }

        return response.json();
    };

    /**
     * Removes a chat attachment.
     *
     * Safe to call more than once, and safe to call for something already expired: the server
     * answers 204 either way. Nothing depends on this being called - abandoned attachments are
     * collected automatically - it simply frees the space sooner.
     *
     * @param {object} project The project the chat session belongs to.
     * @param {string} id The attachment ID.
     * @param {object} [options] Optional settings.
     * @param {bool} [options.keepalive=false] Set true to let the request outlive the page, for
     *   cleanup fired from a pagehide handler.
     *
     * @returns {Promise<void>}
     */
    this.deleteAIAttachment = async function (project, id, options = {}) {

        if (!project || !project.id || !id) {
            return;
        }

        const url = `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment/${encodeURIComponent(id)}`;

        await fetch(url, {
            method: 'DELETE',
            credentials: 'include',
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            keepalive: !!options.keepalive,
        });
    };

    /**
     * Fetches a chat attachment's content as a Blob, for previewing it.
     *
     * Deliberately a fetch rather than a URL to point an <img> or <object> at. The portal is served
     * from a different origin to the API, so an element load would only send the API session cookie
     * if it were marked SameSite=None; and when a login has lapsed the API answers with a redirect,
     * which an element load reports as nothing more than a broken image. Fetching sends credentials
     * explicitly and lets an expired attachment be told apart from an expired session.
     *
     * @param {object} project The project the chat session belongs to.
     * @param {string} id The attachment ID.
     * @param {object} [options] Optional settings.
     * @param {bool} [options.preview=false] If true, a large text attachment is capped at the first
     *   64 KB - all a preview shows in any case.
     * @param {AbortSignal} [options.signal] Signal to abort the fetch.
     *
     * @returns {Promise<Blob>}
     *
     * @throws {Error} With 'expired' set to true when the attachment is no longer available.
     */
    this.getAIAttachmentContent = async function (project, id, options = {}) {

        if (!project || !project.id || !id) {
            throw new Error("Missing project or attachment ID");
        }

        const params = options.preview ? '?preview=1' : '';
        const url = `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment/${encodeURIComponent(id)}/content${params}`;

        const response = await fetch(url, {
            method: 'GET',
            credentials: 'include',
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            signal: options.signal,
        });

        if (!response.ok) {

            // 410 is the server saying the ID was valid but the file has been cleaned up, which is
            // the ordinary end of an attachment's life rather than an error worth apologising for.
            if (response.status === 410) {
                const expired = new Error('This attachment has expired.');
                expired.expired = true;
                throw expired;
            }

            throw new Error(`Could not load the attachment (${response.status})`);
        }

        return response.blob();
    };

    /**
     * Returns the value of the given variable or undefined if it doesn't exist.
     *
     * @param {Object} varName Project to search.
     * @param {string} varName Name of the date variable to format
     *
     * @return {any|undefined} Value of requested variable.
     */
    this.getProjectVar = function (project, varName) {
        return getProjVar(project, varName);
    };

    /**
     * Asynchronously fetches an array of Survey Responses.
     *
     * @example
     * server.getResponses(proj).done(function(responses) {
     *    // Response data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * responses = {
     *    "total": 12345,
     *    "rows": [
     *    {
     *       "accountnumber": 1,
     *       "allowcontact": 1,
     *       "branch": "Branch 1",
     *       "comm1": "A Comment",
     *       "comm2": null,
     *       "comm3": "Another Comment",
     *       "hadproblem": 1,
     *       "id": 1,
     *       "imp1": 3,
     *       "imp2": 7,
     *       "imp3": 8,
     *       "merchant": "Company Ltd.",
     *       "ovsat": 10,
     *       "purchased": 0,
     *       "region": "A Region",
     *       "respondentcompany": "Respondent Ltd.",
     *       "respondentname": "John Smith",
     *       "sat1": 7,
     *       "sat2": 7,
     *       "sat3": 8,
     *       "satindex": 68.90479780129151,
     *       "touchpointdate": "2020-01-18 06:00:37"
     *    },
     *    ...
     *    ],
     *    "truncated": false
     * };
     *
     * @param {object} project Project to inspect
     * @param {string[]} [props=null] Array of properties to return in each Response. Default is to fetch all properties.
     * @param {string} [sort=null] Order in which to return the Responses
     * @param {number} [offset=0] Offset of first Response to fetch (for server-side paging)
     * @param {number} [limit=Number.MAX_SAFE_INTEGER] Maximum number of Responses to return (for server-side paging)
     * @param {number} [sample=0] Request a random sample of Responses
     * @param {string} [filter] Filter to apply to the Responses
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
     * <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
     * <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
     * <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
     */
    this.getResponses = function (project, props, sort, offset, limit, sample, filter, cache) {
        cache = cache !== false;
        const params = [];

        addPropsParam(params, props);
        addSortParam(params, sort);
        addPagingParam(params, offset, limit);
        addSampleParam(params, sample);
        addFilterParam(params, filter);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/responses?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches the coding categories of every coded Response.
     *
     * This method is a special-case of the [<code>getResponses</code>](#getResponses) method.
     * The column set is fixed server-side, so the coding variable can stay marked as not exportable -
     * keeping it out of <code>getResponses</code> and every CSV export - while still being readable here.
     *
     * @example
     * server.getProjectCategories(proj).done(function(responses) {
     *    // Category data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * responses = {
     *    "total": 1234,
     *    "rows": [
     *    {
     *       "coding": "{\"comm1\":{\"+Staff were helpful\":{}}}",
     *       "id": 1,
     *       "month": "2026-04-18 06:00:37"
     *    },
     *    ...
     *    ],
     *    "truncated": false
     * };
     *
     * @param {object} project Project to inspect
     * @param {number} [offset=0] Offset of first Response to fetch (for server-side paging)
     * @param {number} [limit=Number.MAX_SAFE_INTEGER] Maximum number of Responses to return (for server-side paging)
     * @param {string} [filter] Filter to apply to the Responses. This can only ever narrow the resultset - the server ANDs it onto its own clauses rather than replacing them.
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler has the same keys as
     * [<code>getResponses</code>](#getResponses). Each row holds the coding variable and the
     * Project's own date variable - whichever variable the Project places in the
     * <code>response_date</code> category - so callers never need to name either column.
     */
    this.getProjectCategories = function (project, offset, limit, filter, cache) {
        cache = cache !== false;
        const params = [];

        addPagingParam(params, offset, limit);
        addFilterParam(params, filter);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/categories?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches one of more Surveys from Surveys 6.
     *
     * @example
     * server.getSurveys(surveyID).done(function(responses) {
     *    // Summary data in here
     * }).fail(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * {
     *  Survey Object
     * },
     *
     * @param {array} surveyID Encrypted ID of the survey to fetch.
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getSurvey = function (surveyID, cache) {
        cache = cache !== false;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}`,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches a summary of the Survey Response rates from Surveys 6.
     *
     * @example
     * server.getSurveyResponsesSummary(proj).done(function(responses) {
     *    // Summary data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * {
     *  "results": [
     *      {
     *          "completed": 212,
     *          "notStarted": 1339,
     *          "paused": 73,
     *          "site": "Not Known",
     *          "total": 1624,
     *          "usablePaused": 0
     *      },
     *      "totals": [
     *      {
     *          "completed": 212,
     *          "notStarted": 1339,
     *          "paused": 73,
     *          "total": 1624,
     *          "usablePaused": 0
     *      }
     *  ],
     * },
     *
     * @param {string} surveyID ID of the survey to inspect (encrypted)
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getSurveyResponsesSummary = function (surveyID, cache) {
        cache = cache !== false;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}/responses/rates`,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches a summary of the Survey Response rates from Surveys 6.
     *
     * @example
     * server.getSurveyCompletesByDay(proj).done(function(responses) {
     *    // Summary data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * [
     *  {
     *      "count": 47,
     *      "date": "2024-11-14"
     *  },
     *  {
     *      "count": 41,
     *      "date": "2024-11-15"
     *  },
     *  {
     *      "count": 15,
     *      "date": "2024-11-16"
     *  },
     *  ...
     * ],
     *  
     * @param {string} surveyID ID of the survey to inspect (encrypted)
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getSurveyCompletesByDay = function (surveyID, cache) {
        cache = cache !== false;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}/completes-by-day`,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches an array of distinct response values for a given variable.
     * This is often used to dynamically populate a dropdown.
     *
     * @example
     * server.getResponsesDistinct(proj, "region").done(function(responses) {
     *    // Response data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * responses = {
     *    "total": 4,
     *    "rows": [
     *       {
     *       "region": "West Region"
     *       },
     *       {
     *       "region": "North Region"
     *       },
     *       {
     *       "region": "South Region"
     *       },
     *       {
     *       "region": "East Region"
     *       }
     *    ],
     *    "truncated": false
     * };
     *
     * @param {object} project Project to use
     * @param {string} varName Survey variable to inspect
     * @param {string} [filter=null] Filter to apply to the Responses
     * @param {array|string} [groups=null] Group by expressions
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
     * <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
     * <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
     */
    this.getResponsesDistinct = function (project, varName, filter, groups, cache) {
        cache = cache !== false;

        const projVar = getProjVar(project, varName);
        if (!projVar) {
            console.error(`Missing variable ${varName} in project ${project.name}`);
            return;
        }

        const params = [];
        addFilterParam(params, filter);
        addGroupParam(params, groups);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url:
                Server.API_ENDPOINT +
                "/project/" +
                project.id +
                "/responses/distinct/" +
                projVar.id +
                "?" +
                params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches the result of applying an aggregate function, like AVG or COUNT, to one or more Variables.
     * Different backend database engines support different aggregators.
     * The [<code>getInfo</code>](#getInfo) method returns a list of supported aggregators.
     * The <code>function</code> property of each supported aggregator from <code>getInfo</code> may be provided in the <code>aggregator</code> property here.
     *
     * @example
     * server.getResponsesAggregate("myquery", proj, "avg", "ovsat", null, "region").done(function(responses) {
     *    // Response data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * responses = {
     *    "name": "avg",
     *    "tag": "myquery",
     *    "vars": "ovsat",
     *    "group": "region",
     *    "truncated": false,
     *    "rows": [
     *       {
     *          "ovsat": 6.925230769230769,
     *          "region": "East Region"
     *       },
     *       {
     *          "ovsat": 8.563069685386651,
     *          "region": "North Region"
     *       },
     *       {
     *          "ovsat": 7.721746031746032,
     *          "region": "South Region"
     *       },
     *       {
     *          "ovsat": 7.334545454545455,
     *          "region": "West Region"
     *       }
     *    ]
     * };
     *
     * @param {string} name Name of the dataset (included in the returned results in the response's <code>tag</code> key)
     * @param {object} project Project to use
     * @param {string} aggregator Aggregator function to apply (if the aggregator requires a value then append it after a ':' e.g. <code>lte:4</code>)
     * @param {string|array} [varNames=null] Survey variable(s) to inspect
     * @param {string} [filter=null] Filter to apply to the Responses
     * @param {string|array} [groups=null] Survey variable(s) to group by
     * @param {string} [sort=null] Sort order to apply
     * @param {boolean} [rollup=false] When true, and we are grouping, then the results will include summary rows
     * @param {boolean} [cube=false] When true, and we are grouping, then the results will include all combinations of summary rows
     * @param {string} [having=null] Adds an SQL <code>HAVING</code> clause to the resultset
     * @param {string} [rolling=null] Rolling period of the form <em>number</em>[d|w|m|y](<em>varName</em>) e.g. <code>3m(date)</code> or <code>ytd(<em>varName</em>,<em>startMonth</em>)</code>
     * @param {string} [base=null] Base expression to to be used (only applicable to the <code>prop</code> aggregator)  e.g. <code>var1=1 AND var2=1</code>
     * @param {string} [splits=null] Split ranges to to be used (only applicable to the <code>split</code> aggregator) e.g. <code>25,50,75,100</code> to create 4 ranges: 0-25, 25-50, 50-75, 75-100
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     * @param {bool} [anonymize=false] If true, and the Sieve is in Auto mode, the Sieve will always be switched OFF for aggregator queries, but results outside the scope of the Sieve will be anonymized.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
     * <code>name</code> | string | The name of the aggregator function.
     * <code>tag</code> | string | The value of the <code>name</code> parameter.
     * <code>vars</code> | string | CSV of the names of the variables that were inspected, as provided on the <code>varNames</code> parameter.
     * <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
     * <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
     * <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
     * <code>anonymize</code> | boolean | If <code>true</code> the Sieve will NOT anonymize responses, but any Sieved fields will be set to blank.
     */
    this.getResponsesAggregate = function (
        name,
        project,
        aggregator,
        varNames,
        filter,
        groups,
        sort,
        rollup,
        cube,
        having,
        rolling,
        base,
        splits,
        cache,
        anonymize
    ) {
        cache = cache !== false;

        if (!project) {
            return $.Deferred().reject(new Error("Missing aggregator project"));
        }

        if (!aggregator) {
            return $.Deferred().reject(new Error("Missing aggregate function"));
        }

        // Most aggregators require at least one variable
        if (aggregator !== "inspect" && aggregator !== "count" && varNames.length === 0) {
            return $.Deferred().reject(new Error('Missing variable(s) for "' + aggregator + '" aggregator'));
        }

        // The 'prop' aggregator requires a base expression
        if (aggregator === "prop" && !base) {
            return $.Deferred().reject(new Error('Missing base for "prop" aggregator'));
        }

        // The 'split' aggregator requires a base expression
        if (aggregator === "split" && !splits) {
            return $.Deferred().reject(new Error('Missing splits for "split" aggregator'));
        }

        // The 'split' aggregator requires a base expression
        if (aggregator === "split" && !splits) {
            return $.Deferred().reject(new Error('Missing splits for "split" aggregator'));
        }

        // Need some project variables
        if (!project.vars) {
            return $.Deferred().reject(new Error("No project variables"));
        }

        varNames = normalizeToArray(varNames);

        // Build the API query parameters
        const params = ["tag=" + name];
        addFilterParam(params, filter);
        addGroupParam(params, groups);
        addSortParam(params, sort);
        addRollupParam(params, rollup);
        addCubeParam(params, cube);
        addHavingParam(params, having);
        addRollingParam(params, rolling);
        addAnonymizeParam(params, anonymize);

        // Convert the array of variable names to variable IDs
        const ids = varNames.map(function (varName) {
            const v = getProjVar(project, varName);
            if (typeof v === "undefined") {
                console.error("Missing variable", varName);
                return null;
            } else {
                return v.id;
            }
        });

        const connector = ids.length ? "/" : "";
        const aggr = aggregator.split(":");
        const aggrValue = aggr.length > 1 ? "/" + aggr[1] : "";

        let url =
            Server.API_ENDPOINT +
            "/project/" +
            project.id +
            "/responses/" +
            aggr[0] +
            aggrValue +
            connector +
            ids.join(",");

        if (aggr[0] === "prop") {
            url += "/" + encodeURIComponent(base);
        }

        if (aggr[0] === "split") {
            url += "/" + encodeURIComponent(splits);
        }

        url += "?" + params.join("&");

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: url,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches a list of word counts by combining all the given text-based Project Response variables (typically comment responses).
     * The analysis will exclude any stopwords defined in the Project.
     *
     * @example
     * server.getResponsesWordCount("myquery", proj, ["comm1", "comm2"]).done(function(wordcounts) {
     *    // Word count data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * wordcounts = {
     *    "name": "wc",
     *    "tag": "myquery",
     *    "vars": "comm1,comm2",
     *    "group": "",
     *    "truncated": false,
     *    "rows": [
     *       {
     *          "foo": 45,
     *          "bar": 5,
     *          "baz": 271,
     *       }
     *    ]
     * };
     *
     * @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
     * @param {object} project Project to use
     * @param {string|array} varNames Survey variable(s) to inspect
     * @param {string} [filter=null] Filter to apply to the Responses
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
     * <code>name</code> | string | The name of the aggregator function - always <code>wc</code>.
     * <code>tag</code> | string | The value of the <code>name</code> parameter.
     * <code>vars</code> | string | CSV of the names of the variables that were inspected, as provided on the <code>varNames</code> parameter.
     * <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
     * <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
     * <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
     */
    this.getResponsesWordCount = function (name, project, varNames, filter, cache) {
        cache = cache !== false;

        if (!project) {
            return $.Deferred().reject(new Error("Missing aggregator project"));
        }

        // Require at least one variable
        if (varNames.length === 0) {
            return $.Deferred().resolve(new Error("Missing variable(s) for word count"));
        }

        varNames = normalizeToArray(varNames);

        // Build the API query parameters
        const params = ["tag=" + name];
        addFilterParam(params, filter);

        // Convert the array of variable names to variable IDs
        const ids = varNames.map(function (varName) {
            const v = getProjVar(project, varName);
            if (v === "undefined") {
                console.error("Missing variable", varName);
                return null;
            } else {
                return v.id;
            }
        });

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url:
                Server.API_ENDPOINT +
                "/project/" +
                project.id +
                "/responses/wc/" +
                ids.join(",") +
                "?" +
                params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches a list of themes by combining all the given text-based Project Response variables (typically comment responses).
     * Returns a list of themes and the IDs of the associated responses.
     *
     * @example
     * server.getResponsesThemesByFrequency("myquery", proj).done(function(themes) {
     *    // Themes data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * themes = 'These are the main themes contained in the comments.'};
     *
     * @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
     * @param {object} project Project to use
     * @param {string} [filter=null] Filter to apply to the responses before determining the themes
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     */
    this.getResponsesThemesByFrequency = function (name, project, filter, cache) {
        cache = cache !== false;

        if (!project) {
            return $.Deferred().reject(new Error("Missing aggregator project"));
        }

        // Build the API query parameters
        const params = ["tag=" + name];
        addFilterParam(params, filter);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/responses/themes/frequency?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches a list of themes by combining all the given text-based Project Response variables (typically comment responses).
     * Returns a list of themes, the number of Fairly/Very Satisifed responses, and the IDs of the associated responses.
     *
     * @example
     * server.getResponsesThemesByImpact("myquery", proj).done(function(themes) {
     *    // Themes data in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * themes = 'These are the main themes contained in the comments.'};
     *
     * @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
     * @param {object} project Project to use
     * @param {string} varName Overall sat variable to use to calculate the impact
     * @param {string} [filter=null] Filter to apply to the responses before determining the themes
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     */
    this.getResponsesThemesByImpact = function (name, project, varName, filter, cache) {
        cache = cache !== false;

        if (!project) {
            return $.Deferred().reject(new Error("Missing aggregator project"));
        }

        // Build the API query parameters
        const params = ["tag=" + name];
        addFilterParam(params, filter);

        // Convert the array of variable names to variable IDs
        const v = getProjVar(project, varName);
        if (v === "undefined") {
            console.error("Missing variable", varName);
            return null;
        }

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/responses/themes/impact/" + v.id + "?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches an array of responses that triggered a Hot Alert.
     * Requires a single <code>bool</code> Variable in the dataset that has been assigned to the <code>hotalert</code> category.
     * This method returns responses that have the Variable set to <code>true</code> (<code>1</code> in MySQL).
     *
     * This method is a special-case of the [<code>getResponses</code>](#getResponses) method.
     * It exists because its API endpoint is assigned to the <code>hotalert</code> API category, rather than the more generic <code>responses</code> category.
     * Also it limits the returned response data to those variables that have no assigned category, or are in one or more of the following categories: <code>response_date</code>, <code>respondent</code>, <code>hotalert</code>.
     *
     * So a user could be allowed to call it to see HotAlert data, but *not* have access to the full <code>getResponses</code> endpoint's data.
     *
     * @example
     * server.getHotAlertResponses("myquery", proj).done(function(hotalerts) {
     *    // Hot alerts in here
     * }).catch(function() {
     *    // Something went wrong
     * });
     *
     * @example
     * hotalerts = {
     *    "total": "12345",
     *    "truncated": false,
     *    "rows": [
     *       {
     *          "foo": 45,
     *          "bar": "Some hotalert data",
     *          "baz": 271,
     *          "sat1": 8,
     *          "status": "Open|Resolved|Closed",
     *          "status_changed": "2021-02-19 12:02:04.25409345+00:00|2021-02-19 12:01:16.679823054+00:00|2021-02-19 12:02:10.119221324+00:00"
     *       }
     *    ]
     * };
     *
     * @param {string} name Name of the dataset
     * @param {object} project Project to use
     * @param {string} [filter=null] Filter to apply to the Responses
     * @param {array|string} [groups=null] Group by expressions
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
     * <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
     * <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
     * <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
     * <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
     */
    this.getHotAlertResponses = function (name, project, filter, groups, cache) {
        cache = cache !== false;

        const params = ["tag=" + name];
        addFilterParam(params, filter);
        addGroupParam(params, groups);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/hotalerts?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously saves a new Note in the given Project that is associated with the given Response.
     *
     * A Note is an object with the following structure:
     *
     * key | type |description
     * --- | --- | ---
     * <code>author</code> | string | The Note author's full name.
     * <code>value</code> | string | The body text of the Note.
     * <code>type</code> | string | The Note's type. Must be one of <code>comment</code> or <code>status-change</code>.
     * <code>status</code> | number | The current status of the Note. Must be the ID of a Note Status as defined in the <code>note_status</code> table in the database.
     *
     * @example
     * const note = {
     *    author: "Joe Bloggs",
     *    value: "This is a new note",
     *    type: "comment"
     *    status: 3, // Resolved
     * };
     *
     * @example
     * server.saveNote('new-note', proj, participantID, note).done(function () {
     *    // Dispatch a custom event to tell other widgets that a new Note is available
     *    window.dispatchEvent(new CustomEvent('new-note', {detail: note}));
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
     * @param {object} project Project to use
     * @param {number} participantID ID of the Participant to associate with this Note
     * @param {object} note Note to be created
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.saveNote = function (name, project, participantID, note) {
        return jQuery.post({
            url: Server.API_ENDPOINT + "/project/" + project.id + "/response/" + participantID + "/note",
            data: note,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Gets the debug mode of the API.
     *
     * @param {bool} debug Whe true the server API is in debug mode.
     *
     * @returns {jQuery.Deferred}
     */
    this.getAPIDebugMode = function () {
        return jQuery.get({
            url: Server.API_ENDPOINT + "/debug",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Sets the debug mode of the API.
     *
     * @param {bool} debug When set to '1' the API's debug mode will be switched on; if '0' it's switched off.
     *
     * @returns {jQuery.Deferred}
     */
    this.setAPIDebugMode = function (debug) {
        return jQuery.post({
            url: Server.API_ENDPOINT + "/debug/" + (debug ? "1" : "0"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches the PFI associated with the given Project for the logged-in User.
     * PFIs are returned as a single object whose value key is a collection of PFIs separated by pipe characters.
     *
     * @example
     * server.getPFI(project).done(function (data) {
     *    // PFI in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * data = {
     *    "pfi": {
     *       "participant_id": "user@example.com",
     *       "user": "user@example.com",
     *       "type": "pfi",
     *       "value": "This is PFI one|This is PFI 2|This is PFI three!",
     *       "status_id": 0,                                                   // This is always zero for a PFI
     *       "created": "2023-09-28T14:40:00Z"
     *    },
     *    "business_unit": "My Business Unit",
     *    "operating_company": "My Company",
     *    "job_title": "My Job Title"
     * },
     *
     * @param {object} project Project to use
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a simple JSON object containing the User's PFIs for this Project.
     */
    this.getPFI = function (project, cache) {
        cache = cache !== false;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/pfi",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches *all* PFIs in the system.
     * PFIs are returned as an array of PFI objects.
     *
     * @example
     * server.getPFI(project).done(function (pfis) {
     *    // PFIs in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * pfis = [
     * {
     *    "participant_id": "user@example.com",
     *    "user": "user@example.com",
     *    "type": "pfi",
     *    "value": "This is PFI one|This is PFI 2|This is PFI three!",
     *    "status_id": 0,                                                   // This is always zero for a PFI
     *    "created": "2023-09-28T14:40:00Z"
     * }.
     * {
     *    "participant_id": "user2@example.com",
     *    "user": "user2@example.com",
     *    "type": "pfi",
     *    "value": "This is my action|This is anothe one",
     *    "status_id": 0,                                                   // This is always zero for a PFI
     *    "created": "2023-09-29T17:40:23Z"
     * }.
     * ],
     *
     * @param {object} project Project to use
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a simple JSON object containing the User's PFIs for this Project.
     */
    this.getPFIs = function (project, cache) {
        cache = cache !== false;

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/pfis",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously updates the PFIs associated with the given Project for the logged-in User.
     * PFIs are encoded as a single string that is a collection of PFIs separated by pipe characters.
     *
     * @example
     * server.updatePFIs(project, "This is PFI one|This is PFI 2|This is PFI three!");
     *
     * @param {object} project Project to use
     *
     * @returns {jQuery.Deferred}
     *
     */
    this.updatePFIs = function (project, pfis) {
        return jQuery.post({
            url: Server.API_ENDPOINT + "/project/" + project.id + "/pfi",
            dataType: "json",
            data: { value: pfis },
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches an array of notes associated with the given Participant.
     *
     * @example
     * server.getNotes('myquery', proj, participantID).done(function (notes) {
     *    // Notes in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * notes = [
     *    {
     *       "id": 1,
     *       "participant_id": 1,
     *       "user": "Joe Bloggs",
     *       "type": "comment",
     *       "value": "test",
     *       "status_id": 10,
     *       "created": "2021-02-19T12:01:13.280330725Z"
     *    },
     *    {
     *       "id": 2,
     *       "participant_id": 1,
     *       "user": "Joe Bloggs",
     *       "type": "status-change",
     *       "value": "Open",
     *       "status_id": 11,
     *       "created": "2021-02-19T12:01:16.679823054Z"
     *    }
     *    ...
     * ];
     *
     * @param {string} name Name of the dataset
     * @param {object} project Project to use
     * @param {number} participantID ID of the Participant to inspect
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     *
     * The response object passed to the <code>done</code> handler is a simple JSON array of Note objects.
     */
    this.getNotes = function (name, project, participantID, cache, category) {
        cache = cache !== false;

        const params = [];
        if (category) {
            params.push("category=" + encodeURIComponent(category));
        }
        const queryString = params.length ? "?" + params.join("&") : "";

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/participant/" + participantID + "/notes" + queryString,
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches an array of Note Statuses that apply to the given Project.
     *
     * @example
     * server.getNoteStatuses('myquery', proj).done(function (noteStatuses) {
     *    // noteStatuses in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * noteStatuses = [
     *    {
     *       "id": 29,
     *       "project_id": 1,
     *       "value": "Open",
     *       "seq": 0
     *    },
     *    {
     *       "id": 30,
     *       "project_id": 1,
     *       "value": "Pending",
     *       "seq": 1
     *    },
     *    {
     *       "id": 31,
     *       "project_id": 1,
     *       "value": "Resolved",
     *       "seq": 2
     *    },
     *    {
     *       "id": 32,
     *       "project_id": 1,
     *       "value": "Closed",
     *       "seq": 3
     *    }
     * ];
     *
     * @param {string} name Name of the dataset
     * @param {object} project Project to use
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     * The response object passed to the <code>done</code> handler is a JSON arry of NoteStatus objects containing the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>id</code> | number | ID of the NoteStatus
     * <code>project_id</code> | number | ID of the related Project
     * <code>value</code> | number | The text label associated with the NoteStatus
     * <code>seq</code> | number | A sequence number that indicates the logical progression of a Note's status. Dropdowns should display statuses in ascending order of this sequence.
     */
    this.getNoteStatuses = function (name, project, cache) {
        cache = cache !== false;

        const params = ["tag=" + name];

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/notestatus",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches stats about the Hot Alerts pertaining to the given Project.
     *
     * @example
     * server.getHotalertStats('myquery', proj).done(function (hotalertStats) {
     *    // hotalertStats in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * hotalertStats = [
     *    {
     *       "category": "null",
     *       "statusID": 0,
     *       "total": 40
     *    },
     *    {
     *       "category": "Pending",
     *       "statusID": 0,
     *       "total": 16
     *    },
     *    {
     *       "category": "Resolved",
     *       "statusID": 0,
     *       "total": 12
     *    },
     *    {
     *       "category": "Closed",
     *       "statusID": 0,
     *       "total": 16
     *    }
     * ];
     *
     * @param {string} name Name of the dataset
     * @param {object} project Project to use
     * @param {string} [filter] Filter to apply to the Responses
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     * The response object passed to the <code>done</code> handler is a JSON arry of Hotalert Stats objects containing the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>category</code> | string | Each possible Hotalert Status appears in its own stats Category.
     * <code>pstatusID</code> | number | ID of the related Hotalert Status
     * <code>total</code> | number | The total number of Notes in this stats Category,
     */
    this.getHotalertStats = function (name, project, filter, cache, category) {
        cache = cache !== false;

        const params = [];
        addFilterParam(params, filter);
        if (category) {
            params.push("category=" + encodeURIComponent(category));
        }

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/hotalertstats?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches stats about the Hot Alerts status history pertaining to the given Project.
     * Requires a single <code>bool</code> Variable in the dataset that has been assigned to the <code>hotalert</code> category.
     *
     * @example
     * server.getHotalertHistory('myquery', proj).done(function (hotalertHistory) {
     *    // hotalertHistory in here
     * }).catch(function() {
     *    // Something went wrong
     * });;
     *
     * @example
     * hotalertHistory = {
     *    "dates": [
     *       "2021-03-01",
     *       "2021-03-02",
     *       "2021-03-03",
     *    ],
     *    "history": {
     *       "Open": [
     *          100,
     *          99,
     *          97
     *       ],
     *       "Pending": [
     *          0,
     *          1,
     *          1
     *       ],
     *       "Closed": [
     *          0,
     *          0,
     *          2
     *       ]
     *    }
     * };
     *
     * @param {string} name Name of the dataset
     * @param {object} project Project to use
     * @param {string} [filter] Filter to apply
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     * The response object passed to the <code>done</code> handler is a JSON object of a Hotalert History object containing the following keys:
     *
     * key | type |description
     * --- | --- | ---
     * <code>dates</code> | array | An array of dates that contain HotAlerts.
     * <code>history</code> | object | An object whose keys match the possible statuses of a HotAlert for this Project. Each key's value consists of an array of counts, one for each of the dates in the dates array.
     */
    this.getHotalertHistory = function (name, project, filter, cache) {
        cache = cache !== false;

        const params = [];
        addFilterParam(params, filter);

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/hahistory?" + params.join("&"),
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Asynchronously fetches stats about Users' Notes pertaining to the given Project.
     *
     * @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
     * @param {object} project Project to use
     * @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
     *
     * @returns {jQuery.Deferred}
     */
    this.getUserNoteStats = function (name, project, cache) {
        cache = cache !== false;

        const params = ["tag=" + name];

        return jQuery.get({
            dataType: "json",
            cache: cache,
            url: Server.API_ENDPOINT + "/project/" + project.id + "/usernotestats",
            crossDomain: true,
            headers: {
                "X-Requested-With": "XMLHttpRequest",
            },
            xhrFields: {
                withCredentials: true,
            },
            error: this.errorhandler,
            statusCode: {
                401: function () {
                    self.unauthorizedhandler();
                },
            },
        });
    };

    /**
     * Formats an object containing query parameters into a string that can be appended to a URL.
     *
     * @param {object} query
     *
     * @return {string}
     */
    this.formatQuery = function (query) {
        const formattedQuery = [];

        Object.keys(query).forEach(function (key) {
            // Ignore keys that start with an underscore
            if (key.length === 1 || key.substr(0, 1) !== "_") {
                let val;
                if (Array.isArray(query[key])) {
                    val = query[key].join(",");
                } else if (typeof query[key] === "object") {
                    const keys = [];
                    Object.keys(query[key]).forEach(function (k) {
                        keys.push(k + "=" + query[key][k]);
                    });
                    val = keys.join(",");
                } else {
                    val = query[key];
                }

                if (typeof val !== "undefined") {
                    formattedQuery.push(key + "=" + encodeURIComponent(val));
                }
            }
        });

        return formattedQuery.join("&");
    };

    /**
     * Returns the given parameter from the URL query string (as IE11 doesn't support URLSearchParams)
     *
     * @param {string} param
     *
     * @returns {string}
     */
    this.getQueryParameter = function (param) {
        let result = null;
        let tmp = [];
        window.location.search
            .substr(1)
            .split("&")
            .forEach(function (item) {
                tmp = item.split("=");
                if (tmp[0] === param) {
                    result = decodeURIComponent(tmp[1]);
                }
            });
        return result;
    };

    /**
     * Scans the given server response rows and converts any numeric strings to actual numbers.
     *
     * @param response
     *
     * @return {object}
     */
    this.parseResponse = function (response) {
        for (let i = 0; i < response.rows.length; i++) {
            const row = response.rows[i];
            Object.keys(row).forEach(function (datum) {
                if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
                    row[datum] = Number(row[datum]);
                }
            });
        }

        return response;
    };

    /**
     * Returns the SQL needed to format the given date column.
     *
     * @param {string} varName Name of the date variable to format
     * @param {string} format Format string
     *
     * @return {string|null} SQL function needed to format the given variable
     */
    this.getSqlDateFormat = function (varName, format) {
        if (!server.Globals.databaseType) {
            console.error("Missing database type");
            return null;
        }

        return server.Globals.databaseType === "mysql"
            ? "DATE_FORMAT({" + varName + '}, "' + format + '")'
            : 'strftime("' + format + '", datetime({' + varName + '}, "localtime"))';
    };

    // Inject the debugger if needed
    if (this.getQueryParameter("debug")) {
        this.debugger = new Debugger();
        this.debugger.init(this);
    }

    /**
     * Returns the requested presentation template 
     *
     * @param {string} name Name of the template to fetch
     *
     * @return {object} Template object
     */
    this.getPresentationTemplate = async function (name) {
        const onStaging = window.location.origin.includes(".wr3s.");
        const rootURL = onStaging ? "https://static.wr3s.leadershipfactor.com" : "https://static.leadershipfactor.com";
        return await import(`${rootURL}/js/lib/ppt-templates/${name}.js`);
    };

    // Inject the debugger if needed
    if (this.getQueryParameter("debug")) {
        this.debugger = new Debugger();
        this.debugger.init(this);
    }

    /**
     * Adds the given properties to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} props
     *
     */
    function addPropsParam(params, props) {
        if (props) {
            props = normalizeToArray(props);
            if (props.length) {
                params.push("props=" + encodeURIComponent(props.join(",")));
            }
        }
    }

    /**
     * Adds the given sample to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} sample
     *
     */
    function addSampleParam(params, sample) {
        if (sample) {
            params.push("sample=" + encodeURIComponent(sample));
        }
    }

    /**
     * Adds the given filter to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} filter
     *
     */
    function addFilterParam(params, filter) {
        if (filter) {
            params.push("filter=" + encodeURIComponent(filter));
        }
    }

    /**
     * Adds the given sort to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} sort
     *
     */
    function addSortParam(params, sort) {
        if (sort) {
            params.push("sort=" + encodeURIComponent(sort));
        }
    }

    /**
     * Adds the given having to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} having
     *
     */
    function addHavingParam(params, having) {
        if (having) {
            params.push("having=" + encodeURIComponent(having));
        }
    }

    /**
     * Adds the given rolling to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} rolling
     *
     */
    function addRollingParam(params, rolling) {
        if (rolling) {
            params.push("rolling=" + encodeURIComponent(rolling));
        }
    }

    /**
     * Adds the given rollup to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} rollup
     *
     */
    function addRollupParam(params, rollup) {
        if (rollup) {
            params.push("rollup=1");
        }
    }

    /**
     * Adds the given cube to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} cube
     *
     */
    function addCubeParam(params, cube) {
        if (cube) {
            params.push("cube=1");
        }
    }
    /**
     * Adds the given anonymize to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} cube
     *
     */
    function addAnonymizeParam(params, cube) {
        if (cube) {
            params.push("anonymize=1");
        }
    }

    /**
     * Adds the given groups to the URL
     *
     * @private
     *
     * @param {array} params
     * @param {string|array} groups
     *
     */
    function addGroupParam(params, groups) {
        groups = normalizeToArray(groups);
        if (groups.length) {
            params.push("group=" + encodeURIComponent(groups.join(",")));
        }
    }

    /**
     * Add server-side paging to the given parameter list.
     *
     * @private
     *
     * @param params
     * @param offset
     * @param limit
     */
    function addPagingParam(params, offset, limit) {
        if (typeof offset !== "undefined" && typeof limit !== "undefined") {
            params.push("offset=" + encodeURIComponent(offset));
            params.push("limit=" + encodeURIComponent(limit));
        }
    }

    /**
     * Normalizes the given value to an array.
     *
     * @private
     *
     * @param {*} val
     * @return {Array}
     */
    function normalizeToArray(val) {
        if (typeof val === "undefined" || val === null) {
            return [];
        }

        return Array.isArray(val) ? val : [val];
    }

    /**
     * Returns the value of the given variable or undefined if it doesn't exist.
     *
     * @param {Object} project Project to search.
     * @param {string} varName Name of the date variable to format
     *
     * @return {any|undefined} Value of requested variable.
     */
    function getProjVar(project, varName) {

        if (typeof project !== 'object') {
            console.error('Project must be an object');
            return undefined;
        }

        if (typeof varName !== 'string') {
            console.error('Variable name must be a string');
            return undefined;
        }

        if (project.vars[varName]) {
            return project.vars[varName];
        }

        const varNames = Object.keys(project.vars);
        for (let i = 0; i < varNames.length; i++) {
            const v = varNames[i];
            if (v.toLowerCase() === varName.toLowerCase()) {
                return project.vars[v];
            }
        }

        return undefined;
    }
}