From 2f4fabf7bb74b42a7aa54c5b02538e4ca337361f Mon Sep 17 00:00:00 2001 From: Petter Rasmussen Date: Tue, 1 Jan 2013 20:48:15 +0100 Subject: [PATCH] version 1 --- .gitignore | 9 ++ LICENSE | 22 ++++ auth/auth.go | 68 +++++++++++ build-all.sh | 38 ++++++ cli/cli.go | 307 +++++++++++++++++++++++++++++++++++++++++++++++ config/config.go | 74 ++++++++++++ drive.go | 129 ++++++++++++++++++++ gdrive/gdrive.go | 53 ++++++++ util/drive.go | 15 +++ util/generic.go | 196 ++++++++++++++++++++++++++++++ 10 files changed, 911 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 auth/auth.go create mode 100755 build-all.sh create mode 100644 cli/cli.go create mode 100644 config/config.go create mode 100644 drive.go create mode 100644 gdrive/gdrive.go create mode 100644 util/drive.go create mode 100644 util/generic.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..769fd25c --- /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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..4f2fadb5 --- /dev/null +++ b/LICENSE @@ -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 00000000..c4a67e3a --- /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 00000000..5170bddf --- /dev/null +++ b/build-all.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Usage: $0 " + 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 00000000..d78c8385 --- /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 00000000..33451e2c --- /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 00000000..33d64b37 --- /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 00000000..547be0db --- /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 00000000..06140dd7 --- /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=&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 00000000..fec2c31d --- /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 + } +}