From 171e39b230ee3042665fe60adca3e7014f94e735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20M=C3=B6ller?= Date: Sun, 19 Aug 2018 16:16:11 +0200 Subject: [PATCH] drive: add --drive-import-formats Add a new flag to the drive backend to allow document conversions oni upload. The existing --drive-formats flag has been renamed to --drive-export-formats. The old flag is still working to be backward compatible. --- backend/drive/drive.go | 315 ++++++++++++++++++-------- backend/drive/drive_internal_test.go | 176 +++++++++----- backend/drive/test/about.json | 178 +++++++++++++++ backend/drive/test/files/example1.ods | Bin 0 -> 13328 bytes backend/drive/test/files/example2.doc | Bin 0 -> 15360 bytes backend/drive/test/files/example3.odt | Bin 0 -> 21996 bytes docs/content/drive.md | 56 ++++- 7 files changed, 562 insertions(+), 163 deletions(-) create mode 100644 backend/drive/test/about.json create mode 100644 backend/drive/test/files/example1.ods create mode 100644 backend/drive/test/files/example2.doc create mode 100644 backend/drive/test/files/example3.odt diff --git a/backend/drive/drive.go b/backend/drive/drive.go index 46a3a1bdf..6255d9d10 100644 --- a/backend/drive/drive.go +++ b/backend/drive/drive.go @@ -51,7 +51,7 @@ const ( timeFormatIn = time.RFC3339 timeFormatOut = "2006-01-02T15:04:05.000000000Z07:00" minSleep = 10 * time.Millisecond - defaultExtensions = "docx,xlsx,pptx,svg" + defaultExportExtensions = "docx,xlsx,pptx,svg" scopePrefix = "https://www.googleapis.com/auth/" defaultScope = "drive" // chunkSize is the size of the chunks created during a resumable upload and should be a power of two. @@ -103,10 +103,10 @@ var ( "text/plain": ".txt", "text/tab-separated-values": ".tsv", } - extensionToMimeType map[string]string - partialFields = "id,name,size,md5Checksum,trashed,modifiedTime,createdTime,mimeType,parents" - exportFormatsOnce sync.Once // make sure we fetch the export formats only once - _exportFormats map[string][]string // allowed export MIME type conversions + partialFields = "id,name,size,md5Checksum,trashed,modifiedTime,createdTime,mimeType,parents" + fetchFormatsOnce sync.Once // make sure we fetch the export/import formats only once + _exportFormats map[string][]string // allowed export MIME type conversions + _importFormats map[string][]string // allowed import MIME type conversions ) // Register with Fs @@ -214,9 +214,25 @@ func init() { Advanced: true, }, { Name: "formats", - Default: defaultExtensions, + Default: "", + Help: "Deprecated: see export_formats", + Advanced: true, + Hide: fs.OptionHideConfigurator, + }, { + Name: "export_formats", + Default: defaultExportExtensions, Help: "Comma separated list of preferred formats for downloading Google docs.", Advanced: true, + }, { + Name: "import_formats", + Default: "", + Help: "Comma separated list of preferred formats for uploading Google docs.", + Advanced: true, + }, { + Name: "allow_import_name_change", + Default: false, + Help: "Allow the filetype to change when uploading Google docs (e.g. file.doc to file.docx). This will confuse sync and reupload every time.", + Advanced: true, }, { Name: "use_created_date", Default: false, @@ -290,6 +306,9 @@ type Options struct { SharedWithMe bool `config:"shared_with_me"` TrashedOnly bool `config:"trashed_only"` Extensions string `config:"formats"` + ExportExtensions string `config:"export_formats"` + ImportExtensions string `config:"import_formats"` + AllowImportNameChange bool `config:"allow_import_name_change"` UseCreatedDate bool `config:"use_created_date"` ListChunk int64 `config:"list_chunk"` Impersonate string `config:"impersonate"` @@ -303,32 +322,33 @@ type Options struct { // Fs represents a remote drive server type Fs struct { - name string // name of this remote - root string // the path we are working on - opt Options // parsed options - features *fs.Features // optional features - svc *drive.Service // the connection to the drive server - v2Svc *drive_v2.Service // used to create download links for the v2 api - client *http.Client // authorized client - rootFolderID string // the id of the root folder - dirCache *dircache.DirCache // Map of directory path to directory id - pacer *pacer.Pacer // To pace the API calls - extensions []string // preferred extensions to download docs - isTeamDrive bool // true if this is a team drive + name string // name of this remote + root string // the path we are working on + opt Options // parsed options + features *fs.Features // optional features + svc *drive.Service // the connection to the drive server + v2Svc *drive_v2.Service // used to create download links for the v2 api + client *http.Client // authorized client + rootFolderID string // the id of the root folder + dirCache *dircache.DirCache // Map of directory path to directory id + pacer *pacer.Pacer // To pace the API calls + exportExtensions []string // preferred extensions to download docs + importMimeTypes []string // MIME types to convert to docs + isTeamDrive bool // true if this is a team drive } // Object describes a drive object type Object struct { - fs *Fs // what this object is part of - remote string // The remote path - id string // Drive Id of this object - url string // Download URL of this object - md5sum string // md5sum of the object - bytes int64 // size of the object - modifiedDate string // RFC3339 time it was last modified - isDocument bool // if set this is a Google doc - v2Download bool // generate v2 download link ondemand - mimeType string + fs *Fs // what this object is part of + remote string // The remote path + id string // Drive Id of this object + url string // Download URL of this object + md5sum string // md5sum of the object + bytes int64 // size of the object + modifiedDate string // RFC3339 time it was last modified + documentMimeType string // if set this is a Google doc + v2Download bool // generate v2 download link ondemand + mimeType string } // ------------------------------------------------------------ @@ -444,7 +464,7 @@ func (f *Fs) list(dirIDs []string, title string, directoriesOnly bool, filesOnly // if the search title contains an extension and the extension is in the export extensions add a search // for the filename without the extension. // assume that export extensions don't contain escape sequences and only have one part (not .tar.gz) - if ext := path.Ext(searchTitle); handleGdocs && len(ext) > 0 && containsString(f.extensions, ext) { + if ext := path.Ext(searchTitle); handleGdocs && len(ext) > 0 && containsString(f.exportExtensions, ext) { stem = title[:len(title)-len(ext)] query = append(query, fmt.Sprintf("(name='%s' or name='%s')", searchTitle, searchTitle[:len(searchTitle)-len(ext)])) } else { @@ -563,49 +583,35 @@ func isInternalMimeType(mimeType string) bool { } // parseExtensions parses a list of comma separated extensions -// into a list of unique extensions with leading "." -func parseExtensions(extensions ...string) ([]string, error) { - var result []string - for _, extensionText := range extensions { +// into a list of unique extensions with leading "." and a list of associated MIME types +func parseExtensions(extensionsIn ...string) (extensions, mimeTypes []string, err error) { + for _, extensionText := range extensionsIn { for _, extension := range strings.Split(extensionText, ",") { extension = strings.ToLower(strings.TrimSpace(extension)) + if extension == "" { + continue + } if len(extension) > 0 && extension[0] != '.' { extension = "." + extension } - if mime.TypeByExtension(extension) == "" { - return result, errors.Errorf("couldn't find MIME type for extension %q", extension) + mt := mime.TypeByExtension(extension) + if mt == "" { + return extensions, mimeTypes, errors.Errorf("couldn't find MIME type for extension %q", extension) } found := false - for _, existingExtension := range result { + for _, existingExtension := range extensions { if extension == existingExtension { found = true break } } if !found { - result = append(result, extension) + extensions = append(extensions, extension) + mimeTypes = append(mimeTypes, mt) } } } - return result, nil -} - -// parseExtensionMimeTypes parses the given extensions using parseExtensions -// and maps each resulting extension to its MIME type. -func parseExtensionMimeTypes(extensions ...string) ([]string, error) { - parsedExtensions, err := parseExtensions(extensions...) - if err != nil { - return nil, err - } - mimeTypes := make([]string, 0, len(parsedExtensions)) - for i, extension := range parsedExtensions { - mt := mime.TypeByExtension(extension) - if mt == "" { - return nil, errors.Errorf("couldn't find MIME type for extension %q", extension) - } - mimeTypes[i] = mt - } - return mimeTypes, nil + return } // Figure out if the user wants to use a team drive @@ -770,7 +776,18 @@ func NewFs(name, path string, m configmap.Mapper) (fs.Fs, error) { f.dirCache = dircache.New(root, f.rootFolderID, f) // Parse extensions - f.extensions, err = parseExtensions(opt.Extensions, defaultExtensions) + if opt.Extensions != "" { + if opt.ExportExtensions != defaultExportExtensions { + return nil, errors.New("only one of 'formats' and 'export_formats' can be specified") + } + opt.Extensions, opt.ExportExtensions = "", opt.Extensions + } + f.exportExtensions, _, err = parseExtensions(opt.ExportExtensions, defaultExportExtensions) + if err != nil { + return nil, err + } + + _, f.importMimeTypes, err = parseExtensions(opt.ImportExtensions) if err != nil { return nil, err } @@ -824,6 +841,15 @@ func (f *Fs) newObjectWithInfo(remote string, info *drive.File) (fs.Object, erro return o, nil } +func (f *Fs) newDocumentObjectWithInfo(remote, extension, mimeType string, info *drive.File) (fs.Object, error) { + o, err := f.newObjectWithInfo(remote, info) + if err != nil { + return nil, err + } + o.(*Object).setGdocsMetaData(info, extension, mimeType) + return o, nil +} + // NewObject finds the Object at remote. If it can't be found // it returns the error fs.ErrorObjectNotFound. func (f *Fs) NewObject(remote string) (fs.Object, error) { @@ -884,50 +910,101 @@ func isAuthOwned(item *drive.File) bool { return false } +func (f *Fs) fetchFormats() { + fetchFormatsOnce.Do(func() { + var about *drive.About + var err error + err = f.pacer.Call(func() (bool, error) { + about, err = f.svc.About.Get(). + Fields("exportFormats,importFormats"). + Do() + return shouldRetry(err) + }) + if err != nil { + fs.Errorf(f, "Failed to get Drive exportFormats and importFormats: %v", err) + _exportFormats = map[string][]string{} + _importFormats = map[string][]string{} + return + } + _exportFormats = fixMimeTypeMap(about.ExportFormats) + _importFormats = fixMimeTypeMap(about.ImportFormats) + }) +} + // exportFormats returns the export formats from drive, fetching them // if necessary. // // if the fetch fails then it will not export any drive formats func (f *Fs) exportFormats() map[string][]string { - exportFormatsOnce.Do(func() { - var about *drive.About - var err error - err = f.pacer.Call(func() (bool, error) { - about, err = f.svc.About.Get(). - Fields("exportFormats"). - Do() - return shouldRetry(err) - }) - if err != nil { - fs.Errorf(f, "Failed to get Drive exportFormats: %v", err) - _exportFormats = map[string][]string{} - return - } - _exportFormats = fixMimeTypeMap(about.ExportFormats) - }) + f.fetchFormats() return _exportFormats } -// findExportFormat works out the optimum extension and MIME type -// for this item. +// importFormats returns the import formats from drive, fetching them +// if necessary. // -// Look through the extensions and find the first format that can be -// converted. If none found then return "", "" -func (f *Fs) findExportFormat(item *drive.File) (extension, filename, mimeType string, isDocument bool) { - exportMimeTypes, isDocument := f.exportFormats()[item.MimeType] +// if the fetch fails then it will not import any drive formats +func (f *Fs) importFormats() map[string][]string { + f.fetchFormats() + return _importFormats +} + +// findExportFormatByMimeType works out the optimum export settings +// for the given MIME type. +// +// Look through the exportExtensions and find the first format that can be +// converted. If none found then return ("", "", false) +func (f *Fs) findExportFormatByMimeType(itemMimeType string) ( + extension, mimeType string, isDocument bool) { + exportMimeTypes, isDocument := f.exportFormats()[itemMimeType] if isDocument { - for _, _extension := range f.extensions { + for _, _extension := range f.exportExtensions { _mimeType := mime.TypeByExtension(_extension) for _, emt := range exportMimeTypes { if emt == _mimeType { - return _extension, item.Name + _extension, _mimeType, true + return _extension, _mimeType, true } } } } // else return empty - return "", "", "", isDocument + return "", "", isDocument +} + +// findExportFormatByMimeType works out the optimum export settings +// for the given drive.File. +// +// Look through the exportExtensions and find the first format that can be +// converted. If none found then return ("", "", "", false) +func (f *Fs) findExportFormat(item *drive.File) (extension, filename, mimeType string, isDocument bool) { + extension, mimeType, isDocument = f.findExportFormatByMimeType(item.MimeType) + if extension != "" { + filename = item.Name + extension + } + return +} + +// findImportFormat finds the matching upload MIME type for a file +// If the given MIME type is in importMimeTypes, the matching upload +// MIME type is returned +// +// When no match is found "" is returned. +func (f *Fs) findImportFormat(mimeType string) string { + mimeType = fixMimeType(mimeType) + ifs := f.importFormats() + for _, mt := range f.importMimeTypes { + if mt == mimeType { + importMimeTypes := ifs[mimeType] + if l := len(importMimeTypes); l > 0 { + if l > 1 { + fs.Infof(f, "found %d import formats for %q: %q", l, mimeType, importMimeTypes) + } + return importMimeTypes[0] + } + } + } + return "" } // List the objects and directories in dir into entries. The @@ -1170,11 +1247,10 @@ func (f *Fs) itemToDirEntry(remote string, item *drive.File) (fs.DirEntry, error fs.Debugf(remote, "No export formats found for %q", item.MimeType) break } - o, err := f.newObjectWithInfo(remote+extension, item) + o, err := f.newDocumentObjectWithInfo(remote, extension, exportMimeType, item) if err != nil { return nil, err } - o.(*Object).setGdocsMetaData(item, extension, exportMimeType) return o, nil } return nil, nil @@ -1239,11 +1315,35 @@ func (f *Fs) PutUnchecked(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOpt remote := src.Remote() size := src.Size() modTime := src.ModTime() + srcMimeType := fs.MimeTypeFromName(remote) + srcExt := path.Ext(remote) + exportExt := "" + importMimeType := "" + exportMimeType := "" + + if f.importMimeTypes != nil && !f.opt.SkipGdocs { + importMimeType = f.findImportFormat(srcMimeType) + + if isInternalMimeType(importMimeType) { + remote = remote[:len(remote)-len(srcExt)] + + exportExt, exportMimeType, _ = f.findExportFormatByMimeType(importMimeType) + if exportExt == "" { + return nil, errors.Errorf("No export format found for %q", importMimeType) + } + if exportExt != srcExt && !f.opt.AllowImportNameChange { + return nil, errors.Errorf("Can't convert %q to a document with a different export filetype (%q)", srcExt, exportExt) + } + } + } o, createInfo, err := f.createFileInfo(remote, modTime, size) if err != nil { return nil, err } + if importMimeType != "" { + createInfo.MimeType = importMimeType + } var info *drive.File if size == 0 || size < int64(f.opt.UploadCutoff) { @@ -1251,7 +1351,7 @@ func (f *Fs) PutUnchecked(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOpt // Don't retry, return a retry error instead err = f.pacer.CallNoRetry(func() (bool, error) { info, err = f.svc.Files.Create(createInfo). - Media(in, googleapi.ContentType("")). + Media(in, googleapi.ContentType(srcMimeType)). Fields(googleapi.Field(partialFields)). SupportsTeamDrives(f.isTeamDrive). KeepRevisionForever(f.opt.KeepRevisionForever). @@ -1263,11 +1363,14 @@ func (f *Fs) PutUnchecked(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOpt } } else { // Upload the file in chunks - info, err = f.Upload(in, size, createInfo.MimeType, "", createInfo, remote) + info, err = f.Upload(in, size, srcMimeType, "", createInfo, remote) if err != nil { return o, err } } + if isInternalMimeType(importMimeType) { + return f.newDocumentObjectWithInfo(remote, exportExt, exportMimeType, info) + } o.setMetaData(info) return o, nil } @@ -1412,7 +1515,7 @@ func (f *Fs) Copy(src fs.Object, remote string) (fs.Object, error) { fs.Debugf(src, "Can't copy - not same remote type") return nil, fs.ErrorCantCopy } - if srcObj.isDocument { + if srcObj.documentMimeType != "" { return nil, errors.New("can't copy a Google document") } @@ -1531,7 +1634,7 @@ func (f *Fs) Move(src fs.Object, remote string) (fs.Object, error) { fs.Debugf(src, "Can't move - not same remote type") return nil, fs.ErrorCantMove } - if srcObj.isDocument { + if srcObj.documentMimeType != "" { return nil, errors.New("can't move a Google document") } _, srcParentID, err := srcObj.fs.dirCache.FindPath(src.Remote(), false) @@ -1926,7 +2029,7 @@ func (o *Object) setGdocsMetaData(info *drive.File, extension, exportMimeType st o.url = fmt.Sprintf("https://docs.google.com/presentation/d/%s/export/%s", info.Id, extension[1:]) } } - o.isDocument = true + o.documentMimeType = o.mimeType o.mimeType = exportMimeType o.bytes = -1 } @@ -2026,7 +2129,7 @@ func (o *Object) httpResponse(method string, options []fs.OpenOption) (req *http if o.url == "" { return nil, nil, errors.New("forbidden to download - check sharing permission") } - if o.isDocument { + if o.documentMimeType != "" { for _, o := range options { // https://developers.google.com/drive/v3/web/manage-downloads#partial_download if _, ok := o.(*fs.RangeOption); ok { @@ -2144,7 +2247,7 @@ func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) { // reading as it can change from the HEAD in the listing to // this GET. This stops rclone marking the transfer as // corrupted. - if o.isDocument { + if o.documentMimeType != "" { return &openFile{o: o, in: res.Body}, nil } return res.Body, nil @@ -2158,14 +2261,24 @@ func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) { func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { size := src.Size() modTime := src.ModTime() - if o.isDocument { - return errors.New("can't update a google document") - } + srcMimeType := fs.MimeType(src) + importMimeType := "" updateInfo := &drive.File{ - MimeType: fs.MimeType(src), + MimeType: srcMimeType, ModifiedTime: modTime.Format(timeFormatOut), } + if o.fs.importMimeTypes != nil && !o.fs.opt.SkipGdocs { + importMimeType = o.fs.findImportFormat(updateInfo.MimeType) + if importMimeType != "" { + // FIXME: check importMimeType against original object MIME type + // if importMimeType != o.mimeType { + // return errors.Errorf("can't change google document type (o: %q, src: %q, import: %q)", o.mimeType, srcMimeType, importMimeType) + // } + updateInfo.MimeType = importMimeType + } + } + // Make the API request to upload metadata and file data. var err error var info *drive.File @@ -2173,7 +2286,7 @@ func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOptio // Don't retry, return a retry error instead err = o.fs.pacer.CallNoRetry(func() (bool, error) { info, err = o.fs.svc.Files.Update(o.id, updateInfo). - Media(in, googleapi.ContentType("")). + Media(in, googleapi.ContentType(srcMimeType)). Fields(googleapi.Field(partialFields)). SupportsTeamDrives(o.fs.isTeamDrive). KeepRevisionForever(o.fs.opt.KeepRevisionForever). @@ -2185,20 +2298,22 @@ func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOptio } } else { // Upload the file in chunks - info, err = o.fs.Upload(in, size, updateInfo.MimeType, o.id, updateInfo, o.remote) + info, err = o.fs.Upload(in, size, srcMimeType, o.id, updateInfo, o.remote) if err != nil { return err } } o.setMetaData(info) + if importMimeType != "" { + extension, exportMimeType, _ := o.fs.findExportFormatByMimeType(importMimeType) + o.setGdocsMetaData(info, extension, exportMimeType) + } + return nil } // Remove an object func (o *Object) Remove() error { - if o.isDocument { - return errors.New("can't delete a google document") - } var err error err = o.fs.pacer.Call(func() (bool, error) { if o.fs.opt.UseTrash { diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go index fa8998fc5..b9479e78e 100644 --- a/backend/drive/drive_internal_test.go +++ b/backend/drive/drive_internal_test.go @@ -1,64 +1,54 @@ package drive import ( + "bytes" "encoding/json" + "io" + "io/ioutil" "mime" + "path/filepath" "testing" - "google.golang.org/api/drive/v3" - + _ "github.com/ncw/rclone/backend/local" + "github.com/ncw/rclone/fs" + "github.com/ncw/rclone/fs/operations" + "github.com/ncw/rclone/fstest/fstests" "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/drive/v3" ) -const exampleExportFormats = `{ - "application/vnd.google-apps.document": [ - "application/rtf", - "application/vnd.oasis.opendocument.text", - "text/html", - "application/pdf", - "application/epub+zip", - "application/zip", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "text/plain" - ], - "application/vnd.google-apps.spreadsheet": [ - "application/x-vnd.oasis.opendocument.spreadsheet", - "text/tab-separated-values", - "application/pdf", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "text/csv", - "application/zip", - "application/vnd.oasis.opendocument.spreadsheet" - ], - "application/vnd.google-apps.jam": [ - "application/pdf" - ], - "application/vnd.google-apps.script": [ - "application/vnd.google-apps.script+json" - ], - "application/vnd.google-apps.presentation": [ - "application/vnd.oasis.opendocument.presentation", - "application/pdf", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "text/plain" - ], - "application/vnd.google-apps.form": [ - "application/zip" - ], - "application/vnd.google-apps.drawing": [ - "image/svg+xml", - "image/png", - "application/pdf", - "image/jpeg" - ] -}` +/* +var additionalMimeTypes = map[string]string{ + "application/vnd.ms-excel.sheet.macroenabled.12": ".xlsm", + "application/vnd.ms-excel.template.macroenabled.12": ".xltm", + "application/vnd.ms-powerpoint.presentation.macroenabled.12": ".pptm", + "application/vnd.ms-powerpoint.slideshow.macroenabled.12": ".ppsm", + "application/vnd.ms-powerpoint.template.macroenabled.12": ".potm", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.ms-word.document.macroenabled.12": ".docm", + "application/vnd.ms-word.template.macroenabled.12": ".dotm", + "application/vnd.openxmlformats-officedocument.presentationml.template": ".potx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template": ".xltx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template": ".dotx", + "application/vnd.sun.xml.writer": ".sxw", + "text/richtext": ".rtf", +} +*/ // Load the example export formats into exportFormats for testing -func TestInternalLoadExampleExportFormats(t *testing.T) { - exportFormatsOnce.Do(func() {}) - assert.NoError(t, json.Unmarshal([]byte(exampleExportFormats), &_exportFormats)) - _exportFormats = fixMimeTypeMap(_exportFormats) +func TestInternalLoadExampleFormats(t *testing.T) { + fetchFormatsOnce.Do(func() {}) + buf, err := ioutil.ReadFile(filepath.FromSlash("test/about.json")) + var about struct { + ExportFormats map[string][]string `json:"exportFormats,omitempty"` + ImportFormats map[string][]string `json:"importFormats,omitempty"` + } + require.NoError(t, err) + require.NoError(t, json.Unmarshal(buf, &about)) + _exportFormats = fixMimeTypeMap(about.ExportFormats) + _importFormats = fixMimeTypeMap(about.ImportFormats) } func TestInternalParseExtensions(t *testing.T) { @@ -72,7 +62,7 @@ func TestInternalParseExtensions(t *testing.T) { {"docx,svg,Docx", []string{".docx", ".svg"}, nil}, {"docx,potato,docx", []string{".docx"}, errors.New(`couldn't find MIME type for extension ".potato"`)}, } { - extensions, gotErr := parseExtensions(test.in) + extensions, _, gotErr := parseExtensions(test.in) if test.wantErr == nil { assert.NoError(t, gotErr) } else { @@ -82,7 +72,7 @@ func TestInternalParseExtensions(t *testing.T) { } // Test it is appending - extensions, gotErr := parseExtensions("docx,svg", "docx,svg,xlsx") + extensions, _, gotErr := parseExtensions("docx,svg", "docx,svg,xlsx") assert.NoError(t, gotErr) assert.Equal(t, []string{".docx", ".svg", ".xlsx"}, extensions) } @@ -104,11 +94,11 @@ func TestInternalFindExportFormat(t *testing.T) { {[]string{".xls", ".csv", ".svg"}, "", ""}, } { f := new(Fs) - f.extensions = test.extensions + f.exportExtensions = test.extensions gotExtension, gotFilename, gotMimeType, gotIsDocument := f.findExportFormat(item) assert.Equal(t, test.wantExtension, gotExtension) if test.wantExtension != "" { - assert.Equal(t, item.Name+"."+gotExtension, gotFilename) + assert.Equal(t, item.Name+gotExtension, gotFilename) } else { assert.Equal(t, "", gotFilename) } @@ -148,3 +138,85 @@ func TestExtensionsForExportFormats(t *testing.T) { } } } + +func TestExtensionsForImportFormats(t *testing.T) { + t.Skip() + if _importFormats == nil { + t.Error("_importFormats == nil") + } + for fromMT := range _importFormats { + if !isInternalMimeType(fromMT) { + extensions, err := mime.ExtensionsByType(fromMT) + assert.NoError(t, err, "invalid MIME type %q", fromMT) + assert.NotEmpty(t, extensions, "No extension found for %q", fromMT) + } + } +} + +func (f *Fs) InternalTestDocumentImport(t *testing.T) { + oldAllow := f.opt.AllowImportNameChange + f.opt.AllowImportNameChange = true + defer func() { + f.opt.AllowImportNameChange = oldAllow + }() + + testFilesPath, err := filepath.Abs(filepath.FromSlash("test/files")) + require.NoError(t, err) + + testFilesFs, err := fs.NewFs(testFilesPath) + require.NoError(t, err) + + _, f.importMimeTypes, err = parseExtensions("odt,ods,doc") + require.NoError(t, err) + + err = operations.CopyFile(f, testFilesFs, "example2.doc", "example2.doc") + require.NoError(t, err) +} + +func (f *Fs) InternalTestDocumentUpdate(t *testing.T) { + testFilesPath, err := filepath.Abs(filepath.FromSlash("test/files")) + require.NoError(t, err) + + testFilesFs, err := fs.NewFs(testFilesPath) + require.NoError(t, err) + + _, f.importMimeTypes, err = parseExtensions("odt,ods,doc") + require.NoError(t, err) + + err = operations.CopyFile(f, testFilesFs, "example2.xlsx", "example1.ods") + require.NoError(t, err) +} + +func (f *Fs) InternalTestDocumentExport(t *testing.T) { + var buf bytes.Buffer + var err error + + f.exportExtensions, _, err = parseExtensions("txt") + require.NoError(t, err) + + obj, err := f.NewObject("example2.txt") + require.NoError(t, err) + + rc, err := obj.Open() + require.NoError(t, err) + defer func() { require.NoError(t, rc.Close()) }() + + _, err = io.Copy(&buf, rc) + require.NoError(t, err) + text := buf.String() + + for _, excerpt := range []string{ + "Lorem ipsum dolor sit amet, consectetur", + "porta at ultrices in, consectetur at augue.", + } { + require.Contains(t, text, excerpt) + } +} + +func (f *Fs) InternalTest(t *testing.T) { + t.Run("DocumentImport", f.InternalTestDocumentImport) + t.Run("DocumentUpdate", f.InternalTestDocumentUpdate) + t.Run("DocumentExport", f.InternalTestDocumentExport) +} + +var _ fstests.InternalTester = (*Fs)(nil) diff --git a/backend/drive/test/about.json b/backend/drive/test/about.json new file mode 100644 index 000000000..1c2e23176 --- /dev/null +++ b/backend/drive/test/about.json @@ -0,0 +1,178 @@ +{ + "importFormats": { + "text/tab-separated-values": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/x-vnd.oasis.opendocument.presentation": [ + "application/vnd.google-apps.presentation" + ], + "image/jpeg": [ + "application/vnd.google-apps.document" + ], + "image/bmp": [ + "application/vnd.google-apps.document" + ], + "image/gif": [ + "application/vnd.google-apps.document" + ], + "application/vnd.ms-excel.sheet.macroenabled.12": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/vnd.openxmlformats-officedocument.wordprocessingml.template": [ + "application/vnd.google-apps.document" + ], + "application/vnd.ms-powerpoint.presentation.macroenabled.12": [ + "application/vnd.google-apps.presentation" + ], + "application/vnd.ms-word.template.macroenabled.12": [ + "application/vnd.google-apps.document" + ], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [ + "application/vnd.google-apps.document" + ], + "image/pjpeg": [ + "application/vnd.google-apps.document" + ], + "application/vnd.google-apps.script+text/plain": [ + "application/vnd.google-apps.script" + ], + "application/vnd.ms-excel": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/vnd.sun.xml.writer": [ + "application/vnd.google-apps.document" + ], + "application/vnd.ms-word.document.macroenabled.12": [ + "application/vnd.google-apps.document" + ], + "application/vnd.ms-powerpoint.slideshow.macroenabled.12": [ + "application/vnd.google-apps.presentation" + ], + "text/rtf": [ + "application/vnd.google-apps.document" + ], + "text/plain": [ + "application/vnd.google-apps.document" + ], + "application/vnd.oasis.opendocument.spreadsheet": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/x-vnd.oasis.opendocument.spreadsheet": [ + "application/vnd.google-apps.spreadsheet" + ], + "image/png": [ + "application/vnd.google-apps.document" + ], + "application/x-vnd.oasis.opendocument.text": [ + "application/vnd.google-apps.document" + ], + "application/msword": [ + "application/vnd.google-apps.document" + ], + "application/pdf": [ + "application/vnd.google-apps.document" + ], + "application/json": [ + "application/vnd.google-apps.script" + ], + "application/x-msmetafile": [ + "application/vnd.google-apps.drawing" + ], + "application/vnd.openxmlformats-officedocument.spreadsheetml.template": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/vnd.ms-powerpoint": [ + "application/vnd.google-apps.presentation" + ], + "application/vnd.ms-excel.template.macroenabled.12": [ + "application/vnd.google-apps.spreadsheet" + ], + "image/x-bmp": [ + "application/vnd.google-apps.document" + ], + "application/rtf": [ + "application/vnd.google-apps.document" + ], + "application/vnd.openxmlformats-officedocument.presentationml.template": [ + "application/vnd.google-apps.presentation" + ], + "image/x-png": [ + "application/vnd.google-apps.document" + ], + "text/html": [ + "application/vnd.google-apps.document" + ], + "application/vnd.oasis.opendocument.text": [ + "application/vnd.google-apps.document" + ], + "application/vnd.openxmlformats-officedocument.presentationml.presentation": [ + "application/vnd.google-apps.presentation" + ], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/vnd.google-apps.script+json": [ + "application/vnd.google-apps.script" + ], + "application/vnd.openxmlformats-officedocument.presentationml.slideshow": [ + "application/vnd.google-apps.presentation" + ], + "application/vnd.ms-powerpoint.template.macroenabled.12": [ + "application/vnd.google-apps.presentation" + ], + "text/csv": [ + "application/vnd.google-apps.spreadsheet" + ], + "application/vnd.oasis.opendocument.presentation": [ + "application/vnd.google-apps.presentation" + ], + "image/jpg": [ + "application/vnd.google-apps.document" + ], + "text/richtext": [ + "application/vnd.google-apps.document" + ] + }, + "exportFormats": { + "application/vnd.google-apps.document": [ + "application/rtf", + "application/vnd.oasis.opendocument.text", + "text/html", + "application/pdf", + "application/epub+zip", + "application/zip", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain" + ], + "application/vnd.google-apps.spreadsheet": [ + "application/x-vnd.oasis.opendocument.spreadsheet", + "text/tab-separated-values", + "application/pdf", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/csv", + "application/zip", + "application/vnd.oasis.opendocument.spreadsheet" + ], + "application/vnd.google-apps.jam": [ + "application/pdf" + ], + "application/vnd.google-apps.script": [ + "application/vnd.google-apps.script+json" + ], + "application/vnd.google-apps.presentation": [ + "application/vnd.oasis.opendocument.presentation", + "application/pdf", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain" + ], + "application/vnd.google-apps.form": [ + "application/zip" + ], + "application/vnd.google-apps.drawing": [ + "image/svg+xml", + "image/png", + "application/pdf", + "image/jpeg" + ] + } +} diff --git a/backend/drive/test/files/example1.ods b/backend/drive/test/files/example1.ods new file mode 100644 index 0000000000000000000000000000000000000000..52261164a8f6d97590471bd5ac352458fbe39745 GIT binary patch literal 13328 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 literal 0 HcmV?d00001 diff --git a/backend/drive/test/files/example3.odt b/backend/drive/test/files/example3.odt new file mode 100644 index 0000000000000000000000000000000000000000..625d12317d60508bc684e7982b23f7a0c18d9a1c GIT binary patch literal 21996 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