diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index d1c5cbea..adeb775a 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -46,7 +46,7 @@ import {isSchemaAction} from 'app/common/DocActions'; import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {HashLink, IDocPage} from 'app/common/gristUrls'; import {RecalcWhen} from 'app/common/gristTypes'; -import {encodeQueryParams, undef, waitObs} from 'app/common/gutil'; +import {undef, waitObs} from 'app/common/gutil'; import {LocalPlugin} from "app/common/plugin"; import {StringUnion} from 'app/common/StringUnion'; import {TableData} from 'app/common/TableData'; @@ -625,29 +625,20 @@ export class GristDoc extends DisposableWithEvents { ); } - public getXlsxLink() { - const baseUrl = this.docPageModel.appModel.api.getDocAPI(this.docId()).getGenerateXlsxUrl(); - const params = { - title: this.docPageModel.currentDocTitle.get(), - }; - return baseUrl + '?' + encodeQueryParams(params); - } - public getCsvLink() { const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({ colRef : field.colRef.peek(), filter : field.activeFilter.peek() })); - const baseUrl = this.docPageModel.appModel.api.getDocAPI(this.docId()).getGenerateCsvUrl(); const params = { - title: this.docPageModel.currentDocTitle.get(), viewSection: this.viewModel.activeSectionId(), tableId: this.viewModel.activeSection().table().tableId(), activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()), filters : JSON.stringify(filters), }; - return baseUrl + '?' + encodeQueryParams(params); + + return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadCsvUrl(params); } public hasGranularAccessRules(): boolean { diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 871df798..57cd2bc2 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -223,8 +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')), + menuItemLink({ + href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(), + 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/common/UserAPI.ts b/app/common/UserAPI.ts index b05d5ab0..8c4d0519 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -11,6 +11,7 @@ import {FullUser} from 'app/common/LoginSessionAPI'; import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {addCurrentOrgToPath} from 'app/common/urlUtils'; +import {encodeQueryParams} from 'app/common/gutil'; export {FullUser} from 'app/common/LoginSessionAPI'; @@ -320,6 +321,16 @@ export interface UserAPI { forRemoved(): UserAPI; // Get a version of the API that works on removed resources. } +/** + * Parameters for the download CSV endpoint (/download/csv). + */ + export interface DownloadCsvParams { + tableId: string; + viewSection?: number; + activeSortSpec?: string; + filters?: string; +} + /** * Collect endpoints related to the content of a single document that we've been thinking * of as the (restful) "Doc API". A few endpoints that could be here are not, for historical @@ -347,8 +358,8 @@ 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; - getGenerateXlsxUrl(): string; - getGenerateCsvUrl(): string; + getDownloadXlsxUrl(): string; + getDownloadCsvUrl(params: DownloadCsvParams): 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). @@ -792,12 +803,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { return this._url + `/download?template=${Number(template)}`; } - public getGenerateXlsxUrl() { - return this._url + '/gen-xlsx'; + public getDownloadXlsxUrl() { + return this._url + '/download/xlsx'; } - public getGenerateCsvUrl() { - return this._url + '/gen-csv'; + public getDownloadCsvUrl(params: DownloadCsvParams) { + // We spread `params` to work around TypeScript being overly cautious. + return this._url + '/download/csv?' + encodeQueryParams({...params}); } public async sendToDrive(code: string, title: string): Promise<{url: string}> { diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 89b45754..6e7415c7 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -42,8 +42,6 @@ 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/gen-csv', withDoc); - app.use('/api/docs/:docId/gen-xlsx', 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); diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 779a1d5f..836d3167 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -33,7 +33,9 @@ import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth"; import * as _ from "lodash"; import {isRaisedException} from "app/common/gristTypes"; import {localeFromRequest} from "app/server/lib/ServerLocale"; -import { generateCSV, generateXLSX } from "app/server/serverMethods"; +import { downloadCSV, DownloadCSVOptions } from "app/server/lib/ExportCSV"; +import { downloadXLSX, DownloadXLSXOptions } from "app/server/lib/ExportXLSX"; +import { parseExportParameters } from "app/server/lib/Export"; // 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 @@ -566,9 +568,31 @@ export class DocWorkerApi { res.json(result); })); - this._app.get('/api/docs/:docId/gen-csv', canView, withDoc(generateCSV)); + this._app.get('/api/docs/:docId/download/csv', canView, withDoc(async (activeDoc, req, res) => { + // Query DB for doc metadata to get the doc title. + const {name: docTitle} = + await this._dbManager.getDoc({userId: getUserId(req), org: req.org, urlId: getDocId(req)}); - this._app.get('/api/docs/:docId/gen-xlsx', canView, withDoc(generateXLSX)); + const params = parseExportParameters(req); + const filename = docTitle + (params.tableId === docTitle ? '' : '-' + params.tableId); + + const options: DownloadCSVOptions = { + ...params, + filename, + }; + + await downloadCSV(activeDoc, req, res, options); + })); + + this._app.get('/api/docs/:docId/download/xlsx', canView, withDoc(async (activeDoc, req, res) => { + // Query DB for doc metadata to get the doc title (to use as the filename). + const {name: filename} = + await this._dbManager.getDoc({userId: getUserId(req), org: req.org, urlId: getDocId(req)}); + + const options: DownloadXLSXOptions = {filename}; + + await downloadXLSX(activeDoc, req, res, options); + })); this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive)); diff --git a/app/server/lib/Export.ts b/app/server/lib/Export.ts index 0818437b..3ad61eda 100644 --- a/app/server/lib/Export.ts +++ b/app/server/lib/Export.ts @@ -11,7 +11,7 @@ import {DocumentSettings} from 'app/common/DocumentSettings'; import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {docSessionFromRequest} from 'app/server/lib/DocSession'; -import { integerParam, optJsonParam, stringParam} from 'app/server/lib/requestUtils'; +import {optIntegerParam, optJsonParam, stringParam} from 'app/server/lib/requestUtils'; import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters'; import * as express from 'express'; import * as _ from 'underscore'; @@ -72,7 +72,7 @@ export interface ExportData { */ export interface ExportParameters { tableId: string; - viewSectionId: number; + viewSectionId: number | undefined; sortOrder: number[]; filters: Filter[]; } @@ -82,7 +82,7 @@ export interface ExportParameters { */ export function parseExportParameters(req: express.Request): ExportParameters { const tableId = stringParam(req.query.tableId); - const viewSectionId = integerParam(req.query.viewSection); + const viewSectionId = optIntegerParam(req.query.viewSection); const sortOrder = optJsonParam(req.query.activeSortSpec, []) as number[]; const filters: Filter[] = optJsonParam(req.query.filters, []); @@ -94,20 +94,6 @@ export function parseExportParameters(req: express.Request): ExportParameters { }; } -/** - * Calculates the file name (without an extension) for exported table. - * @param activeDoc ActiveDoc - * @param req Request (with export params) - */ -export function parseExportFileName(activeDoc: ActiveDoc, req: express.Request) { - const title = req.query.title; - const tableId = req.query.tableId; - const docName = title || activeDoc.docName; - const name = docName + - (tableId === docName ? '' : '-' + tableId); - return name; -} - // Makes assertion that value does exists or throws an error function safe(value: T, msg: string) { if (!value) { throw new Error(msg); } diff --git a/app/server/lib/ExportCSV.ts b/app/server/lib/ExportCSV.ts index e659b3e4..789c5162 100644 --- a/app/server/lib/ExportCSV.ts +++ b/app/server/lib/ExportCSV.ts @@ -1,16 +1,54 @@ import {createFormatter} from 'app/common/ValueFormatter'; import {ActiveDoc} from 'app/server/lib/ActiveDoc'; -import {ExportData, exportSection, Filter} from 'app/server/lib/Export'; +import {ExportData, exportSection, exportTable, Filter} from 'app/server/lib/Export'; import * as bluebird from 'bluebird'; import * as csv from 'csv'; import * as express from 'express'; +import * as log from 'app/server/lib/log'; +import * as contentDisposition from 'content-disposition'; + +export interface DownloadCSVOptions { + filename: string; + tableId: string; + viewSectionId: number | undefined; + filters: Filter[]; + sortOrder: number[]; +} // promisify csv bluebird.promisifyAll(csv); /** - * Returns a csv stream that can be transformed or parsed. See https://github.com/wdavidw/node-csv - * for API details. + * Converts `activeDoc` to a CSV and sends the converted data through `res`. + */ +export async function downloadCSV(activeDoc: ActiveDoc, req: express.Request, + res: express.Response, options: DownloadCSVOptions) { + log.info('Generating .csv file...'); + const {filename, tableId, viewSectionId, filters, sortOrder} = options; + + try { + const data = viewSectionId ? + await makeCSVFromViewSection(activeDoc, viewSectionId, sortOrder, filters, req) : + await makeCSVFromTable(activeDoc, tableId, req); + res.set('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', contentDisposition(filename + '.csv')); + res.send(data); + } catch (err) { + log.error("Exporting to CSV has failed. Request url: %s", req.url, err); + const errHtml = + ` + + There was an unexpected error while generating a csv file. + +`; + res.status(400).send(errHtml); + } +} + +/** + * Returns a csv stream of a view section that can be transformed or parsed. + * + * See https://github.com/wdavidw/node-csv for API details. * * @param {Object} activeDoc - the activeDoc that the table being converted belongs to. * @param {Integer} viewSectionId - id of the viewsection to export. @@ -18,7 +56,7 @@ bluebird.promisifyAll(csv); * @param {Filter[]} filters (optional) - filters defined from ui. * @return {Promise} Promise for the resulting CSV. */ -export async function makeCSV( +export async function makeCSVFromViewSection( activeDoc: ActiveDoc, viewSectionId: number, sortOrder: number[], @@ -30,6 +68,31 @@ export async function makeCSV( return file; } +/** + * Returns a csv stream of a table that can be transformed or parsed. + * + * @param {Object} activeDoc - the activeDoc that the table being converted belongs to. + * @param {Integer} tableId - id of the table to export. + * @return {Promise} Promise for the resulting CSV. + */ +export async function makeCSVFromTable( + activeDoc: ActiveDoc, + tableId: string, + req: express.Request) { + + if (!activeDoc.docData) { + throw new Error('No docData in active document'); + } + + // Look up the table to make a CSV from. + const tables = activeDoc.docData.getTable('_grist_Tables')!; + const tableRef = tables.findRow('tableId', tableId); + + const data = await exportTable(activeDoc, tableRef, req); + const file = convertToCsv(data); + return file; +} + function convertToCsv({ rowIds, access, diff --git a/app/server/lib/ExportXLSX.ts b/app/server/lib/ExportXLSX.ts index 04693d58..fa111a02 100644 --- a/app/server/lib/ExportXLSX.ts +++ b/app/server/lib/ExportXLSX.ts @@ -3,6 +3,37 @@ import {createExcelFormatter} from 'app/server/lib/ExcelFormatter'; import {ExportData, exportDoc} from 'app/server/lib/Export'; import {Alignment, Border, Fill, Workbook} from 'exceljs'; import * as express from 'express'; +import * as log from 'app/server/lib/log'; +import * as contentDisposition from 'content-disposition'; + +export interface DownloadXLSXOptions { + filename: string; +} + +/** + * Converts `activeDoc` to CSV and sends the converted data through `res`. + */ +export async function downloadXLSX(activeDoc: ActiveDoc, req: express.Request, + res: express.Response, {filename}: DownloadXLSXOptions) { + log.debug(`Generating .xlsx file`); + try { + const data = await makeXLSX(activeDoc, req); + res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', contentDisposition(filename + '.xlsx')); + res.send(data); + log.debug('XLSX file generated'); + } catch (err) { + log.error("Exporting to XLSX has failed. Request url: %s", req.url, err); + // send a generic information to client + const errHtml = + ` + + There was an unexpected error while generating a xlsx file. + +`; + res.status(400).send(errHtml); + } +} /** * Creates excel document with all tables from an active Grist document. diff --git a/app/server/serverMethods.ts b/app/server/serverMethods.ts deleted file mode 100644 index 0199e18e..00000000 --- a/app/server/serverMethods.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {parseExportFileName, parseExportParameters} from 'app/server/lib/Export'; -import {makeCSV} from 'app/server/lib/ExportCSV'; -import {makeXLSX} from 'app/server/lib/ExportXLSX'; -import * as log from 'app/server/lib/log'; -import * as contentDisposition from 'content-disposition'; -import * as express from 'express'; -import {ActiveDoc} from 'app/server/lib/ActiveDoc'; - -export async function generateCSV(activeDoc: ActiveDoc, req: express.Request, res: express.Response) { - log.info('Generating .csv file...'); - const { - viewSectionId, - filters, - sortOrder - } = parseExportParameters(req); - - // Generate a decent name for the exported file. - const name = parseExportFileName(activeDoc, req); - try { - const data = await makeCSV(activeDoc, viewSectionId, sortOrder, filters, req); - res.set('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', contentDisposition(name + '.csv')); - res.send(data); - } catch (err) { - log.error("Exporting to CSV has failed. Request url: %s", req.url, err); - const errHtml = - ` - - There was an unexpected error while generating a csv file. - -`; - res.status(400).send(errHtml); - } -} - -export async function generateXLSX(activeDoc: ActiveDoc, req: express.Request, res: express.Response) { - log.debug(`Generating .xlsx file`); - try { - const data = await makeXLSX(activeDoc, req); - res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - res.setHeader('Content-Disposition', contentDisposition((req.query.title || activeDoc.docName) + '.xlsx')); - res.send(data); - log.debug('XLSX file generated'); - } catch (err) { - log.error("Exporting to XLSX has failed. Request url: %s", req.url, err); - // send a generic information to client - const errHtml = - ` - - There was an unexpected error while generating a xlsx file. - -`; - res.status(400).send(errHtml); - } -}