// Provide a way to access grist for iframe, web worker (which runs the main safeBrowser script) and // unsafeNode. WebView should work the same way as iframe, grist is exposed just the same way and // necessary api is exposed using preload script. Here we bootstrap from channel capabilities to key // parts of the grist API. // For iframe (and webview): // user will add '' and get a window.grist // For web worker: // use will add `self.importScripts('/grist-api.js');` // For node, user will do something like: // const {grist} = require('grist-api'); // grist.registerFunction(); // In TypeScript: // import {grist} from 'grist-api'; // grist.registerFunction(); // tslint:disable:no-console import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest, WidgetColumnMap } from './CustomSectionAPI'; import { AccessTokenOptions, AccessTokenResult, GristAPI, GristDocAPI, GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI'; import { RowRecord } from './GristData'; import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI'; import { decodeObject, mapValues } from './objtypes'; import { RenderOptions, RenderTarget } from './RenderOptions'; import { TableOperations } from './TableOperations'; import { TableOperationsImpl } from './TableOperationsImpl'; import { checkers } from './TypeCheckers'; import { WidgetAPI } from './WidgetAPI'; export * from './TypeCheckers'; export * from './FileParserAPI'; export * from './GristAPI'; export * from './GristData'; export * from './GristTable'; export * from './ImportSourceAPI'; export * from './StorageAPI'; export * from './RenderOptions'; export * from './WidgetAPI'; export * from './CustomSectionAPI'; import {IRpcLogger, Rpc} from 'grain-rpc'; import isEqual from 'lodash/isEqual'; export const rpc: Rpc = new Rpc({logger: createRpcLogger()}); export const api = rpc.getStub(RPC_GRISTAPI_INTERFACE, checkers.GristAPI); export const coreDocApi = rpc.getStub('GristDocAPI@grist', checkers.GristDocAPI); /** * Interface for the records backing a custom widget. */ export const viewApi = rpc.getStub('GristView', checkers.GristView); /** * Interface for the state of a custom widget. */ export const widgetApi = rpc.getStub('WidgetAPI', checkers.WidgetAPI); /** * Interface for the mapping of a custom widget. */ export const sectionApi = rpc.getStub('CustomSectionAPI', checkers.CustomSectionAPI); export const commandApi = rpc.getStub('CommandAPI'); /** * Shortcut for [[GristView.allowSelectBy]]. */ export const allowSelectBy = viewApi.allowSelectBy; /** * Shortcut for [[GristView.setSelectedRows]]. */ export const setSelectedRows = viewApi.setSelectedRows; /** * Sets the cursor position in a linked section. */ export const setCursorPos = viewApi.setCursorPos; /** * Fetches data backing the widget as for [[GristView.fetchSelectedTable]], * but decoding data by default, replacing e.g. ['D', timestamp] with * a moment date. Option `keepEncoded` skips the decoding step. */ export async function fetchSelectedTable(options: {keepEncoded?: boolean} = {}) { const table = await viewApi.fetchSelectedTable(); return options.keepEncoded ? table : mapValues(table, (col) => col.map(decodeObject)); } /** * Fetches current selected record as for [[GristView.fetchSelectedRecord]], * but decoding data by default, replacing e.g. ['D', timestamp] with * a moment date. Option `keepEncoded` skips the decoding step. */ export async function fetchSelectedRecord(rowId: number, options: {keepEncoded?: boolean} = {}) { const rec = await viewApi.fetchSelectedRecord(rowId); return options.keepEncoded ? rec : mapValues(rec, decodeObject); } /** * A collection of methods for fetching document data. The * fetchSelectedTable and fetchSelectedRecord methods are * overridden to decode data by default. */ export const docApi: GristDocAPI & GristView = { ...coreDocApi, ...viewApi, fetchSelectedTable, fetchSelectedRecord, }; export const on = rpc.on.bind(rpc); // Exposing widgetApi methods in a module scope. /** * Shortcut for [[WidgetAPI.getOption]] */ export const getOption = widgetApi.getOption.bind(widgetApi); /** * Shortcut for [[WidgetAPI.setOption]] */ export const setOption = widgetApi.setOption.bind(widgetApi); /** * Shortcut for [[WidgetAPI.setOptions]] */ export const setOptions = widgetApi.setOptions.bind(widgetApi); /** * Shortcut for [[WidgetAPI.getOptions]] */ export const getOptions = widgetApi.getOptions.bind(widgetApi); /** * Shortcut for [[WidgetAPI.clearOptions]] */ export const clearOptions = widgetApi.clearOptions.bind(widgetApi); /** * Get access to a table in the document. If no tableId specified, this * will use the current selected table (for custom widgets). * If a table does not exist, there will be no error until an operation * on the table is attempted. */ export function getTable(tableId?: string): TableOperations { return new TableOperationsImpl({ async getTableId() { return tableId || await getSelectedTableId(); }, throwError(verb, text, status) { throw new Error(text); }, applyUserActions(actions, opts) { return docApi.applyUserActions(actions, opts); }, }, {}); } /** * Get an access token, for making API calls outside of the custom widget * API. There is no caching of tokens. The returned token can * be used to authorize regular REST API calls that access the content of the * document. For example, in a custom widget for a table with a `Photos` column * containing attachments, the following code will update the `src` of an * image with id `the_image` to show the attachment: * ```js * grist.onRecord(async (record) => { * const tokenInfo = await grist.docApi.getAccessToken({readOnly: true}); * const img = document.getElementById('the_image'); * const id = record.Photos[0]; // get an id of an attachment - there could be several * // in a cell, for this example we just take the first. * const src = `${tokenInfo.baseUrl}/attachments/${id}/download?auth=${tokenInfo.token}`; * img.setAttribute('src', src); * }); * ``` */ export async function getAccessToken(options?: AccessTokenOptions): Promise { return docApi.getAccessToken(options || {}); } /** * Get the current selected table (for custom widgets). */ export const selectedTable: TableOperations = getTable(); // Get the ID of the current selected table (for custom widgets). // Will wait for the table ID to be set. export async function getSelectedTableId(): Promise { await _initialization; return _tableId!; } // Get the ID of the current selected table if set (for custom widgets). // The ID may take some time to be set, or may never be set if the widget // is not linked to anything. export function getSelectedTableIdSync(): string|undefined { return _tableId; } // For custom widgets that support custom columns mappings store current configuration // in a memory. // Actual cached value. Undefined means that widget hasn't asked for configuration yet. // Here we are storing serialized configuration instead of actual one, since widget can // mutate returned value. let _mappingsCache: WidgetColumnMap|null|undefined; // Since widget needs to ask for mappings during onRecord and onRecords event, we will reuse // current request if available; let _activeRefreshReq: Promise|null = null; // Remember columns requested during ready call. let _columnsToMap: ColumnsToMap|undefined; let _tableId: string|undefined; let _setInitialized: () => void; const _initialization = new Promise(resolve => _setInitialized = resolve); let _readyCalled: boolean = false; async function getMappingsIfChanged(data: any): Promise { const uninitialized = _mappingsCache === undefined; if (data.mappingsChange || uninitialized) { // If no active request. if (!_activeRefreshReq) { // Request for new mappings. _activeRefreshReq = sectionApi .mappings() // Store it in global variable. .then(mappings => void (_mappingsCache = mappings)) // Clear current request variable. .finally(() => _activeRefreshReq = null); } await _activeRefreshReq; } return _mappingsCache ? JSON.parse(JSON.stringify(_mappingsCache)) : null; } /** * Renames columns in the result using columns mapping configuration passed in ready method. * Returns null if not all required columns were mapped or not widget doesn't support * custom column mapping. */ export function mapColumnNames(data: any, options?: { columns?: ColumnsToMap mappings?: WidgetColumnMap|null, reverse?: boolean, }) { options = {columns: _columnsToMap, mappings: _mappingsCache, reverse: false, ...options}; // If no column configuration was requested or // table has no rows, return original data. if (!options.columns) { return data; } // If we haven't received columns configuration return null. if (!options.mappings) { return null; } // If we are renaming names for whole table, but it is empty, don't do anything. if (Array.isArray(data) && data.length === 0) { return data; } // Prepare convert function - a function that will take record returned from Grist // and convert it to a new record with mapped field names; // Convert function will consists of several transformations: const transformations: ((from: any, to: any) => void)[] = []; // First transformation is for copying id field: transformations.push((from, to) => to.id = from.id); // Helper function to test if a column was configured as optional. function isOptional(col: string) { return Boolean( // Columns passed as strings are required. !options!.columns?.includes(col) && options!.columns?.find(c => typeof c === 'object' && c?.name === col && c.optional) ); } // For each widget column in mapping. // Keys are ordered for determinism in case of conflicts. for(const widgetCol of Object.keys(options.mappings).sort()) { // Get column from Grist. const gristCol = options.mappings[widgetCol]; // Copy column as series (multiple values) if (Array.isArray(gristCol) && gristCol.length) { if (!options.reverse) { transformations.push((from, to) => { to[widgetCol] = gristCol.map(col => from[col]); }); } else { transformations.push((from, to) => { for (const [idx, col] of gristCol.entries()) { to[col] = from[widgetCol]?.[idx]; } }); } // Copy column directly under widget column name. } else if (!Array.isArray(gristCol) && gristCol) { if (!options.reverse) { transformations.push((from, to) => to[widgetCol] = from[gristCol]); } else { transformations.push((from, to) => to[gristCol] = from[widgetCol]); } } else if (!isOptional(widgetCol)) { // Column was not configured but was required. return null; } } // Finally assemble function to convert a single record. const convert = (rec: any) => transformations.reduce((obj, tran) => { tran(rec, obj); return obj; }, {} as any); // Transform all records (or a single one depending on the arguments). return Array.isArray(data) ? data.map(convert) : convert(data); } /** * Offer a convenient way to map data with renamed columns back into the * form used in the original table. This is useful for making edits to the * original table in a widget with column mappings. As for mapColumnNames(), * we don't attempt to do these transformations automatically. */ export function mapColumnNamesBack(data: any, options?: { columns?: ColumnsToMap mappings?: WidgetColumnMap|null, }) { return mapColumnNames(data, {...options, reverse: true}); } /** * For custom widgets, add a handler that will be called whenever the * row with the cursor changes - either by switching to a different row, or * by some value within the row potentially changing. Handler may * in the future be called with null if the cursor moves away from * any row. */ export function onRecord(callback: (data: RowRecord | null, mappings: WidgetColumnMap | null) => unknown) { // TODO: currently this will be called even if the content of a different row changes. on('message', async function(msg) { if (!msg.tableId || !msg.rowId || msg.rowId === 'new') { return; } const rec = await docApi.fetchSelectedRecord(msg.rowId); callback(rec, await getMappingsIfChanged(msg)); }); } /** * For custom widgets, add a handler that will be called whenever the * new (blank) row is selected. */ export function onNewRecord(callback: (mappings: WidgetColumnMap | null) => unknown) { on('message', async function(msg) { if (msg.tableId && msg.rowId === 'new') { callback(await getMappingsIfChanged(msg)); } }); } /** * For custom widgets, add a handler that will be called whenever the * selected records change. Handler will be called with a list of records. */ export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMap | null) => unknown) { on('message', async function(msg) { if (!msg.tableId || !msg.dataChange) { return; } const data = await docApi.fetchSelectedTable(); if (!data.id) { return; } const rows: RowRecord[] = []; for (let i = 0; i < data.id.length; i++) { const row: RowRecord = {id: data.id[i]}; for (const key of Object.keys(data)) { row[key] = data[key][i]; } rows.push(row); } callback(rows, await getMappingsIfChanged(msg)); }); } /* Keep track of the completion status of all initial onOptions calls made prior to sending * the ready message. The `ready` function will wait for this list of calls to settle after * it receives the initial options message from Grist. * * Note that this includes all internal calls to `onOptions`, such as the one made by * `syncThemeWithGrist`. */ const _pendingInitializeOptionsCalls: Promise[] = []; /** * For custom widgets, add a handler that will be called whenever the * widget options change (and on initial ready message). Handler will be * called with an object containing saved json options, or null if no options were saved. * The second parameter has information about the widgets relationship with * the document that contains it. */ export function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) { let finishInitialization: () => void; if (!_readyCalled) { const promise = new Promise(resolve => { finishInitialization = resolve; }); _pendingInitializeOptionsCalls.push(promise); } on('message', async function(msg) { if (msg.settings) { await callback(msg.options || null, msg.settings); finishInitialization?.(); } }); } /** * Calling `addImporter(...)` adds a safeBrowser importer. It is a short-hand for forwarding calls * to an `ImportSourceAPI` implementation registered in the file at `path`. It takes care of * creating the stub, registering an implementation that renders the file, forward the call and * dispose the view properly. If `mode` is `'inline'` embeds the view in the import modal, otherwise * renders fullscreen. * * Notes: it assumes that file at `path` registers an `ImportSourceAPI` implementation under * `name`. Calling `addImporter(...)` from another component than a `safeBrowser` component is not * currently supported. * * @internal */ export async function addImporter(name: string, path: string, mode: 'fullscreen' | 'inline', options?: RenderOptions) { // checker is omitted for implementation because call was already checked by grist. rpc.registerImpl(name, { async getImportSource(target: RenderTarget): Promise { const procId = await api.render(path, mode === 'inline' ? target : 'fullscreen', options); try { // stubName for the interface `name` at forward destination `path` const stubName = `${name}@${path}`; // checker is omitted in stub because call will be checked just after in grist. return await rpc.getStub(stubName).getImportSource(); } finally { await api.dispose(procId); } } }); } export function enableKeyboardShortcuts() { const Mousetrap = require('mousetrap'); Mousetrap.bind('mod+z', () => commandApi.run('undo')); Mousetrap.bind(['mod+shift+z', 'ctrl+y'], () => commandApi.run('redo')); } /** * Options when initializing connection to Grist. */ export interface ReadyPayload extends Omit { /** * Handler that will be called by Grist to open additional configuration panel inside the Custom Widget. */ onEditOptions?: () => unknown; } /** * Declare that a component is prepared to receive messages from the outside world. * Grist will not attempt to communicate with it until this method is called. */ export function ready(settings?: ReadyPayload): void { // Make it safe for this method to be called multiple times. if (_readyCalled) { return; } _readyCalled = true; if (settings && settings.onEditOptions) { rpc.registerFunc('editOptions', settings.onEditOptions); } on('message', async function(msg) { if (msg.settings && msg.fromReady) { /* Grist sends an options message immediately after receiving a ready message, containing * some initial settings (such as the Grist theme). Grist may decide to wait until all * initial onOptions calls have completed before making the custom widget's iframe visible * to the client. This is the case when a widget's manifest explicitly declares a widget * must be rendered after the ready, or in subsequent renders of a custom widget that * previously sent Grist a ready message. The reason for this behavior is to make the * experience of embedding iframes within a Grist document feel more seamless and polished. * * Here, we check for that initial options message, waiting for all onOptions calls to * settle before notifying Grist that all settings have been initialized. */ await Promise.all(_pendingInitializeOptionsCalls); void (async function() { await rpc.postMessage({settings: {status: 'initialized'}}); })(); } if (msg.tableId && msg.tableId !== _tableId) { if (!_tableId) { _setInitialized(); } _tableId = msg.tableId; } }); rpc.processIncoming(); void (async function() { await rpc.sendReadyMessage(); if (settings) { const options = { ...(settings), hasCustomOptions: Boolean(settings.onEditOptions), }; delete options.onEditOptions; _columnsToMap = options.columns; await sectionApi.configure(options).catch((err: unknown) => console.error(err)); } })(); } /** @internal */ function getPluginPath(location: Location) { return location.pathname.replace(/^\/plugins\//, ''); } if (typeof window !== 'undefined') { // Window or iframe. const preloadWindow: any = window; if (preloadWindow.isRunningUnderElectron) { rpc.setSendMessage(msg => preloadWindow.sendToHost(msg)); preloadWindow.onGristMessage((data: any) => rpc.receiveMessage(data)); } else { rpc.setSendMessage(msg => window.parent.postMessage(msg, "*")); window.onmessage = (e: MessageEvent) => rpc.receiveMessage(e.data); } // Allow outer Grist application to trigger printing. This is similar to using // iframe.contentWindow.print(), but that call does not work cross-domain. rpc.registerFunc("print", () => window.print()); } else if (typeof process === 'undefined') { // Web worker. We can't really bring in the types for WebWorker (available with --lib flag) // without conflicting with a regular window, so use just use `self as any` here. self.onmessage = (e: MessageEvent) => rpc.receiveMessage(e.data); rpc.setSendMessage((mssg: any) => (self as any).postMessage(mssg)); } else if (typeof process.send !== 'undefined') { // Forked ChildProcess of node or electron. // sendMessage callback returns void 0 because rpc process.send returns a boolean and rpc // expecting void|Promise interprets truthy values as Promise which cause failure. rpc.setSendMessage((data) => { process.send!(data); }); process.on('message', (data: any) => rpc.receiveMessage(data)); process.on('disconnect', () => { process.exit(0); }); } else { // Not a recognized environment, perhaps plain nodejs run independently of Grist, or tests // running under mocha. For now, we only provide a dysfunctional implementation. It allows // plugins to call methods like registerFunction() without failing, so that plugin code may be // imported, but the methods don't do anything useful. rpc.setSendMessage((data) => { return; }); } /** @internal */ function createRpcLogger(): IRpcLogger { let prefix: string; if (typeof window !== 'undefined') { prefix = `PLUGIN VIEW ${getPluginPath(window.location)}:`; } else if (typeof process === 'undefined') { prefix = `PLUGIN VIEW ${getPluginPath(self.location)}:`; } else if (typeof process.send !== 'undefined') { prefix = `PLUGIN NODE ${process.env.GRIST_PLUGIN_PATH || ""}:`; } else { return {}; } return { info(msg: string) { console.log("%s %s", prefix, msg); }, warn(msg: string) { console.warn("%s %s", prefix, msg); }, }; } let _theme: any; function syncThemeWithGrist() { onOptions((_options, settings) => { if (settings.theme && !isEqual(settings.theme, _theme)) { _theme = settings.theme; attachCssThemeVars(_theme); } }); } function attachCssThemeVars({appearance, colors}: any) { // Prepare the custom properties needed for applying the theme. const properties = Object.entries(colors) .map(([name, value]) => `--grist-theme-${name}: ${value};`); // Include properties for styling the scrollbar. properties.push(...getCssScrollbarProperties(appearance)); // Apply the properties to the theme style element. getOrCreateStyleElement('grist-theme').textContent = `:root { ${properties.join('\n')} }`; // Make the browser aware of the color scheme. document.documentElement.style.setProperty(`color-scheme`, appearance); } function getCssScrollbarProperties(appearance: 'light' | 'dark') { return [ '--scroll-bar-fg: ' + (appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'), '--scroll-bar-hover-fg: ' + (appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'), '--scroll-bar-active-fg: ' + (appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'), '--scroll-bar-bg: ' + (appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'), ]; } function getOrCreateStyleElement(id: string) { let style = document.head.querySelector(`#${id}`); if (style) { return style; } style = document.createElement('style'); style.setAttribute('id', id); document.head.append(style); return style; } syncThemeWithGrist();