diff --git a/backend/drive/drive.go b/backend/drive/drive.go index f582e4574..250b20b0b 100644 --- a/backend/drive/drive.go +++ b/backend/drive/drive.go @@ -21,6 +21,7 @@ import ( "strconv" "strings" "sync" + "text/template" "time" "github.com/ncw/rclone/fs" @@ -103,10 +104,18 @@ var ( "text/plain": ".txt", "text/tab-separated-values": ".tsv", } - 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 + _mimeTypeToExtensionLinks = map[string]string{ + "application/x-link-desktop": ".desktop", + "application/x-link-html": ".link.html", + "application/x-link-url": ".url", + "application/x-link-webloc": ".webloc", + } + partialFields = "id,name,size,md5Checksum,trashed,modifiedTime,createdTime,mimeType,parents,webViewLink" + 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 + templatesOnce sync.Once // parse link templates only once + _linkTemplates map[string]*template.Template // available link types ) // Register with Fs @@ -284,7 +293,9 @@ func init() { // register duplicate MIME types first // this allows them to be used with mime.ExtensionsByType() but // mime.TypeByExtension() will return the later registered MIME type - for _, m := range []map[string]string{_mimeTypeToExtensionDuplicates, _mimeTypeToExtension} { + for _, m := range []map[string]string{ + _mimeTypeToExtensionDuplicates, _mimeTypeToExtension, _mimeTypeToExtensionLinks, + } { for mimeType, extension := range m { if err := mime.AddExtensionType(extension, mimeType); err != nil { log.Fatalf("Failed to register MIME type %q: %v", mimeType, err) @@ -351,6 +362,11 @@ type documentObject struct { documentMimeType string // the original document MIME type extLen int // The length of the added export extension } +type linkObject struct { + baseObject + content []byte // The file content generated by a link template + extLen int // The length of the added export extension +} // Object describes a drive object type Object struct { @@ -590,6 +606,9 @@ func fixMimeTypeMap(m map[string][]string) map[string][]string { func isInternalMimeType(mimeType string) bool { return strings.HasPrefix(mimeType, "application/vnd.google-apps.") } +func isLinkMimeType(mimeType string) bool { + return strings.HasPrefix(mimeType, "application/x-link-") +} // parseExtensions parses a list of comma separated extensions // into a list of unique extensions with leading "." and a list of associated MIME types @@ -886,6 +905,32 @@ func (f *Fs) newDocumentObject(remote string, info *drive.File, extension, expor }, nil } +// newLinkObject creates a fs.Object that represents a link a google docs drive.File +func (f *Fs) newLinkObject(remote string, info *drive.File, extension, exportMimeType string) (fs.Object, error) { + t := linkTemplate(exportMimeType) + if t == nil { + return nil, errors.Errorf("unsupported link type %s", exportMimeType) + } + var buf bytes.Buffer + err := t.Execute(&buf, struct { + URL, Title string + }{ + info.WebViewLink, info.Name, + }) + if err != nil { + return nil, errors.Wrap(err, "executing template failed") + } + + baseObject := f.newBaseObject(remote+extension, info) + baseObject.bytes = int64(buf.Len()) + baseObject.mimeType = exportMimeType + return &linkObject{ + baseObject: baseObject, + content: buf.Bytes(), + extLen: len(extension), + }, nil +} + // newObjectWithInfo creates a fs.Object for any drive.File // // When the drive.File cannot be represented as a fs.Object it will return (nil, nil). @@ -922,6 +967,9 @@ func (f *Fs) newObjectWithExportInfo( fs.Debugf(remote, "No export formats found for %q", info.MimeType) return nil, nil } + if isLinkMimeType(exportMimeType) { + return f.newLinkObject(remote, info, extension, exportMimeType) + } return f.newDocumentObject(remote, info, extension, exportMimeType) } } @@ -1003,6 +1051,23 @@ func isAuthOwned(item *drive.File) bool { return false } +// linkTemplate returns the Template for a MIME type or nil if the +// MIME type does not represent a link +func linkTemplate(mt string) *template.Template { + templatesOnce.Do(func() { + _linkTemplates = map[string]*template.Template{ + "application/x-link-desktop": template.Must( + template.New("application/x-link-desktop").Parse(desktopTemplate)), + "application/x-link-html": template.Must( + template.New("application/x-link-html").Parse(htmlTemplate)), + "application/x-link-url": template.Must( + template.New("application/x-link-url").Parse(urlTemplate)), + "application/x-link-webloc": template.Must( + template.New("application/x-link-webloc").Parse(weblocTemplate)), + } + }) + return _linkTemplates[mt] +} func (f *Fs) fetchFormats() { fetchFormatsOnce.Do(func() { var about *drive.About @@ -1053,6 +1118,9 @@ func (f *Fs) findExportFormatByMimeType(itemMimeType string) ( if isDocument { for _, _extension := range f.exportExtensions { _mimeType := mime.TypeByExtension(_extension) + if isLinkMimeType(_mimeType) { + return _extension, _mimeType, true + } for _, emt := range exportMimeTypes { if emt == _mimeType { return _extension, _mimeType, true @@ -1579,6 +1647,9 @@ func (f *Fs) Copy(src fs.Object, remote string) (fs.Object, error) { case *documentObject: srcObj = &src.baseObject srcRemote, ext = srcRemote[:len(srcRemote)-src.extLen], srcRemote[len(srcRemote)-src.extLen:] + case *linkObject: + srcObj = &src.baseObject + srcRemote, ext = srcRemote[:len(srcRemote)-src.extLen], srcRemote[len(srcRemote)-src.extLen:] default: fs.Debugf(src, "Can't copy - not same remote type") return nil, fs.ErrorCantCopy @@ -1709,6 +1780,9 @@ func (f *Fs) Move(src fs.Object, remote string) (fs.Object, error) { case *documentObject: srcObj = &src.baseObject srcRemote, ext = srcRemote[:len(srcRemote)-src.extLen], srcRemote[len(srcRemote)-src.extLen:] + case *linkObject: + srcObj = &src.baseObject + srcRemote, ext = srcRemote[:len(srcRemote)-src.extLen], srcRemote[len(srcRemote)-src.extLen:] default: fs.Debugf(src, "Can't move - not same remote type") return nil, fs.ErrorCantMove @@ -2310,6 +2384,31 @@ func (o *documentObject) Open(options ...fs.OpenOption) (in io.ReadCloser, err e } return } +func (o *linkObject) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) { + var offset, limit int64 = 0, -1 + var data = o.content + for _, option := range options { + switch x := option.(type) { + case *fs.SeekOption: + offset = x.Offset + case *fs.RangeOption: + offset, limit = x.Decode(int64(len(data))) + default: + if option.Mandatory() { + fs.Logf(o, "Unsupported mandatory option: %v", option) + } + } + } + if l := int64(len(data)); offset > l { + offset = l + } + data = data[offset:] + if limit != -1 && limit < int64(len(data)) { + data = data[:limit] + } + + return ioutil.NopCloser(bytes.NewReader(data)), nil +} func (o *baseObject) update(updateInfo *drive.File, uploadMimeType string, in io.Reader, src fs.ObjectInfo) (info *drive.File, err error) { @@ -2396,6 +2495,10 @@ func (o *documentObject) Update(in io.Reader, src fs.ObjectInfo, options ...fs.O return nil } +func (o *linkObject) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { + return errors.New("cannot update link files") +} + // Remove an object func (o *baseObject) Remove() error { var err error @@ -2429,6 +2532,39 @@ func (o *baseObject) ID() string { return o.id } +// templates for document link files +const ( + urlTemplate = `[InternetShortcut]{{"\r"}} +URL={{ .URL }}{{"\r"}} +` + weblocTemplate = ` + + + + URL + {{ .URL }} + + +` + desktopTemplate = `[Desktop Entry] +Encoding=UTF-8 +Name={{ .Title }} +URL={{ .URL }} +Icon=text-html +Type=Link +` + htmlTemplate = ` + + + {{ .Title }} + + + Loading {{ .Title }} + + +` +) + // Check the interfaces are satisfied var ( _ fs.Fs = (*Fs)(nil) @@ -2451,4 +2587,7 @@ var ( _ fs.Object = (*documentObject)(nil) _ fs.MimeTyper = (*documentObject)(nil) _ fs.IDer = (*documentObject)(nil) + _ fs.Object = (*linkObject)(nil) + _ fs.MimeTyper = (*linkObject)(nil) + _ fs.IDer = (*linkObject)(nil) ) diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go index b9479e78e..933686b56 100644 --- a/backend/drive/drive_internal_test.go +++ b/backend/drive/drive_internal_test.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "mime" "path/filepath" + "strings" "testing" _ "github.com/ncw/rclone/backend/local" @@ -213,10 +214,39 @@ func (f *Fs) InternalTestDocumentExport(t *testing.T) { } } +func (f *Fs) InternalTestDocumentLink(t *testing.T) { + var buf bytes.Buffer + var err error + + f.exportExtensions, _, err = parseExtensions("link.html") + require.NoError(t, err) + + obj, err := f.NewObject("example2.link.html") + 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() + + require.True(t, strings.HasPrefix(text, "")) + require.True(t, strings.HasSuffix(text, "\n")) + for _, excerpt := range []string{ + `