Skip to content

Commit

Permalink
feat(plugin-google-docs): add field to upload to google docs
Browse files Browse the repository at this point in the history
  • Loading branch information
romain-gilliotte committed Jun 1, 2023
1 parent 30ee6f7 commit 0c227d3
Show file tree
Hide file tree
Showing 17 changed files with 1,322 additions and 0 deletions.
Empty file.
674 changes: 674 additions & 0 deletions packages/plugin-google-docs/LICENSE

Large diffs are not rendered by default.

Empty file.
8 changes: 8 additions & 0 deletions packages/plugin-google-docs/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* eslint-disable import/no-relative-packages */
import jestConfig from '../../jest.config';

export default {
...jestConfig,
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
testMatch: ['<rootDir>/test/**/*.test.ts'],
};
33 changes: 33 additions & 0 deletions packages/plugin-google-docs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@forestadmin/plugin-google-docs",
"version": "1.0.0",
"main": "dist/index.js",
"license": "GPL-3.0",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ForestAdmin/agent-nodejs.git",
"directory": "packages/plugin-google-docs"
},
"dependencies": {
"@googleapis/docs": "^2.0.1",
"@googleapis/drive": "^5.1.0"
},
"devDependencies": {
"@forestadmin/datasource-customizer": "1.7.1",
"@forestadmin/datasource-toolkit": "1.5.0"
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts"
],
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"clean": "rm -rf coverage dist",
"lint": "eslint src test",
"test": "jest"
}
}
40 changes: 40 additions & 0 deletions packages/plugin-google-docs/src/field/create-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Configuration } from '../types';
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';

import { encodeDataUri } from '../utils/data-uri';

export default function createField(collection: CollectionCustomizer, config: Configuration): void {
collection.addField(config.filename, {
columnType: 'String', // TODO check type for config.readMode === 'download'
dependencies: [config.sourcename],
getValues: records =>
records.map(async record => {
const key = record[config.sourcename];

if (!key) {
return null;
}

if (config.readMode === 'title') {
return (await config.client.load(key)).title;
}

if (config.readMode === 'download') {
return encodeDataUri({
mimeType: 'application/pdf',
buffer: await config.client.exportToPdf(key),
});
}

if (config.readMode === 'webViewLink') {
return (await config.client.getFileFromDrive(key)).webViewLink;
}

if (config.readMode === 'webContentLink') {
return (await config.client.getFileFromDrive(key)).webContentLink;
}

return key;
}),
});
}
18 changes: 18 additions & 0 deletions packages/plugin-google-docs/src/field/make-field-filterable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Configuration } from '../types';
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';
import type { ColumnSchema } from '@forestadmin/datasource-toolkit';

export default function makeFieldFilterable(
collection: CollectionCustomizer,
config: Configuration,
): void {
const schema = collection.schema.fields[config.sourcename] as ColumnSchema;

for (const operator of schema.filterOperators) {
collection.replaceFieldOperator(config.filename, operator, value => ({
field: config.sourcename,
operator,
value,
}));
}
}
14 changes: 14 additions & 0 deletions packages/plugin-google-docs/src/field/make-field-required.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Configuration } from '../types';
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';
import type { ColumnSchema } from '@forestadmin/datasource-toolkit';

export default function makeFieldRequired(
collection: CollectionCustomizer,
config: Configuration,
): void {
const schema = collection.schema.fields[config.sourcename] as ColumnSchema;

if (schema.validation?.find(rule => rule.operator === 'Present')) {
collection.addFieldValidation(config.filename, 'Present');
}
}
9 changes: 9 additions & 0 deletions packages/plugin-google-docs/src/field/make-field-sortable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Configuration } from '../types';
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';

export default function makeFieldSortable(
collection: CollectionCustomizer,
config: Configuration,
): void {
collection.replaceFieldSorting(config.filename, [{ ascending: true, field: config.sourcename }]);
}
33 changes: 33 additions & 0 deletions packages/plugin-google-docs/src/field/make-field-writable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';
import type { ColumnSchema, RecordData } from '@forestadmin/datasource-toolkit';

import { Configuration } from '../types';

function getRecordId(collection: CollectionCustomizer, record: RecordData): string[] {
return Object.entries(collection.schema.fields)
.filter(([, schema]) => schema.type === 'Column' && schema.isPrimaryKey)
.map(([name]) => record[name]);
}

export default function makeFieldWritable(
collection: CollectionCustomizer,
config: Configuration,
): void {
const schema = collection.schema.fields[config.sourcename] as ColumnSchema;
if (schema.isReadOnly) return;

collection.replaceFieldWriting(config.filename, async (value, context) => {
const patch = { [config.sourcename]: null };

// Auto provision at creation only ?
if (value) {
const recordId = getRecordId(collection, context.record).join('|');

const documentId = await config.client.create(recordId);

patch[config.sourcename] = documentId;
}

return patch;
});
}
10 changes: 10 additions & 0 deletions packages/plugin-google-docs/src/field/replace-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Configuration } from '../types';
import type { CollectionCustomizer } from '@forestadmin/datasource-customizer';

export default function replaceField(
collection: CollectionCustomizer,
config: Configuration,
): void {
collection.removeField(config.sourcename);
collection.renameField(config.filename, config.sourcename);
}
45 changes: 45 additions & 0 deletions packages/plugin-google-docs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type {
CollectionCustomizer,
DataSourceCustomizer,
} from '@forestadmin/datasource-customizer';

import createField from './field/create-field';
import makeFieldFilterable from './field/make-field-filterable';
import makeFieldRequired from './field/make-field-required';
import makeFieldSortable from './field/make-field-sortable';
import makeFieldWritable from './field/make-field-writable';
import replaceField from './field/replace-field';
import { Options } from './types';
import Client from './utils/google';

export { Options };

export async function createGoogleDocsField(
_dataSource: DataSourceCustomizer,
collection: CollectionCustomizer,
options?: Options,
): Promise<void> {
if (!collection) throw new Error('createGoogleDocsField can only be used on collections.');
if (!options) throw new Error('Options must be provided.');

const sourceSchema = collection.schema.fields[options.fieldname];

if (!sourceSchema || sourceSchema.type !== 'Column' || sourceSchema.columnType !== 'String') {
const field = `${collection.name}.${options.fieldname}`;
throw new Error(`The field '${field}' does not exist or is not a string.`);
}

const config = {
readMode: options.readMode ?? 'download',
sourcename: options.fieldname,
filename: `${options.fieldname}__google-docs`,
client: new Client(options.google),
};

createField(collection, config);
makeFieldWritable(collection, config);
makeFieldSortable(collection, config);
makeFieldFilterable(collection, config);
makeFieldRequired(collection, config);
replaceField(collection, config);
}
35 changes: 35 additions & 0 deletions packages/plugin-google-docs/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable max-len */

import Client from './utils/google';

/**
* Configuration for the GOOGLE docs addon of Forest Admin.
*
* TODO: specify required GOOGLE authorization scopes
* ['https://www.googleapis.com/auth/documents', 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/drive.appdata', 'https://www.googleapis.com/auth/drive.appfolder', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.resource', 'https://www.googleapis.com/auth/drive.metadata']
*/
export type Options = {
/** Name of the field that you want to use as a file-picker on the frontend */
fieldname: string;

/**
*/
readMode?: 'title' | 'download' | 'webViewLink' | 'webContentLink' | 'raw';

/** GOOGLE configuration */
google?: {
/** GOOGLE API keys, defaults to process.env.GOOGLE_API_KEY. */
auth?: string;

/** Alternatively, you can specify the path to the GOOGLE service account credential file via the keyFile */
// keyFile?: string;
};
};

export type Configuration = Required<
Pick<Options, 'readMode'> & {
client: Client;
sourcename: string;
filename: string;
}
>;
18 changes: 18 additions & 0 deletions packages/plugin-google-docs/src/utils/data-uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// eslint-disable-next-line import/prefer-default-export
export function encodeDataUri(data: { mimeType: string; buffer: Buffer }): string {
// prefix
let uri = `data:${data.mimeType}`;

// media types
const mediaTypes = Object.entries(data)
.filter(([mediaType, value]) => value && mediaType !== 'mimeType' && mediaType !== 'buffer')
.map(([mediaType, value]) => `${mediaType}=${encodeURIComponent(value as string)}`)
.join(';');

if (mediaTypes.length) uri += `;${mediaTypes}`;

// data
uri += `;base64,${data.buffer.toString('base64')}`;

return uri;
}
73 changes: 73 additions & 0 deletions packages/plugin-google-docs/src/utils/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { docs, docs_v1 } from '@googleapis/docs';
import { drive, drive_v3 } from '@googleapis/drive';

import { Options } from '../types';

export default class Client {
private client: docs_v1.Docs;
private service: drive_v3.Drive;

constructor(options: Options['google']) {
this.client = docs({ version: 'v1', auth: options.auth });
this.service = drive({ version: 'v3', auth: options.auth });
}

async exportToPdf(fileId: string): Promise<Buffer> {
// https://developers.google.com/drive/api/reference/rest/v3/files/export
const buffer = await this.service.files.export({
fileId,
mimeType: 'application/pdf',
});

return buffer as unknown as Buffer;
}

async getFileFromDrive(fileId: string) {
// https://developers.google.com/drive/api/reference/rest/v3/files/get
const file = await this.service.files.get({
fileId,
});

return file.data;
}

async load(documentId: string): Promise<docs_v1.Schema$Document> {
const response = await this.client.documents.get({
documentId,
});

return response.data;
}

async updates(documentId: string, text: string): Promise<void> {
await this.client.documents.batchUpdate({
documentId,
requestBody: {
requests: [
{
insertText: {
// The first text inserted into the document must create a paragraph,
// which can't be done with the `location` property. Use the
// `endOfSegmentLocation` instead, which assumes the Body if
// unspecified.
endOfSegmentLocation: {},
text,
},
},
],
},
});
}

async create(originId: string): Promise<string> {
// The initial call to create the doc will have a title but no content.
// This is a limitation of the underlying API.
const createResponse = await this.client.documents.create({
requestBody: {
title: `Your new document for ${originId}!`,
},
});

return createResponse.data.documentId;
}
}
Loading

0 comments on commit 0c227d3

Please sign in to comment.