diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 38b2a6f2..94eaca55 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -601,6 +601,14 @@ export class GristDoc extends DisposableWithEvents { ); } + public getXlsxLink() { + const params = { + ...this.docComm.getUrlParams(), + title: this.docPageModel.currentDocTitle.get(), + }; + return this.docComm.docUrl(`gen_xlsx`) + '?' + encodeQueryParams(params); + } + public getCsvLink() { const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({ colRef : field.colRef.peek(), @@ -614,7 +622,7 @@ export class GristDoc extends DisposableWithEvents { activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()), filters : JSON.stringify(filters), }; - return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams(params); + return this.docComm.docUrl(`gen_csv`) + '?' + encodeQueryParams(params); } public hasGranularAccessRules(): boolean { diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 9b51cc2b..871df798 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -3,6 +3,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel'; import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; +import {sendToDrive} from 'app/client/ui/sendToDrive'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; import {primaryButton} from 'app/client/ui2018/buttons'; import {colors, mediaXSmall, testId} from 'app/client/ui2018/cssVars'; @@ -222,6 +223,10 @@ function menuExports(doc: Document, pageModel: DocPageModel) { ), menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, menuIcon('Download'), 'Export CSV', testId('tb-share-option')), + menuItemLink({ href: gristDoc.getXlsxLink(), target: '_blank', download: ''}, + menuIcon('Download'), 'Export XLSX', testId('tb-share-option')), + menuItem(() => sendToDrive(doc, pageModel), + menuIcon('Download'), 'Send to Google Drive', testId('tb-share-option')), ]; } diff --git a/app/client/ui/sendToDrive.ts b/app/client/ui/sendToDrive.ts new file mode 100644 index 00000000..df982fdd --- /dev/null +++ b/app/client/ui/sendToDrive.ts @@ -0,0 +1,106 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {getHomeUrl} from 'app/client/models/AppModel'; +import {reportError} from 'app/client/models/errors'; +import {spinnerModal} from 'app/client/ui2018/modals'; +import type { DocPageModel } from 'app/client/models/DocPageModel'; +import type { Document } from 'app/common/UserAPI'; +import type { Disposable } from 'grainjs'; + +const G = getBrowserGlobals('window'); + +/** + * Generates Google Auth endpoint (exposed by Grist) url. For example: + * https://docs.getgrist.com/auth/google + * @param scope Requested access scope for Google Services + * https://developers.google.com/identity/protocols/oauth2/scopes + */ +function getGoogleAuthEndpoint(scope?: string) { + return new URL(`auth/google?scope=${scope || ''}`, getHomeUrl()).href; +} + +/** + * Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access token, + * then it calls "send-to-drive" api endpoint to upload xlsx file to drive and finally it redirects + * to the created spreadsheet. + * Code that is received from Google contains encrypted access token, server is able to decrypt it + * using GOOGLE_CLIENT_SECRET key. + */ +export function sendToDrive(doc: Document, pageModel: DocPageModel) { + // Get current document - it will be used to remove popup listener. + const gristDoc = pageModel.gristDoc.get(); + // Sanity check - gristDoc should be always present + if (!gristDoc) { throw new Error("Grist document is not present in Page Model"); } + + // Create send to google drive handler (it will return a spreadsheet url). + const send = (code: string) => + // Decorate it with a spinner + spinnerModal('Sending file to Google Drive', + pageModel.appModel.api.getDocAPI(doc.id) + .sendToDrive(code, pageModel.currentDocTitle.get()) + ); + + // Compute google auth server endpoint (grist endpoint for server side google authentication). + // This endpoint will redirect user to Google Consent screen and after Google sends a response, + // it will render a page (/static/message.html) that will post a message containing message + // from Google. Message will be an object { code, error }. We will use the code to invoke + // "send-to-drive" api endpoint - that will actually send the xlsx file to Google Drive. + const authLink = getGoogleAuthEndpoint(); + const authWindow = openPopup(authLink); + attachListener(gristDoc, authWindow, async (event: MessageEvent) => { + // For the first message (we expect only a single message) close the window. + authWindow.close(); + + // Check response from the popup + const response = (event.data || {}) as { code?: string, error?: string }; + // - when user declined, do nothing, + if (response.error === "access_denied") { return; } + // - when there is no response code or error code is different from what we expected - report to user. + if (!response.code) { reportError(response.error || "Unrecognized or empty error code"); return; } + + // Send file to Google Drive. + try { + const { url } = await send(response.code); + G.window.location.assign(url); + } catch (err) { + reportError(err); + } + }); +} + +// Helper function that attaches a handler to message event from a popup window. +function attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent) => any) { + const wrapped = (e: MessageEvent) => { + // Listen to events only from our window. + if (e.source !== popup) { return; } + // In case when Grist was reloaded or user navigated away - do nothing. + if (owner.isDisposed()) { return; } + listener(e); + }; + G.window.addEventListener('message', wrapped); + owner.onDispose(() => { + G.window.removeEventListener('message', wrapped); + }); +} + +function openPopup(url: string): Window { + // Center window on desktop + // https://stackoverflow.com/questions/16363474/window-open-on-a-multi-monitor-dual-monitor-system-where-does-window-pop-up + const width = 600; + const height = 650; + const left = window.screenX + (screen.width - width) / 2; + const top = (screen.height - height) / 4; + let windowFeatures = `top=${top},left=${left},menubar=no,location=no,` + + `resizable=yes,scrollbars=yes,status=yes,height=${height},width=${width}`; + + // If window will be too large (for example on mobile) - open as a new tab + if (screen.width <= width || screen.height <= height) { + windowFeatures = ''; + } + + const authWindow = G.window.open(url, "GoogleAuthPopup", windowFeatures); + if (!authWindow) { + // This method should be invoked by an user action. + throw new Error("This method should be invoked synchronously"); + } + return authWindow; +} diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index a9059094..11247a1c 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -344,6 +344,13 @@ export interface DocAPI { // is HEAD, the result will contain a copy of any rows added or updated. compareVersion(leftHash: string, rightHash: string): Promise; getDownloadUrl(template?: boolean): string; + /** + * Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first + * acquire "code" via Google Auth Endpoint (see ShareMenu.ts for an example). + * @param code Authorization code returned from Google (requested via Grist's Google Auth Endpoint) + * @param title Name of the spreadsheet that will be created (should use a Grist document's title) + */ + sendToDrive(code: string, title: string): Promise<{url: string}>; } // Operations that are supported by a doc worker. @@ -775,4 +782,11 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { public getDownloadUrl(template: boolean = false) { return this._url + `/download?template=${Number(template)}`; } + + public async sendToDrive(code: string, title: string): Promise<{url: string}> { + const url = new URL(`${this._url}/send-to-drive`); + url.searchParams.append('title', title); + url.searchParams.append('code', code); + return this.requestJson(url.href); + } } diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 3f406a3a..a788b8bf 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -133,7 +133,7 @@ export function undef>(...list: T): Undef { */ export function safeJsonParse(json: string, defaultVal: any): any { try { - return JSON.parse(json); + return json !== '' && json !== undefined ? JSON.parse(json) : defaultVal; } catch (e) { return defaultVal; } diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index c3947c4d..6e7415c7 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -42,6 +42,7 @@ export class DocApiForwarder { app.use('/api/docs/:docId/remove', withDoc); app.delete('/api/docs/:docId', withDoc); app.use('/api/docs/:docId/download', withDoc); + app.use('/api/docs/:docId/send-to-drive', withDoc); app.use('/api/docs/:docId/fork', withDoc); app.use('/api/docs/:docId/create-fork', withDoc); app.use('/api/docs/:docId/apply', withDoc); diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 40c788d6..1c9c171d 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -28,6 +28,8 @@ import * as contentDisposition from 'content-disposition'; import { Application, NextFunction, Request, RequestHandler, Response } from "express"; import fetch from 'node-fetch'; import * as path from 'path'; +import { exportToDrive } from "app/server/lib/GoogleExport"; +import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth"; // Cap on the number of requests that can be outstanding on a single document via the // rest doc api. When this limit is exceeded, incoming requests receive an immediate @@ -92,6 +94,8 @@ export class DocWorkerApi { const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true)); // check document exists, don't check user access const docExists = expressWrap(this._assertAccess.bind(this, null, false)); + // converts google code to access token and adds it to request object + const decodeGoogleToken = expressWrap(googleAuthTokenMiddleware.bind(null)); // Middleware to limit number of outstanding requests per document. Will also // handle errors like expressWrap would. @@ -443,6 +447,8 @@ export class DocWorkerApi { res.json(result); })); + this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive)); + // Create a document. When an upload is included, it is imported as the initial // state of the document. Otherwise a fresh empty document is created. // A "timezone" option can be supplied. diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts index 1b6a9b45..5914f4b5 100644 --- a/app/server/lib/DocWorker.ts +++ b/app/server/lib/DocWorker.ts @@ -13,7 +13,7 @@ import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import * as log from 'app/server/lib/log'; import {integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils'; import {OpenMode, quoteIdent, SQLiteDB} from 'app/server/lib/SQLiteDB'; -import {generateCSV} from 'app/server/serverMethods'; +import {generateCSV, generateXLSX} from 'app/server/serverMethods'; import * as contentDisposition from 'content-disposition'; import * as express from 'express'; import * as fse from 'fs-extra'; @@ -34,6 +34,10 @@ export class DocWorker { await generateCSV(req, res, this._comm); } + public async getXLSX(req: express.Request, res: express.Response): Promise { + await generateXLSX(req, res, this._comm); + } + public async getAttachment(req: express.Request, res: express.Response): Promise { try { const docSession = this._getDocSession(stringParam(req.query.clientId), diff --git a/app/server/lib/ExcelFormatter.ts b/app/server/lib/ExcelFormatter.ts new file mode 100644 index 00000000..4ac47755 --- /dev/null +++ b/app/server/lib/ExcelFormatter.ts @@ -0,0 +1,257 @@ +import {CellValue} from 'app/common/DocActions'; +import {GristType} from 'app/common/gristTypes'; +import * as gutil from 'app/common/gutil'; +import * as gristTypes from 'app/common/gristTypes'; +import {NumberFormatOptions} from 'app/common/NumberFormat'; +import {formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter'; +import {decodeObject} from 'app/plugin/objtypes'; +import {Style} from 'exceljs'; +import * as moment from 'moment-timezone'; + +interface WidgetOptions extends NumberFormatOptions { + textColor?: 'string'; + fillColor?: 'string'; + alignment?: 'left' | 'center' | 'right'; + dateFormat?: string; + timeFormat?: string; +} +class BaseFormatter { + protected isRightType: IsRightTypeFunc; + protected widgetOptions: WidgetOptions = {}; + + constructor(public type: string, public opts: object) { + this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) || + gristTypes.isRightType('Any')!; + this.widgetOptions = opts; + } + + /** + * Formats a value that matches the type of this formatter. This should be overridden by derived + * classes to handle values in formatter-specific ways. + */ + public format(value: any): any { + return value; + } + + public style(): Partial