diff options
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | LICENSE | 22 | ||||
| -rw-r--r-- | auth/auth.go | 68 | ||||
| -rwxr-xr-x | build-all.sh | 38 | ||||
| -rw-r--r-- | cli/cli.go | 307 | ||||
| -rw-r--r-- | config/config.go | 74 | ||||
| -rw-r--r-- | drive.go | 129 | ||||
| -rw-r--r-- | gdrive/gdrive.go | 53 | ||||
| -rw-r--r-- | util/drive.go | 15 | ||||
| -rw-r--r-- | util/generic.go | 196 | 
10 files changed, 911 insertions, 0 deletions
| diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..769fd25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Ignore bin folder and drive binary +bin/ +drive + +# vim files +.*.sw[a-z] +*.un~ +Session.vim +.netrwhist @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2013 Petter Rasmussen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..c4a67e3 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,68 @@ +package auth + +import ( +    "net/http" +    "fmt" +    "code.google.com/p/goauth2/oauth" +    "../util" +) + +// Get auth code from user +func promptUserForAuthCode(config *oauth.Config) string { +    authUrl := config.AuthCodeURL("state") +    fmt.Println("Go to the following link in your browser:") +    fmt.Printf("%v\n\n", authUrl) +    return util.Prompt("Enter verification code: ") +} + +// Returns true if we have a valid cached token +func hasValidToken(cacheFile oauth.CacheFile, transport *oauth.Transport) bool { +    // Check if we have a cached token +    token, err := cacheFile.Token() +    if err != nil { +        return false +    } + +    // Refresh token if its expired +    if token.Expired() { +        transport.Token = token +        err = transport.Refresh() +        if err != nil { +            fmt.Println(err) +            return false +        } +    } +    return true +} + +func GetOauth2Client(clientId, clientSecret, cachePath string) (*http.Client, error) { +    cacheFile := oauth.CacheFile(cachePath) + +    config := &oauth.Config{ +        ClientId:     clientId, +        ClientSecret: clientSecret, +        Scope:        "https://www.googleapis.com/auth/drive", +        RedirectURL:  "urn:ietf:wg:oauth:2.0:oob", +        AuthURL:      "https://accounts.google.com/o/oauth2/auth", +        TokenURL:     "https://accounts.google.com/o/oauth2/token", +        TokenCache:   cacheFile, +    } + +    transport := &oauth.Transport{ +        Config:    config, +        Transport: http.DefaultTransport, +    } + +    // Return client if we have a valid token +    if hasValidToken(cacheFile, transport) { +        return transport.Client(), nil +    } + +    // Get auth code from user and request a new token +    code := promptUserForAuthCode(config) +    _, err := transport.Exchange(code) +    if err != nil { +        return nil, err +    } +    return transport.Client(), nil +} diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 0000000..5170bdd --- /dev/null +++ b/build-all.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +if [ -z "$1" ]; then +    echo "Usage: $0 <app>" +    exit 1 +fi + +PLATFORMS="darwin/386 darwin/amd64 freebsd/386 freebsd/amd64 linux/386 linux/amd64 linux/arm windows/386 windows/amd64" +APP_NAME=$1 + +# Remove old binaries +rm bin/* + +# Load crosscompile environment +source /Users/pii/scripts/golang-crosscompile/crosscompile.bash + +# Build binary for each platform in parallel +for PLATFORM in $PLATFORMS; do +    GOOS=${PLATFORM%/*} +    GOARCH=${PLATFORM#*/} +    BIN_NAME="${APP_NAME}-$GOOS-$GOARCH" + +    if [ $GOOS == "windows" ]; then +        BIN_NAME="${BIN_NAME}.exe" +    fi + +    BUILD_CMD="go-${GOOS}-${GOARCH} build -o bin/${BIN_NAME} $APP_NAME.go" + +    echo "Building $APP_NAME for ${GOOS}/${GOARCH}..." +    $BUILD_CMD & +done + +# Wait for builds to complete +for job in $(jobs -p); do +    wait $job +done + +echo "All done" diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..d78c838 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,307 @@ +package cli + +import ( +    "fmt" +    "os" +    "io" +    "path/filepath" +    "strings" +    "code.google.com/p/google-api-go-client/drive/v2" +    "../util" +    "../gdrive" +) + +func List(d *gdrive.Drive, query, titleFilter string, maxResults int, sharedStatus bool) { +    caller := d.Files.List() + +    if maxResults > 0 { +        caller.MaxResults(int64(maxResults)) +    } + +    if titleFilter != "" { +        q := fmt.Sprintf("title contains '%s'", titleFilter) +        caller.Q(q) +    } + +    if query != "" { +        caller.Q(query) +    } + +    list, err := caller.Do() +    if err != nil { +        fmt.Println(err) +        return +    } + +    items := make([]map[string]string, 0, 0) + +    for _, f := range list.Items { +        // Skip files that dont have a download url (they are not stored on google drive) +        if f.DownloadUrl == "" { +            continue +        } + +        items = append(items, map[string]string{ +            "Id": f.Id, +            "Title": util.TruncateString(f.Title, 40), +            "Size": util.FileSizeFormat(f.FileSize), +            "Created": util.ISODateToLocal(f.CreatedDate), +        }) +    } + +    columnOrder := []string{"Id", "Title", "Size", "Created"} + +    if sharedStatus { +        addSharedStatus(d, items) +        columnOrder = append(columnOrder, "Shared") +    } + +    util.PrintColumns(items, columnOrder, 3) +} + +// Adds the key-value-pair 'Shared: True/False' to the map +func addSharedStatus(d *gdrive.Drive, items []map[string]string) { +    // Limit to 10 simultaneous requests +    active := make(chan bool, 10) +    done := make(chan bool) + +    // Closure that performs the check +    checkStatus := func(item map[string]string) { +        // Wait for an empty spot in the active queue +        active <- true + +        // Perform request +        shared := isShared(d, item["Id"]) +        item["Shared"] = util.FormatBool(shared) + +        // Decrement the active queue and notify that we are done +        <-active +        done <- true +    } + +    // Go, go, go! +    for _, item := range items { +        go checkStatus(item) +    } + +    // Wait for all goroutines to finish +    for i := 0; i < len(items); i++ { +        <-done +    } +} + +func Info(d *gdrive.Drive, fileId string) { +    info, err := d.Files.Get(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } +    printInfo(d, info) +} + +func printInfo(d *gdrive.Drive, f *drive.File) { +    fields := map[string]string{ +        "Id": f.Id, +        "Title": f.Title, +        "Description": f.Description, +        "Size": util.FileSizeFormat(f.FileSize), +        "Created": util.ISODateToLocal(f.CreatedDate), +        "Modified": util.ISODateToLocal(f.ModifiedDate), +        "Owner": strings.Join(f.OwnerNames, ", "), +        "Md5sum": f.Md5Checksum, +        "Shared": util.FormatBool(isShared(d, f.Id)), +    } + +    order := []string{"Id", "Title", "Description", "Size", "Created", "Modified", "Owner", "Md5sum", "Shared"} +    util.Print(fields, order) +} + +// Upload file to drive +func Upload(d *gdrive.Drive, input io.ReadCloser, title string, share bool) { +    // Use filename or 'untitled' as title if no title is specified +    if title == "" { +        if f, ok := input.(*os.File); ok && input != os.Stdin { +            title = filepath.Base(f.Name()) +        } else { +            title = "untitled" +        } +    } + +    metadata := &drive.File{Title: title} +    getRate := util.MeasureTransferRate() + +    info, err := d.Files.Insert(metadata).Media(input).Do() +    if err != nil { +        fmt.Printf("An error occurred uploading the document: %v\n", err) +        return +    } + +    // Total bytes transferred +    bytes := info.FileSize + +    // Print information about uploaded file +    printInfo(d, info) +    fmt.Printf("Uploaded '%s' at %s, total %s\n", info.Title, getRate(bytes), util.FileSizeFormat(bytes)) + +    // Share file if the share flag was provided +    if share { +        Share(d, info.Id) +    } +} + +func DownloadLatest(d *gdrive.Drive, stdout bool) { +    list, err := d.Files.List().Do() + +    if err != nil { +        fmt.Println(err) +        return +    } + +    if len(list.Items) == 0 { +        fmt.Println("No files found") +        return +    } + +    latestId := list.Items[0].Id +    Download(d, latestId, stdout, true) +} + +// Download file from drive +func Download(d *gdrive.Drive, fileId string, stdout, deleteAfterDownload bool) { +    // Get file info +    info, err := d.Files.Get(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    if info.DownloadUrl == "" { +        // If there is no DownloadUrl, there is no body +        fmt.Println("An error occurred: File is not downloadable") +        return +    } + +    // Measure transfer rate +    getRate := util.MeasureTransferRate() + +    // GET the download url +    res, err := d.Client().Get(info.DownloadUrl) +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    // Close body on function exit +    defer res.Body.Close() + +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    // Write file content to stdout +    if stdout { +        io.Copy(os.Stdout, res.Body) +        return +    } + +    // Check if file exists +    if util.FileExists(info.Title) { +        fmt.Printf("An error occurred: '%s' already exists\n", info.Title) +        return +    } + +    // Create a new file +    outFile, err := os.Create(info.Title)  +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    // Close file on function exit +    defer outFile.Close() + +    // Save file to disk +    bytes, err := io.Copy(outFile, res.Body) +    if err != nil { +        fmt.Printf("An error occurred: %v") +        return +    } + +    fmt.Printf("Downloaded '%s' at %s, total %s\n", info.Title, getRate(bytes), util.FileSizeFormat(bytes)) + +    if deleteAfterDownload { +        Delete(d, fileId) +    } +} + +// Delete file with given file id +func Delete(d *gdrive.Drive, fileId string) { +    info, err := d.Files.Get(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    if err = d.Files.Delete(fileId).Do(); err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } +     +    fmt.Printf("Removed file '%s'\n", info.Title) +} + +// Make given file id readable by anyone -- auth not required to view/download file +func Share(d *gdrive.Drive, fileId string) { +    info, err := d.Files.Get(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    perm := &drive.Permission{ +        Value: "me", +        Type: "anyone", +        Role: "reader", +    } + +    _, err = d.Permissions.Insert(fileId, perm).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    fmt.Printf("File '%s' is now readable by everyone @ %s\n", info.Title, util.PreviewUrl(fileId)) +} + +// Removes the 'anyone' permission -- auth will be required to view/download file +func Unshare(d *gdrive.Drive, fileId string) { +    info, err := d.Files.Get(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    err = d.Permissions.Delete(fileId, "anyone").Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return +    } + +    fmt.Printf("File '%s' is now longer shared to 'anyone'\n", info.Title) +} + +func isShared(d *gdrive.Drive, fileId string) bool { +    r, err := d.Permissions.List(fileId).Do() +    if err != nil { +        fmt.Printf("An error occurred: %v\n", err) +        return false +    } + +    for _, perm := range r.Items { +        if perm.Type == "anyone" { +            return true +        } +    } +    return false +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..33451e2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,74 @@ +package config + +import ( +    "fmt" +    "io/ioutil" +    "encoding/json" +    "../util" +) + +// Client ID and secrect for installed applications +const ( +    ClientId = "367116221053-7n0vf5akeru7on6o2fjinrecpdoe99eg.apps.googleusercontent.com" +    ClientSecret = "1qsNodXNaWq1mQuBjUjmvhoO" +) + +type Config struct { +    ClientId string +    ClientSecret string +} + +func defaultConfig() *Config { +    return &Config{ +        ClientId: ClientId, +        ClientSecret: ClientSecret, +    } +} + +func promptUser() *Config { +    return &Config{ +        ClientId: util.Prompt("Enter Client Id: "), +        ClientSecret: util.Prompt("Enter Client Secret: "), +    } +} + +func load(fname string) (*Config, error) { +    data, err := ioutil.ReadFile(fname) +    if err != nil { +        return nil, err +    } +    config := &Config{} +    return config, json.Unmarshal(data, config) +} + +func save(fname string, config *Config) error { +    data, err := json.MarshalIndent(config, "", "    ") +    if err != nil { +        return err +    } + +    if err = util.Mkdir(fname); err != nil { +        return err +    } +    return ioutil.WriteFile(fname, data, 0600) +} + +func Load(fname string, advancedUser bool) *Config { +    config, err := load(fname) +    if err != nil { +        // Unable to read existing config, lets start from scracth +        // Get config from user input for advanced users, or just use default settings +        if advancedUser { +            config = promptUser() +        } else { +            config = defaultConfig() +        } +         +        // Save config to file +        err := save(fname, config) +        if err != nil { +            fmt.Printf("Failed to save config (%s)\n", err) +        } +    } +    return config +} diff --git a/drive.go b/drive.go new file mode 100644 index 0000000..33d64b3 --- /dev/null +++ b/drive.go @@ -0,0 +1,129 @@ +package main + +import ( +    "fmt" +    "os" +    "github.com/voxelbrain/goptions" +    "./gdrive" +    "./util" +    "./cli" +) + +const ( +    VersionNumber = "1.0.0" +) + +type Options struct { +    Advanced bool `goptions:"-a, --advanced, description='Advanced Mode -- lets you specify your own oauth client id and secret on setup'"` +    AppPath string `goptions:"-c, --config, description='Set application path where config and token is stored. Defaults to ~/.gdrive'"` +    Version bool `goptions:"-v, --version, description='Print version'"` +    goptions.Help `goptions:"-h, --help, description='Show this help'"` + +    goptions.Verbs + +    List struct { +        MaxResults int `goptions:"-m, --max, description='Max results'"` +        TitleFilter string `goptions:"-t, --title, mutexgroup='query', description='Title filter'"` +        Query string `goptions:"-q, --query, mutexgroup='query', description='Query (see https://developers.google.com/drive/search-parameters)'"` +        SharedStatus bool `goptions:"-s, --shared, description='Show shared status (Note: this will generate 1 http req per file)'"` +    } `goptions:"list"` + +    Info struct { +        FileId string `goptions:"-i, --id, obligatory, description='File Id'"` +    } `goptions:"info"` + +    Upload struct { +        File *os.File `goptions:"-f, --file, mutexgroup='input', obligatory, rdonly, description='File to upload'"` +        Stdin bool `goptions:"-s, --stdin, mutexgroup='input', obligatory, description='Use stdin as file content'"` +        Title string `goptions:"-t, --title, description='Title to give uploaded file. Defaults to filename'"` +        Share bool `goptions:"--share, description='Share uploaded file'"` +    } `goptions:"upload"` + +    Download struct { +        FileId string `goptions:"-i, --id, mutexgroup='download', obligatory, description='File Id'"` +        Stdout bool `goptions:"-s, --stdout, description='Write file content to stdout'"` +        Pop bool `goptions:"--pop, mutexgroup='download', description='Download latest file, and remove it from google drive'"` +    } `goptions:"download"` + +    Delete struct { +        FileId string `goptions:"-i, --id, obligatory, description='File Id'"` +    } `goptions:"delete"` + +    Share struct { +        FileId string `goptions:"-i, --id, obligatory, description='File Id'"` +    } `goptions:"share"` + +    Unshare struct { +        FileId string `goptions:"-i, --id, obligatory, description='File Id'"` +    } `goptions:"unshare"` + +    Url struct { +        FileId string `goptions:"-i, --id, obligatory, description='File Id'"` +        Preview bool `goptions:"-p, --preview, mutexgroup='urltype', description='Generate preview url (default)'"` +        Download bool `goptions:"-d, --download, mutexgroup='urltype', description='Generate download url'"` +    } `goptions:"url"` +} + +func main() { +    opts := &Options{} +    goptions.ParseAndFail(opts) + +    // Print version number and exit if the version flag is set +    if opts.Version { +        fmt.Printf("gdrive v%s\n", VersionNumber) +        return +    } + +    // Get authorized drive client +    drive, err := gdrive.New(opts.AppPath, opts.Advanced) +    if err != nil { +        fmt.Printf("An error occurred creating Drive client: %v\n", err) +        return +    } + +    switch opts.Verbs { +        case "list": +            args := opts.List +            cli.List(drive, args.Query, args.TitleFilter, args.MaxResults, args.SharedStatus) + +        case "info": +            cli.Info(drive, opts.Info.FileId) + +        case "upload": +            args := opts.Upload +            if args.Stdin { +                cli.Upload(drive, os.Stdin, args.Title, args.Share) +            } else { +                cli.Upload(drive, args.File, args.Title, args.Share) +            } + +        case "download": +            args := opts.Download +            if args.Pop { +                cli.DownloadLatest(drive, args.Stdout) +            } else { +                cli.Download(drive, args.FileId, args.Stdout, false) +            } + +        case "delete": +            cli.Delete(drive, opts.Delete.FileId) + +        case "share": +            cli.Share(drive, opts.Share.FileId) + +        case "unshare": +            cli.Unshare(drive, opts.Unshare.FileId) + +        case "url": +            if opts.Url.Download { +                fmt.Println(util.DownloadUrl(opts.Url.FileId)) +            } else { +                fmt.Println(util.PreviewUrl(opts.Url.FileId)) +            } + +        default: +            goptions.PrintHelp() +    } +} + + diff --git a/gdrive/gdrive.go b/gdrive/gdrive.go new file mode 100644 index 0000000..547be0d --- /dev/null +++ b/gdrive/gdrive.go @@ -0,0 +1,53 @@ +package gdrive + +import ( +    "path/filepath" +    "net/http" +    "code.google.com/p/google-api-go-client/drive/v2" +    "../util" +    "../config" +    "../auth" +) + +// File paths and names +var ( +    AppPath = filepath.Join(util.Homedir(), ".gdrive") +    ConfigFname = "config.json" +    TokenFname = "token.json" +    //ConfigPath = filepath.Join(ConfigDir, "config.json") +    //TokenPath = filepath.Join(ConfigDir, "token.json") +) + +type Drive struct { +    *drive.Service +    client *http.Client +} + +// Returns the raw http client which has the oauth transport +func (self *Drive) Client() *http.Client { +    return self.client +} + +func New(customAppPath string, advancedMode bool) (*Drive, error) { +    if customAppPath != "" { +        AppPath = customAppPath +    } + +    // Build paths to config files +    configPath := filepath.Join(AppPath, ConfigFname) +    tokenPath := filepath.Join(AppPath, TokenFname) + +    config := config.Load(configPath, advancedMode) +    client, err := auth.GetOauth2Client(config.ClientId, config.ClientSecret, tokenPath) +    if err != nil { +        return nil, err +    } + +    drive, err := drive.New(client) +    if err != nil { +        return nil, err +    } + +    // Return a new authorized Drive client. +    return &Drive{drive, client}, nil +} diff --git a/util/drive.go b/util/drive.go new file mode 100644 index 0000000..06140dd --- /dev/null +++ b/util/drive.go @@ -0,0 +1,15 @@ +package util + +import ( +    "fmt" +) + +func PreviewUrl(id string) string { +    //return fmt.Sprintf("https://drive.google.com/uc?id=%s&export=preview", id) +    return fmt.Sprintf("https://drive.google.com/uc?id=%s", id) +} + +// Note to self: file.WebContentLink = https://docs.google.com/uc?id=<id>&export=download +func DownloadUrl(id string) string { +    return fmt.Sprintf("https://drive.google.com/uc?id=%s&export=download", id) +} diff --git a/util/generic.go b/util/generic.go new file mode 100644 index 0000000..fec2c31 --- /dev/null +++ b/util/generic.go @@ -0,0 +1,196 @@ +package util + +import ( +    "fmt" +    "os" +    "time" +    "strings" +    "strconv" +    "unicode/utf8" +    "path/filepath" +    "runtime" +) + +// Prompt user to input data +func Prompt(msg string) string { +    fmt.Printf(msg) +    var str string +    fmt.Scanln(&str) +    return str +} + +// Returns true if file/directory exists +func FileExists(path string) bool { +    _, err := os.Stat(path) +    if err == nil { +        return true +    } +    return false +} + +func Mkdir(path string) error { +    dir := filepath.Dir(path) +    if FileExists(dir) { +        return nil +    } +    return os.Mkdir(dir, 0700) +} + +// Returns the users home dir +func Homedir() string { +    if runtime.GOOS == "windows" { +        return os.Getenv("APPDATA") +    } +    return os.Getenv("HOME") +} + +func FormatBool(b bool) string { +    return strings.Title(strconv.FormatBool(b)) +} + +func FileSizeFormat(bytes int64) string { +    units := []string{"B", "KB", "MB", "GB", "TB", "PB"} + +    var i int +    value := bytes + +    for value > 1000 { +        value /= 1000 +        i++ +    } +    return fmt.Sprintf("%d %s", value, units[i]) +} + +// Truncates string to given max length, and inserts ellipsis into +// the middle of the string to signify that the string has been truncated +func TruncateString(str string, maxRunes int) string { +    indicator := "..." + +    // Number of runes in string +    runeCount := utf8.RuneCountInString(str) + +    // Return input string if length of input string is less than max length +    // Input string is also returned if max length is less than 9 which is the minmal supported length +    if runeCount <= maxRunes || maxRunes < 9 { +        return str +    } +     +    // Number of remaining runes to be removed +    remaining := (runeCount - maxRunes) + utf8.RuneCountInString(indicator) +     +    var truncated string +    var skip bool +     +    for leftOffset, char := range str { +        rightOffset := runeCount - (leftOffset + remaining) + +        // Start skipping chars when the left and right offsets are equal +        // Or in the case where we wont be able to do an even split: when the left offset is larger than the right offset +        if leftOffset == rightOffset || (leftOffset > rightOffset && !skip) { +            skip = true +            truncated += indicator +        } + +        if skip && remaining > 0 { +            // Skip char and decrement the remaining skip counter +            remaining-- +            continue +        } +         +        // Add char to result string +        truncated += string(char) +    } +     +    // Return truncated string +    return truncated +} + +func ISODateToLocal(iso string) string { +    t, err := time.Parse(time.RFC3339, iso) +    if err != nil { +        return iso +    } +    local := t.Local() +    year, month, day := local.Date() +    hour, min, sec := local.Clock() +    return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, min, sec) +} + +func MeasureTransferRate() func(int64)string { +    start := time.Now() + +    return func(bytes int64) string { +        seconds := int64(time.Now().Sub(start).Seconds()) +        if seconds < 1 { +            return fmt.Sprintf("%s/s", FileSizeFormat(bytes)) +        } +        bps := bytes / seconds +        return fmt.Sprintf("%s/s", FileSizeFormat(bps)) +    } +} + +// Prints a map in the provided order with one key-value-pair per line +func Print(m map[string]string, keyOrder []string) { +    for _, key := range keyOrder { +        value, ok := m[key] +        if ok && value != "" { +            fmt.Printf("%s: %s\n", key, value) +        } +    } +} + +// Prints items in columns with header and correct padding +func PrintColumns(items []map[string]string, keyOrder []string, columnSpacing int) { +    // Create header +    header := make(map[string]string) +    for _, key := range keyOrder { +        header[key] = key +    } + +    // Add header as the first element of items +    items = append([]map[string]string{header}, items...) + +    // Get a padding function for each column +    padFns := make(map[string]func(string)string) +    for _, key := range keyOrder { +        padFns[key] = columnPadder(items, key, columnSpacing) +    } +     +    // Loop, pad and print items +    for _, item := range items { +        var line string + +        // Add each column to line with correct padding +        for _, key := range keyOrder { +            value, _ := item[key] +            line += padFns[key](value) +        } + +        // Print line +        fmt.Println(line) +    } +} + +// Returns a padding function, that pads input to the longest string in items +func columnPadder(items []map[string]string, key string, spacing int) func(string)string { +    // Holds length of longest string +    var max int +     +    // Find the longest string of type key in the array +    for _, item := range items { +        str := item[key] +        length := utf8.RuneCountInString(str) +        if length > max { +            max = length +        } +    } + +    // Return padding function +    return func(str string) string { +        column := str +        for utf8.RuneCountInString(column) < max + spacing { +            column += " " +        } +        return column +    } +} | 
