Google drive shared with me (#2758)

* Refactor list to use promises

* Include shared with me files

Fixes #2739

* revert usage of promise version of purest

it fails tests

* fix bugs

* fix lint warnings

* implement shared with me virtual dir

* pull out adaptData (it's not a public method, eslint warn)

* fix test

* disable checkbox for “shared with me”

//cc @mifi

Co-authored-by: Kevin van Zonneveld <kevin@vanzonneveld.net>
Co-authored-by: Artur Paikin <artur@arturpaikin.com>
This commit is contained in:
Mikael Finstad 2021-03-31 16:12:24 +07:00 committed by GitHub
parent 9e82431a16
commit ceadd2e7f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 195 additions and 137 deletions

View file

@ -1,8 +1,9 @@
const Provider = require('../Provider')
/* eslint-disable no-underscore-dangle */
const { callbackify } = require('util')
const request = require('request')
// @ts-ignore
const purest = require('purest')({ request })
const Provider = require('../Provider')
const logger = require('../../logger')
const adapter = require('./adapter')
const { ProviderApiError, ProviderAuthError } = require('../error')
@ -12,6 +13,76 @@ const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FI
// using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
const SHARED_DRIVE_FIELDS = '*'
// Hopefully this name will not be used by Google
const VIRTUAL_SHARED_DIR = 'shared-with-me'
function waitForFailedResponse (resp) {
return new Promise((resolve, reject) => {
let data = ''
resp.on('data', (chunk) => {
data += chunk
}).on('end', () => {
try {
resolve(JSON.parse(data.toString()))
} catch (error) {
reject(error)
}
})
})
}
function adaptData (listFilesResp, sharedDrivesResp, directory, query, showSharedWithMe) {
const adaptItem = (item) => ({
isFolder: adapter.isFolder(item),
icon: adapter.getItemIcon(item),
name: adapter.getItemName(item),
mimeType: adapter.getMimeType(item),
id: adapter.getItemId(item),
thumbnail: adapter.getItemThumbnailUrl(item),
requestPath: adapter.getItemRequestPath(item),
modifiedDate: adapter.getItemModifiedDate(item),
size: adapter.getItemSize(item),
custom: {
// @todo isTeamDrive is left for backward compatibility. We should remove it in the next
// major release.
isTeamDrive: adapter.isSharedDrive(item),
isSharedDrive: adapter.isSharedDrive(item),
imageHeight: adapter.getImageHeight(item),
imageWidth: adapter.getImageWidth(item),
imageRotation: adapter.getImageRotation(item),
imageDateTime: adapter.getImageDate(item),
videoHeight: adapter.getVideoHeight(item),
videoWidth: adapter.getVideoWidth(item),
videoDurationMillis: adapter.getVideoDurationMillis(item),
},
})
const items = adapter.getItemSubList(listFilesResp)
const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
// “Shared with me” is a list of shared documents,
// not the same as sharedDrives
const virtualItem = showSharedWithMe && ({
isFolder: true,
icon: 'folder',
name: 'Shared with me',
mimeType: 'application/vnd.google-apps.folder',
id: VIRTUAL_SHARED_DIR,
requestPath: VIRTUAL_SHARED_DIR,
})
const adaptedItems = [
...(virtualItem ? [virtualItem] : []), // shared folder first
...([...sharedDrives, ...items].map(adaptItem)),
]
return {
username: adapter.getUsername(listFilesResp),
items: adaptedItems,
nextPagePath: adapter.getNextPagePath(listFilesResp, query, directory),
}
}
/**
* Adapter for API https://developers.google.com/drive/api/v3/
*/
@ -32,69 +103,85 @@ class Drive extends Provider {
return 'google'
}
list (options, done) {
async _list (options) {
const directory = options.directory || 'root'
const query = options.query || {}
let sharedDrivesPromise = Promise.resolve(undefined)
const { client } = this
const handleErrorResponse = this._error.bind(this)
const shouldListSharedDrives = directory === 'root' && !query.cursor
if (shouldListSharedDrives) {
sharedDrivesPromise = new Promise((resolve) => {
this.client
.query()
const isRoot = directory === 'root'
const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
async function fetchSharedDrives () {
try {
const shouldListSharedDrives = isRoot && !query.cursor
if (!shouldListSharedDrives) return undefined
const resp = await new Promise((resolve, reject) => client
.get('drives')
.qs({ fields: SHARED_DRIVE_FIELDS })
.auth(options.token)
.request((err, resp) => {
if (err) {
logger.error(err, 'provider.drive.sharedDrive.error')
return
}
resolve(resp)
})
})
.request((err, resp2) => {
if (err || resp2.statusCode !== 200) return reject(handleErrorResponse(err, resp2))
return resolve(resp2)
}))
return resp && resp.body
} catch (err) {
logger.error(err, 'provider.drive.sharedDrive.error')
throw err
}
}
const where = {
fields: DRIVE_FILES_FIELDS,
pageToken: query.cursor,
q: `'${directory}' in parents and trashed=false`,
includeItemsFromAllDrives: true,
supportsAllDrives: true,
async function fetchFiles () {
// Shared with me items in root don't have any parents
const q = isVirtualSharedDirRoot
? `sharedWithMe and trashed=false`
: `('${directory}' in parents) and trashed=false`
const where = {
fields: DRIVE_FILES_FIELDS,
pageToken: query.cursor,
q,
// pageSize: 10, // can be used for testing pagination if you don't have many files
includeItemsFromAllDrives: true,
supportsAllDrives: true,
}
try {
const resp = await new Promise((resolve, reject) => client
.query()
.get('files')
.qs(where)
.auth(options.token)
.request((err, resp2) => {
if (err || resp2.statusCode !== 200) return reject(handleErrorResponse(err, resp2))
return resolve(resp2)
}))
return resp && resp.body
} catch (err) {
logger.error(err, 'provider.drive.list.error')
throw err
}
}
const filesPromise = new Promise((resolve, reject) => {
this.client
.query()
.get('files')
.qs(where)
.auth(options.token)
.request((err, resp) => {
if (err || resp.statusCode !== 200) {
reject(this._error(err, resp))
return
}
resolve(resp)
})
})
const [sharedDrives, filesResponse] = await Promise.all([fetchSharedDrives(), fetchFiles()])
// console.log({ directory, sharedDrives, filesResponse })
Promise.all([sharedDrivesPromise, filesPromise])
.then(
([sharedDrives, filesResponse]) => {
const returnData = this.adaptData(
filesResponse.body,
sharedDrives && sharedDrives.body,
directory,
query
)
done(null, returnData)
},
(reqErr) => {
logger.error(reqErr, 'provider.drive.list.error')
done(reqErr)
}
)
return adaptData(
filesResponse,
sharedDrives,
directory,
query,
isRoot && !query.cursor // we can only show it on the first page request, or else we will have duplicates of it
)
}
list (options, done) {
// @ts-ignore
callbackify(this._list.bind(this))(options, done)
}
stats ({ id, token }, done) {
@ -116,21 +203,6 @@ class Drive extends Provider {
.request()
}
_waitForFailedResponse (resp) {
return new Promise((resolve, reject) => {
let data = ''
resp.on('data', (chunk) => {
data += chunk
}).on('end', () => {
try {
resolve(JSON.parse(data.toString()))
} catch (error) {
reject(error)
}
})
})
}
download ({ id, token }, onData) {
this.stats({ id, token }, (err, _, body) => {
if (err) {
@ -154,20 +226,19 @@ class Drive extends Provider {
requestStream
.on('response', (resp) => {
if (resp.statusCode !== 200) {
this._waitForFailedResponse(resp)
waitForFailedResponse(resp)
.then((jsonResp) => {
resp.body = jsonResp
onData(this._error(null, resp))
onData(this._error(null, { ...resp, body: jsonResp }))
})
.catch((err) => onData(this._error(err, resp)))
.catch((err2) => onData(this._error(err2, resp)))
} else {
resp.on('data', (chunk) => onData(null, chunk))
}
})
.on('end', () => onData(null, null))
.on('error', (err) => {
logger.error(err, 'provider.drive.download.error')
onData(err)
.on('error', (err2) => {
logger.error(err2, 'provider.drive.download.error')
onData(err2)
})
})
}
@ -182,9 +253,10 @@ class Drive extends Provider {
size ({ id, token }, done) {
return this.stats({ id, token }, (err, resp, body) => {
if (err || resp.statusCode !== 200) {
err = this._error(err, resp)
logger.error(err, 'provider.drive.size.error')
return done(err)
const err2 = this._error(err, resp)
logger.error(err2, 'provider.drive.size.error')
done(err2)
return
}
if (adapter.isGsuiteFile(body.mimeType)) {
@ -195,7 +267,7 @@ class Drive extends Provider {
const maxExportFileSize = 10 * 1024 * 1024 // 10 MB
done(null, maxExportFileSize)
} else {
done(null, parseInt(body.size))
done(null, parseInt(body.size, 10))
}
})
}
@ -214,44 +286,6 @@ class Drive extends Provider {
})
}
adaptData (res, sharedDrivesResp, directory, query) {
const adaptItem = (item) => ({
isFolder: adapter.isFolder(item),
icon: adapter.getItemIcon(item),
name: adapter.getItemName(item),
mimeType: adapter.getMimeType(item),
id: adapter.getItemId(item),
thumbnail: adapter.getItemThumbnailUrl(item),
requestPath: adapter.getItemRequestPath(item),
modifiedDate: adapter.getItemModifiedDate(item),
size: adapter.getItemSize(item),
custom: {
// @todo isTeamDrive is left for backward compatibility. We should remove it in the next
// major release.
isTeamDrive: adapter.isSharedDrive(item),
isSharedDrive: adapter.isSharedDrive(item),
imageHeight: adapter.getImageHeight(item),
imageWidth: adapter.getImageWidth(item),
imageRotation: adapter.getImageRotation(item),
imageDateTime: adapter.getImageDate(item),
videoHeight: adapter.getVideoHeight(item),
videoWidth: adapter.getVideoWidth(item),
videoDurationMillis: adapter.getVideoDurationMillis(item),
},
})
const items = adapter.getItemSubList(res)
const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
const adaptedItems = sharedDrives.concat(items).map(adaptItem)
return {
username: adapter.getUsername(res),
items: adaptedItems,
nextPagePath: adapter.getNextPagePath(res, query, directory),
}
}
_error (err, resp) {
if (resp) {
const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`

View file

@ -57,7 +57,21 @@ describe('list provider files', () => {
.expect(200)
.then((res) => {
expect(res.body.username).toBe(fixtures.defaults.USERNAME)
const item = res.body.items[0]
const items = [...res.body.items]
// Drive has a virtual "shared-with-me" folder as the first item
if (providerName === 'drive') {
const item0 = items.shift()
expect(item0.isFolder).toBe(true)
expect(item0.name).toBe('Shared with me')
expect(item0.mimeType).toBe('application/vnd.google-apps.folder')
expect(item0.id).toBe('shared-with-me')
expect(item0.requestPath).toBe('shared-with-me')
expect(item0.icon).toBe('folder')
}
const item = items[0]
expect(item.isFolder).toBe(false)
expect(item.name).toBe(providerFixtures.itemName || fixtures.defaults.ITEM_NAME)
expect(item.mimeType).toBe(providerFixtures.itemMimeType || fixtures.defaults.MIME_TYPE)

View file

@ -22,19 +22,21 @@ const getAriaLabelOfCheckbox = (props) => {
module.exports = (props) => {
return (
<li className={props.className} title={props.isDisabled ? props.restrictionReason : null}>
<button
type="button"
className={`uppy-u-reset uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`}
onClick={props.toggleCheckbox}
// for the <label/>
id={props.id}
role="option"
aria-label={getAriaLabelOfCheckbox(props)}
aria-selected={props.isChecked}
aria-disabled={props.isDisabled}
disabled={props.isDisabled}
data-uppy-super-focusable
/>
{!props.isCheckboxDisabled ? (
<button
type="button"
className={`uppy-u-reset uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`}
onClick={props.toggleCheckbox}
// for the <label/>
id={props.id}
role="option"
aria-label={getAriaLabelOfCheckbox(props)}
aria-selected={props.isChecked}
aria-disabled={props.isDisabled}
disabled={props.isDisabled}
data-uppy-super-focusable
/>
) : null}
{props.type === 'file' ? (
// label for a checkbox

View file

@ -2,6 +2,9 @@ const { h } = require('preact')
const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal')
const Item = require('./Item/index')
// Hopefully this name will not be used by Google
const VIRTUAL_SHARED_DIR = 'shared-with-me'
const getSharedProps = (fileOrFolder, props) => ({
id: fileOrFolder.id,
title: fileOrFolder.name,
@ -15,7 +18,9 @@ const getSharedProps = (fileOrFolder, props) => ({
})
module.exports = (props) => {
if (!props.folders.length && !props.files.length) {
const { folders, files, handleScroll, isChecked } = props
if (!folders.length && !files.length) {
return <div className="uppy-Provider-empty">{props.i18n('noFilesFound')}</div>
}
@ -23,20 +28,21 @@ module.exports = (props) => {
<div className="uppy-ProviderBrowser-body">
<ul
className="uppy-ProviderBrowser-list"
onScroll={props.handleScroll}
onScroll={handleScroll}
role="listbox"
// making <ul> not focusable for firefox
tabIndex="-1"
>
{props.folders.map(folder => {
{folders.map(folder => {
return Item({
...getSharedProps(folder, props),
type: 'folder',
isDisabled: props.isChecked(folder) ? props.isChecked(folder).loading : false,
isDisabled: isChecked(folder) ? isChecked(folder).loading : false,
isCheckboxDisabled: folder.id === VIRTUAL_SHARED_DIR,
handleFolderClick: () => props.handleFolderClick(folder),
})
})}
{props.files.map(file => {
{files.map(file => {
const validateRestrictions = props.validateRestrictions(
remoteFileObjToLocal(file),
[...props.uppyFiles, ...props.currentSelection]

View file

@ -84,6 +84,8 @@
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
// focus underline is otherwise invisible
line-height: 1.2;
}
}