# HG changeset patch # User Mikael Berthe # Date 1492621727 -7200 # Node ID 5abace72458460409da3621b8f2a91e6d5f01dd6 Initial public release v0.0.9 diff -r 000000000000 -r 5abace724584 .gitignore --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.gitignore Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,9 @@ +# Binaries +madonctl +*.exe +*.test + +*.swp + +.goxc* +doc diff -r 000000000000 -r 5abace724584 LICENSE --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/LICENSE Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Mikael Berthe + +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 -r 000000000000 -r 5abace724584 README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README.md Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,28 @@ +# madonctl + +Golang command line interface for the Mastodon API + +[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE) + +`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API. + +## Installation + +To install the application (you need to have Go): + + go get github.com/McKael/madonctl + +Some pre-built binaries are available on the home page (see below). + +## Usage + +This section has not been written yet. + +Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an +introduction and a few examples. + +## References + +- [madon](https://github.com/McKael/madon), the Go library for Mastodon API +- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) +- [Mastodon repository](https://github.com/tootsuite/mastodon) diff -r 000000000000 -r 5abace724584 cmd/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/README.md Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,28 @@ +# madonctl + +Golang command line interface for the Mastodon API + +[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE) + +`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API. + +## Installation + +To install the application (you need to have Go): + + go get github.com/McKael/madonctl + +Some pre-built binaries are available on the home page (see below). + +## Usage + +This section has not been written yet. + +Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an +introduction and a few examples. + +## References + +- [madon](https://github.com/McKael/madon), the Go library for Mastodon API +- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) +- [Mastodon repository](https://github.com/tootsuite/mastodon) diff -r 000000000000 -r 5abace724584 cmd/accounts.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/accounts.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,391 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" + + "github.com/McKael/madon" +) + +var accountsOpts struct { + accountID int + unset bool // TODO remove eventually? + limit int + onlyMedia, excludeReplies bool // For acccount statuses + remoteUID string // For account follow + acceptFR, rejectFR bool // For account follow_requests + list bool // For account follow_requests/reports + accountIDs string // For account relationships + statusIDs string // For account reports + comment string // For account reports + show bool // For account reports +} + +func init() { + RootCmd.AddCommand(accountsCmd) + + // Subcommands + accountsCmd.AddCommand(accountSubcommands...) + + // Global flags + accountsCmd.PersistentFlags().IntVarP(&accountsOpts.accountID, "account-id", "a", 0, "Account ID number") + //accountsCmd.PersistentFlags().IntVarP(&accountsOpts.limit, "limit", "l", 0, "Limit number of results") + + // Subcommand flags + accountStatusesSubcommand.Flags().BoolVar(&accountsOpts.onlyMedia, "only-media", false, "Only statuses with media attachments") + accountStatusesSubcommand.Flags().BoolVar(&accountsOpts.excludeReplies, "exclude-replies", false, "Exclude replies to other statuses") + + accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.list, "list", false, "List pending follow requests") + accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.acceptFR, "accept", false, "Accept the follow request from the account ID") + accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.rejectFR, "reject", false, "Reject the follow request from the account ID") + + accountBlockSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unblock the account") + accountMuteSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unmute the account") + accountFollowSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unfollow the account") + accountFollowSubcommand.Flags().StringVarP(&accountsOpts.remoteUID, "remote", "r", "", "Follow remote account (user@domain)") + + accountRelationshipsSubcommand.Flags().StringVar(&accountsOpts.accountIDs, "account-ids", "", "Comma-separated list of account IDs") + + accountReportsSubcommand.Flags().StringVar(&accountsOpts.statusIDs, "status-ids", "", "Comma-separated list of status IDs") + accountReportsSubcommand.Flags().StringVar(&accountsOpts.comment, "comment", "", "Report comment") + accountReportsSubcommand.Flags().BoolVar(&accountsOpts.list, "list", false, "List current user reports") +} + +// accountsCmd represents the accounts command +// This command does nothing without a subcommand +var accountsCmd = &cobra.Command{ + Use: "accounts [--account-id ID] subcommand", + Aliases: []string{"account"}, + Short: "Account-related functions", + //Long: `TBW...`, // TODO + //PersistentPreRunE: func(cmd *cobra.Command, args []string) error {}, +} + +// Note: Some account subcommands are not defined in this file. +var accountSubcommands = []*cobra.Command{ + &cobra.Command{ + Use: "show", + Long: `Displays the details about the requested account. +If no account ID is specified, the current user account is used.`, + Aliases: []string{"display"}, + Short: "Display the account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "followers", + Short: "Display the accounts following the specified account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "following", + Short: "Display the accounts followed by the specified account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "favourites", + Aliases: []string{"favorites", "favourited", "favorited"}, + Short: "Display the user's favourites", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "blocks", + Aliases: []string{"blocked"}, + Short: "Display the user's blocked accounts", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "mutes", + Aliases: []string{"muted"}, + Short: "Display the user's muted accounts", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "search TEXT", + Short: "Search for user accounts", + Long: `Search for user accounts. +The server will lookup an account remotely if the search term is in the +username@domain format and not yet in the database.`, + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, + }, + accountStatusesSubcommand, + accountFollowRequestsSubcommand, + accountFollowSubcommand, + accountBlockSubcommand, + accountMuteSubcommand, + accountRelationshipsSubcommand, + accountReportsSubcommand, +} + +var accountStatusesSubcommand = &cobra.Command{ + Use: "statuses", + Aliases: []string{"st"}, + Short: "Display the account statuses", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE(cmd.Name(), args) + }, +} + +var accountFollowRequestsSubcommand = &cobra.Command{ + Use: "follow-requests", + Aliases: []string{"follow-request", "fr"}, + Example: ` madonctl accounts follow-requests --list + madonctl accounts follow-requests --account-id X --accept + madonctl accounts follow-requests --account-id Y --reject`, + Short: "List, accept or deny a follow request", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("follow-requests", args) + }, +} +var accountFollowSubcommand = &cobra.Command{ + Use: "follow", + Short: "Follow or unfollow the account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("follow", args) + }, +} + +var accountBlockSubcommand = &cobra.Command{ + Use: "block", + Short: "Block or unblock the account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("block", args) + }, +} + +var accountMuteSubcommand = &cobra.Command{ + Use: "mute", + Short: "Mute or unmute the account", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("mute", args) + }, +} + +var accountRelationshipsSubcommand = &cobra.Command{ + Use: "relationships --account-ids ACC1,ACC2...", + Short: "List relationships with the accounts", + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("relationships", args) + }, +} + +var accountReportsSubcommand = &cobra.Command{ + Use: "reports", + Short: "List reports or report a user account", + Example: ` madonctl accounts reports --list + madonctl accounts reports --account-id ACCOUNT --status-ids ID... --comment TEXT`, + RunE: func(cmd *cobra.Command, args []string) error { + return accountSubcommandsRunE("reports", args) + }, +} + +// accountSubcommandsRunE is a generic function for status subcommands +func accountSubcommandsRunE(subcmd string, args []string) error { + opt := accountsOpts + + switch subcmd { + case "show", "search": + // These subcommands do not require an account ID + case "favourites", "blocks", "mutes": + // Those subcommands can not use an account ID + if opt.accountID > 0 { + return errors.New("useless account ID") + } + case "follow": + if opt.accountID < 1 && opt.remoteUID == "" { + return errors.New("missing account ID or URI") + } + if opt.accountID > 0 && opt.remoteUID != "" { + return errors.New("cannot use both account ID and URI") + } + if opt.unset && opt.accountID < 1 { + return errors.New("unfollowing requires an account ID") + } + case "follow-requests": + if opt.list { + if opt.acceptFR || opt.rejectFR { + return errors.New("incompatible options") + } + } else { + if !opt.acceptFR && !opt.rejectFR { // No flag + return errors.New("missing parameter (--list, --accept or --reject)") + } + // This is a FR reply + if opt.acceptFR && opt.rejectFR { + return errors.New("incompatible options") + } + if opt.accountID < 1 { + return errors.New("missing account ID") + } + } + case "relationships": + if opt.accountID < 1 && len(opt.accountIDs) == 0 { + return errors.New("missing account IDs") + } + if opt.accountID > 0 && len(opt.accountIDs) > 0 { + return errors.New("incompatible options") + } + case "reports": + if opt.list { + break // No argument needed + } + if opt.accountID < 1 || len(opt.statusIDs) == 0 || opt.comment == "" { + return errors.New("missing parameter") + } + default: + // The other subcommands here require an account ID + if opt.accountID < 1 { + return errors.New("missing account ID") + } + } + + // All account subcommands need to have signed in + if err := madonInit(true); err != nil { + return err + } + + var obj interface{} + var err error + + switch subcmd { + case "show": + var account *madon.Account + if opt.accountID > 0 { + account, err = gClient.GetAccount(opt.accountID) + } else { + account, err = gClient.GetCurrentAccount() + } + obj = account + case "search": + var accountList []madon.Account + limit := 0 // TODO use a global flag + accountList, err = gClient.SearchAccounts(strings.Join(args, " "), limit) + obj = accountList + case "followers": + var accountList []madon.Account + accountList, err = gClient.GetAccountFollowers(opt.accountID) + obj = accountList + case "following": + var accountList []madon.Account + accountList, err = gClient.GetAccountFollowing(opt.accountID) + obj = accountList + case "statuses": + var statusList []madon.Status + statusList, err = gClient.GetAccountStatuses(opt.accountID, opt.onlyMedia, opt.excludeReplies) + obj = statusList + case "follow": + if opt.unset { + err = gClient.UnfollowAccount(opt.accountID) + } else { + if opt.accountID > 0 { + err = gClient.FollowAccount(opt.accountID) + } else { + var account *madon.Account + account, err = gClient.FollowRemoteAccount(opt.remoteUID) + obj = account + } + } + case "follow-requests": + if opt.list { + var followRequests []madon.Account + followRequests, err = gClient.GetAccountFollowRequests() + obj = followRequests + } else { + err = gClient.FollowRequestAuthorize(opt.accountID, !opt.rejectFR) + } + case "block": + if opt.unset { + err = gClient.UnblockAccount(opt.accountID) + } else { + err = gClient.BlockAccount(opt.accountID) + } + case "mute": + if opt.unset { + err = gClient.UnmuteAccount(opt.accountID) + } else { + err = gClient.MuteAccount(opt.accountID) + } + case "favourites": + var statusList []madon.Status + statusList, err = gClient.GetFavourites() + obj = statusList + case "blocks": + var accountList []madon.Account + accountList, err = gClient.GetBlockedAccounts() + obj = accountList + case "mutes": + var accountList []madon.Account + accountList, err = gClient.GetMutedAccounts() + obj = accountList + case "relationships": + var ids []int + ids, err = splitIDs(opt.accountIDs) + if err != nil { + return errors.New("cannot parse account IDs") + } + if opt.accountID > 0 { // Allow --account-id + ids = []int{opt.accountID} + } + if len(ids) < 1 { + return errors.New("missing account IDs") + } + var relationships []madon.Relationship + relationships, err = gClient.GetAccountRelationships(ids) + obj = relationships + case "reports": + if opt.list { + var reports []madon.Report + reports, err = gClient.GetReports() + obj = reports + break + } + // Send a report + var ids []int + ids, err = splitIDs(opt.statusIDs) + if err != nil { + return errors.New("cannot parse status IDs") + } + if len(ids) < 1 { + return errors.New("missing status IDs") + } + var report *madon.Report + report, err = gClient.ReportUser(opt.accountID, ids, opt.comment) + obj = report + default: + return errors.New("accountSubcommand: internal error") + } + + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + if obj == nil { + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(obj, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/completion.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/completion.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,232 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion bash|zsh", + Short: "Generate shell completion", + ValidArgs: []string{"bash", "zsh"}, + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + errPrint("Please specify your shell") + os.Exit(1) + } + + switch args[0] { + case "bash": + if err := runCompletionBash(os.Stdout, RootCmd); err != nil { + errPrint("Error: %s", err.Error()) + os.Exit(1) + } + case "zsh": + if err := runCompletionZsh(os.Stdout, RootCmd); err != nil { + errPrint("Error: %s", err.Error()) + os.Exit(1) + } + default: + errPrint("Only bash is supported at the moment") + os.Exit(1) + } + }, +} + +func init() { + RootCmd.AddCommand(completionCmd) +} + +func runCompletionBash(out io.Writer, c *cobra.Command) error { + return c.GenBashCompletion(out) +} + +// Many thanks to the Kubernetes project for this one! +func runCompletionZsh(out io.Writer, c *cobra.Command) error { + const zshInitialization = `# Copyright 2016 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__madonctl_bash_source() { + alias shopt=':' + alias _expand=_bash_expand + alias _complete=_bash_comp + emulate -L sh + setopt kshglob noshglob braceexpand + + source "$@" +} + +__madonctl_type() { + # -t is not supported by zsh + if [ "$1" == "-t" ]; then + shift + + # fake Bash 4 to disable "complete -o nospace". Instead + # "compopt +-o nospace" is used in the code to toggle trailing + # spaces. We don't support that, but leave trailing spaces on + # all the time + if [ "$1" = "__madonctl_compopt" ]; then + echo builtin + return 0 + fi + fi + type "$@" +} + +__madonctl_compgen() { + local completions w + completions=( $(compgen "$@") ) || return $? + + # filter by given word as prefix + while [[ "$1" = -* && "$1" != -- ]]; do + shift + shift + done + if [[ "$1" == -- ]]; then + shift + fi + for w in "${completions[@]}"; do + if [[ "${w}" = "$1"* ]]; then + echo "${w}" + fi + done +} + +__madonctl_compopt() { + true # don't do anything. Not supported by bashcompinit in zsh +} + +__madonctl_declare() { + if [ "$1" == "-F" ]; then + whence -w "$@" + else + builtin declare "$@" + fi +} + +__madonctl_ltrim_colon_completions() +{ + if [[ "$1" == *:* && "$COMP_WORDBREAKS" == *:* ]]; then + # Remove colon-word prefix from COMPREPLY items + local colon_word=${1%${1##*:}} + local i=${#COMPREPLY[*]} + while [[ $((--i)) -ge 0 ]]; do + COMPREPLY[$i]=${COMPREPLY[$i]#"$colon_word"} + done + fi +} + +__madonctl_get_comp_words_by_ref() { + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[${COMP_CWORD}-1]}" + words=("${COMP_WORDS[@]}") + cword=("${COMP_CWORD[@]}") +} + +__madonctl_filedir() { + local RET OLD_IFS w qw + + __debug "_filedir $@ cur=$cur" + if [[ "$1" = \~* ]]; then + # somehow does not work. Maybe, zsh does not call this at all + eval echo "$1" + return 0 + fi + + OLD_IFS="$IFS" + IFS=$'\n' + if [ "$1" = "-d" ]; then + shift + RET=( $(compgen -d) ) + else + RET=( $(compgen -f) ) + fi + IFS="$OLD_IFS" + + IFS="," __debug "RET=${RET[@]} len=${#RET[@]}" + + for w in ${RET[@]}; do + if [[ ! "${w}" = "${cur}"* ]]; then + continue + fi + if eval "[[ \"\${w}\" = *.$1 || -d \"\${w}\" ]]"; then + qw="$(__madonctl_quote "${w}")" + if [ -d "${w}" ]; then + COMPREPLY+=("${qw}/") + else + COMPREPLY+=("${qw}") + fi + fi + done +} + +__madonctl_quote() { + if [[ $1 == \'* || $1 == \"* ]]; then + # Leave out first character + printf %q "${1:1}" + else + printf %q "$1" + fi +} + +autoload -U +X bashcompinit && bashcompinit + +# use word boundary patterns for BSD or GNU sed +LWORD='[[:<:]]' +RWORD='[[:>:]]' +if sed --help 2>&1 | grep -q GNU; then + LWORD='\<' + RWORD='\>' +fi + +__madonctl_convert_bash_to_zsh() { + sed \ + -e 's/declare -F/whence -w/' \ + -e 's/local \([a-zA-Z0-9_]*\)=/local \1; \1=/' \ + -e 's/flags+=("\(--.*\)=")/flags+=("\1"); two_word_flags+=("\1")/' \ + -e 's/must_have_one_flag+=("\(--.*\)=")/must_have_one_flag+=("\1")/' \ + -e "s/${LWORD}_filedir${RWORD}/__madonctl_filedir/g" \ + -e "s/${LWORD}_get_comp_words_by_ref${RWORD}/__madonctl_get_comp_words_by_ref/g" \ + -e "s/${LWORD}__ltrim_colon_completions${RWORD}/__madonctl_ltrim_colon_completions/g" \ + -e "s/${LWORD}compgen${RWORD}/__madonctl_compgen/g" \ + -e "s/${LWORD}compopt${RWORD}/__madonctl_compopt/g" \ + -e "s/${LWORD}declare${RWORD}/__madonctl_declare/g" \ + -e "s/\\\$(type${RWORD}/\$(__madonctl_type/g" \ + <<'BASH_COMPLETION_EOF' +` + + const zshTail = ` +BASH_COMPLETION_EOF +} + +__madonctl_bash_source <(__madonctl_convert_bash_to_zsh) +` + + buf := new(bytes.Buffer) + out.Write([]byte(zshInitialization)) + err := c.GenBashCompletion(buf) + script := strings.Replace(buf.String(), "flaghash[${flagname}]", `flaghash[workaround]`, -1) + out.Write([]byte(script)) + out.Write([]byte(zshTail)) + return err +} diff -r 000000000000 -r 5abace724584 cmd/config.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/config.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,106 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/McKael/madonctl/printer" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Display configuration", +} + +func init() { + RootCmd.AddCommand(configCmd) + + // Subcommands + configCmd.AddCommand(configSubcommands...) +} + +var configSubcommands = []*cobra.Command{ + &cobra.Command{ + Use: "dump", + Short: "Dump the configuration", + RunE: func(cmd *cobra.Command, args []string) error { + return configDump() + }, + }, + &cobra.Command{ + Use: "whoami", + Aliases: []string{"token"}, + Short: "Display user token", + RunE: func(cmd *cobra.Command, args []string) error { + return configDisplayToken() + }, + }, +} + +const configurationTemplate = `--- +instance: '{{.InstanceURL}}' +app_id: '{{.ID}}' +app_secret: '{{.Secret}}' + +{{if .UserToken}}token: {{.UserToken.access_token}}{{else}}#token: ''{{end}} +#login: '' +#password: '' +safe_mode: true +... +` + +func configDump() error { + if viper.GetBool("safe_mode") { + errPrint("Cannot dump: disabled by configuration (safe_mode)") + return nil + } + + if err := madonInitClient(); err != nil { + return err + } + // Try to sign in, but don't mind if it fails + if err := madonLogin(); err != nil { + errPrint("Info: could not log in: %s", err) + } + + var p printer.ResourcePrinter + var err error + + if getOutputFormat() == "plain" { + cfile := viper.ConfigFileUsed() + if cfile == "" { + cfile = defaultConfigFile + } + errPrint("You can copy the following lines into a configuration file.") + errPrint("E.g. %s -i INSTANCE -L USERNAME -P PASS config dump > %s\n", AppName, cfile) + p, err = printer.NewPrinterTemplate(configurationTemplate) + } else { + p, err = getPrinter() + } + if err != nil { + return err + } + return p.PrintObj(gClient, nil, "") +} + +func configDisplayToken() error { + if viper.GetBool("safe_mode") { + errPrint("Cannot dump: disabled by configuration (safe_mode)") + return nil + } + + if err := madonInit(true); err != nil { + return err + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(gClient.UserToken, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/instance.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/instance.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,39 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// timelinesCmd represents the timelines command +var instanceCmd = &cobra.Command{ + Use: "instance", + Short: "Display current instance information", + RunE: instanceRunE, +} + +func init() { + RootCmd.AddCommand(instanceCmd) +} + +func instanceRunE(cmd *cobra.Command, args []string) error { + if err := madonInit(false); err != nil { + return err + } + + i, err := gClient.GetCurrentInstance() + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(i, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/madon.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/madon.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,115 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/McKael/madon" + "github.com/spf13/viper" +) + +var scopes = []string{"read", "write", "follow"} + +func madonInit(signIn bool) error { + if gClient == nil { + if err := madonInitClient(); err != nil { + return err + } + } + if signIn { + return madonLogin() + } + return nil +} + +func madonInitClient() error { + if gClient != nil { + return nil + } + var err error + + // Overwrite variables using Viper + instanceURL = viper.GetString("instance") + appID = viper.GetString("app_id") + appSecret = viper.GetString("app_secret") + + if instanceURL == "" { + return errors.New("no instance provided") + } + + if appID != "" && appSecret != "" { + // We already have an app key/secret pair + gClient, err = madon.RestoreApp(AppName, instanceURL, appID, appSecret, nil) + if err != nil { + return err + } + // Check instance + if _, err := gClient.GetCurrentInstance(); err != nil { + return fmt.Errorf("could not use provided app secrets") + } + if verbose { + errPrint("Using provided app secrets") + } + return nil + } + + if appID != "" || appSecret != "" { + errPrint("Warning: provided app id/secrets incomplete -- registering again") + } + + gClient, err = madon.NewApp(AppName, scopes, madon.NoRedirect, instanceURL) + if err != nil { + return fmt.Errorf("app registration failed: %s", err.Error()) + } + + errPrint("Registred new application.") + return nil +} + +func madonLogin() error { + if gClient == nil { + return errors.New("application not registred") + } + + token = viper.GetString("token") + login = viper.GetString("login") + password = viper.GetString("password") + + if token != "" { // TODO check token validity? + if verbose { + errPrint("Reusing existing token.") + } + gClient.SetUserToken(token, login, password, []string{}) + return nil + } + + err := gClient.LoginBasic(login, password, scopes) + if err == nil { + return nil + } + return fmt.Errorf("login failed: %s", err.Error()) +} + +// splitIDs splits a list of IDs into an int array +func splitIDs(ids string) (list []int, err error) { + var i int + if ids == "" { + return + } + l := strings.Split(ids, ",") + for _, s := range l { + i, err = strconv.Atoi(s) + if err != nil { + return + } + list = append(list, i) + } + return +} diff -r 000000000000 -r 5abace724584 cmd/media.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/media.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,68 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" +) + +var mediaOpts struct { + filePath string +} + +// mediaCmd represents the media command +var mediaCmd = &cobra.Command{ + Use: "media --file FILENAME", + Aliases: []string{"upload"}, + Short: "Upload a media attachment", + //Long: `TBW...`, + RunE: mediaRunE, +} + +func init() { + RootCmd.AddCommand(mediaCmd) + + mediaCmd.Flags().StringVar(&mediaOpts.filePath, "file", "", "Path of the media file") + mediaCmd.MarkFlagRequired("file") +} + +func mediaRunE(cmd *cobra.Command, args []string) error { + opt := mediaOpts + + if opt.filePath == "" { + return errors.New("no media file name provided") + } + + if err := madonInit(true); err != nil { + return err + } + + attachment, err := gClient.UploadMedia(opt.filePath) + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(attachment, nil, "") +} + +// uploadFile uploads a media file and returns the attachment ID +func uploadFile(filePath string) (int, error) { + attachment, err := gClient.UploadMedia(filePath) + if err != nil { + return 0, err + } + if attachment == nil { + return 0, nil + } + return attachment.ID, nil +} diff -r 000000000000 -r 5abace724584 cmd/notifications.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/notifications.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,76 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" +) + +var notificationsOpts struct { + list, clear bool + notifID int +} + +// notificationsCmd represents the notifications subcommand +var notificationsCmd = &cobra.Command{ + Use: "notifications", // XXX + Aliases: []string{"notification", "notif"}, + Short: "Manage notifications", + Example: ` madonctl accounts notifications --list + madonctl accounts notifications --list --clear + madonctl accounts notifications --notification-id N`, + //Long: `TBW...`, + RunE: notificationRunE, +} + +func init() { + accountsCmd.AddCommand(notificationsCmd) + + notificationsCmd.Flags().BoolVar(¬ificationsOpts.list, "list", false, "List all current notifications") + notificationsCmd.Flags().BoolVar(¬ificationsOpts.clear, "clear", false, "Clear all current notifications") + notificationsCmd.Flags().IntVar(¬ificationsOpts.notifID, "notification-id", 0, "Get a notification") +} + +func notificationRunE(cmd *cobra.Command, args []string) error { + opt := notificationsOpts + + if !opt.list && !opt.clear && opt.notifID < 1 { + return errors.New("missing parameters") + } + + if err := madonInit(true); err != nil { + return err + } + + var obj interface{} + var err error + + if opt.list { + obj, err = gClient.GetNotifications() + } else if opt.notifID > 0 { + obj, err = gClient.GetNotification(opt.notifID) + } + + if err == nil && opt.clear { + err = gClient.ClearNotifications() + } + + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + if obj == nil { + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(obj, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/root.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/root.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,190 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/McKael/madon" + "github.com/McKael/madonctl/printer" +) + +// AppName is the CLI application name +const AppName = "madonctl" +const defaultConfigFile = "$HOME/.config/" + AppName + "/" + AppName + ".yaml" + +var cfgFile string +var safeMode bool +var instanceURL, appID, appSecret string +var login, password, token string +var gClient *madon.Client +var verbose bool +var outputFormat string +var outputTemplate, outputTemplateFile string + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: AppName, + Short: "A CLI utility for Mastodon API", + PersistentPreRunE: checkOutputFormat, + Long: `madonctl is a CLI tool for the Mastodon REST API. + +You can use a configuration file to store common options. +For example, create ` + defaultConfigFile + ` with the following +contents: + + --- + instance: "INSTANCE" + login: "USERNAME" + password: "USERPASSWORD" + ... + +The simplest way to generate a configuration file is to use the 'config dump' +command. + +(Configuration files in JSON are also accepted.) + +If you want shell auto-completion (for bash or zsh), you can generate the +completion scripts with "madonctl completion $SHELL". +For example if you use bash: + + madonctl completion bash > _bash_madonctl + source _bash_madonctl + +Now you should have tab completion for subcommands and flags. + +Note: Most examples assume the user's credentials are set in the configuration +file. +`, + Example: ` madonctl instance + madonctl toot "Hello, World" + madonctl toot --visibility direct "@McKael Hello, You" + madonctl toot --visibility private --spoiler CW "The answer was 42" + madonctl post --file image.jpg Selfie + madonctl --instance INSTANCE --login USERNAME --password PASS timeline + madonctl accounts notifications --list --clear + madonctl accounts blocked + madonctl accounts search Gargron + madonctl search --resolve https://mastodon.social/@Gargron + madonctl accounts follow --remote Gargron@mastodon.social + madonctl accounts --account-id 399 statuses + madonctl status --status-id 416671 show + madonctl status --status-id 416671 favourite + madonctl status --status-id 416671 boost + madonctl accounts show + madonctl accounts show -o yaml + madonctl accounts --account-id 1 followers --template '{{.acct}}{{"\n"}}' + madonctl config whoami + madonctl timeline :mastodon`, +} + +// Execute adds all child commands to the root command sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + errPrint("Error: %s", err.Error()) + os.Exit(-1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", + "config file (default is "+defaultConfigFile+")") + RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose mode") + RootCmd.PersistentFlags().StringVarP(&instanceURL, "instance", "i", "", "Mastodon instance") + RootCmd.PersistentFlags().StringVarP(&login, "login", "L", "", "Instance user login") + RootCmd.PersistentFlags().StringVarP(&password, "password", "P", "", "Instance user password") + RootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "User token") + RootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "plain", + "Output format (plain|json|yaml|template)") + RootCmd.PersistentFlags().StringVar(&outputTemplate, "template", "", + "Go template (for output=template)") + RootCmd.PersistentFlags().StringVar(&outputTemplateFile, "template-file", "", + "Go template file (for output=template)") + + // Configuration file bindings + viper.BindPFlag("output", RootCmd.PersistentFlags().Lookup("output")) + viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose")) + // XXX viper.BindPFlag("apiKey", RootCmd.PersistentFlags().Lookup("api-key")) + viper.BindPFlag("instance", RootCmd.PersistentFlags().Lookup("instance")) + viper.BindPFlag("login", RootCmd.PersistentFlags().Lookup("login")) + viper.BindPFlag("password", RootCmd.PersistentFlags().Lookup("password")) + viper.BindPFlag("token", RootCmd.PersistentFlags().Lookup("token")) +} + +func checkOutputFormat(cmd *cobra.Command, args []string) error { + of := viper.GetString("output") + switch of { + case "", "plain", "json", "yaml", "template": + return nil // Accepted + } + return fmt.Errorf("output format '%s' not supported", of) +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { // enable ability to specify config file via flag + viper.SetConfigFile(cfgFile) + } + + viper.SetConfigName(AppName) // name of config file (without extension) + viper.AddConfigPath("$HOME/.config/" + AppName) + viper.AddConfigPath("$HOME/." + AppName) + + // Read in environment variables that match, with a prefix + viper.SetEnvPrefix(AppName) + viper.AutomaticEnv() + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); viper.GetBool("verbose") && err == nil { + errPrint("Using config file: %s", viper.ConfigFileUsed()) + } +} + +// getOutputFormat return the requested output format, defaulting to "plain". +func getOutputFormat() string { + of := viper.GetString("output") + if of == "" { + of = "plain" + } + // Override format if a template is provided + if of == "plain" && (outputTemplate != "" || outputTemplateFile != "") { + // If the format is plain and there is a template option, + // set the format to "template". + of = "template" + } + return of +} + +// getPrinter returns a resource printer for the requested output format. +func getPrinter() (printer.ResourcePrinter, error) { + var opt string + of := getOutputFormat() + + if of == "template" { + opt = outputTemplate + if outputTemplateFile != "" { + tmpl, err := ioutil.ReadFile(outputTemplateFile) + if err != nil { + return nil, err + } + opt = string(tmpl) + } + } + return printer.NewPrinter(of, opt) +} + +func errPrint(format string, a ...interface{}) (n int, err error) { + return fmt.Fprintf(os.Stderr, format+"\n", a...) +} diff -r 000000000000 -r 5abace724584 cmd/search.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/search.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,55 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" +) + +var searchOpts struct { + resolve bool +} + +// searchCmd represents the search command +var searchCmd = &cobra.Command{ + Use: "search [--resolve] STRING", + Short: "Search for contents (accounts or statuses)", + //Long: `TBW...`, + RunE: searchRunE, +} + +func init() { + RootCmd.AddCommand(searchCmd) + + searchCmd.Flags().BoolVar(&searchOpts.resolve, "resolve", false, "Resolve non-local accounts") +} + +func searchRunE(cmd *cobra.Command, args []string) error { + opt := searchOpts + + if len(args) == 0 { + return errors.New("no search string provided") + } + + if err := madonInit(true); err != nil { + return err + } + + results, err := gClient.Search(strings.Join(args, " "), opt.resolve) + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(results, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/status.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/status.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,218 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" + + "github.com/McKael/madon" +) + +var statusOpts struct { + statusID int + unset bool + + // The following fields are used for the post/toot command + visibility string + sensitive bool + spoiler string + inReplyToID int + filePath string + mediaIDs string + + // TODO + limit int +} + +func init() { + RootCmd.AddCommand(statusCmd) + + // Subcommands + statusCmd.AddCommand(statusSubcommands...) + + // Global flags + statusCmd.PersistentFlags().IntVarP(&statusOpts.statusID, "status-id", "s", 0, "Status ID number") + //statusCmd.PersistentFlags().IntVarP(&statusOpts.limit, "limit", "l", 0, "Limit number of results") + + statusCmd.MarkPersistentFlagRequired("status-id") + + // Subcommand flags + statusReblogSubcommand.Flags().BoolVar(&statusOpts.unset, "unset", false, "Unreblog the status") + statusFavouriteSubcommand.Flags().BoolVar(&statusOpts.unset, "unset", false, "Remove the status from the favourites") + statusPostSubcommand.Flags().BoolVar(&statusOpts.sensitive, "sensitive", false, "Mark post as sensitive (NSFW)") + statusPostSubcommand.Flags().StringVar(&statusOpts.visibility, "visibility", "", "Visibility (direct|private|unlisted|public)") + statusPostSubcommand.Flags().StringVar(&statusOpts.spoiler, "spoiler", "", "Spoiler warning (CW)") + statusPostSubcommand.Flags().StringVar(&statusOpts.mediaIDs, "media-ids", "", "Comma-separated list of media IDs") + statusPostSubcommand.Flags().StringVarP(&statusOpts.filePath, "file", "f", "", "File name") + statusPostSubcommand.Flags().IntVarP(&statusOpts.inReplyToID, "in-reply-to", "r", 0, "Status ID to reply to") +} + +// statusCmd represents the status command +// This command does nothing without a subcommand +var statusCmd = &cobra.Command{ + Use: "status --status-id ID subcommand", + Aliases: []string{"st"}, + Short: "Get status details", + //Long: `TBW...`, // TODO + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // This is common to status and all status subcommands but "post" + if statusOpts.statusID < 1 && cmd.Name() != "post" { + return errors.New("missing status ID") + } + if err := madonInit(true); err != nil { + return err + } + return nil + }, +} + +var statusSubcommands = []*cobra.Command{ + &cobra.Command{ + Use: "show", + Aliases: []string{"display"}, + Short: "Get the status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "context", + Short: "Get the status context", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "card", + Short: "Get the status card", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "reblogged-by", + Short: "Display accounts which reblogged the status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "favourited-by", + Aliases: []string{"favorited-by"}, + Short: "Display accounts which favourited the status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete the status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, + }, + statusReblogSubcommand, + statusFavouriteSubcommand, + statusPostSubcommand, +} + +var statusReblogSubcommand = &cobra.Command{ + Use: "boost", + Aliases: []string{"reblog"}, + Short: "Boost (reblog) or unreblog the status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, +} + +var statusFavouriteSubcommand = &cobra.Command{ + Use: "favourite", + Aliases: []string{"favorite", "fave"}, + Short: "Mark/unmark the status as favourite", + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, +} + +var statusPostSubcommand = &cobra.Command{ + Use: "post", + Aliases: []string{"toot", "pouet"}, + Short: "Post a message (same as 'madonctl toot')", + Example: ` madonctl status post --spoiler Warning "Hello, World" + madonctl status toot --sensitive --file image.jpg Image + madonctl status post --media-ids ID1,ID2,ID3 Image`, + RunE: func(cmd *cobra.Command, args []string) error { + return statusSubcommandRunE(cmd.Name(), args) + }, +} + +func statusSubcommandRunE(subcmd string, args []string) error { + opt := statusOpts + + var obj interface{} + var err error + + switch subcmd { + case "show": + var status *madon.Status + status, err = gClient.GetStatus(opt.statusID) + obj = status + case "context": + var context *madon.Context + context, err = gClient.GetStatusContext(opt.statusID) + obj = context + case "card": + var context *madon.Card + context, err = gClient.GetStatusCard(opt.statusID) + obj = context + case "reblogged-by": + var accountList []madon.Account + accountList, err = gClient.GetStatusRebloggedBy(opt.statusID) + obj = accountList + case "favourited-by": + var accountList []madon.Account + accountList, err = gClient.GetStatusFavouritedBy(opt.statusID) + obj = accountList + case "delete": + err = gClient.DeleteStatus(opt.statusID) + case "boost": + if opt.unset { + err = gClient.UnreblogStatus(opt.statusID) + } else { + err = gClient.ReblogStatus(opt.statusID) + } + case "favourite": + if opt.unset { + err = gClient.UnfavouriteStatus(opt.statusID) + } else { + err = gClient.FavouriteStatus(opt.statusID) + } + case "post": // toot + var s *madon.Status + s, err = toot(strings.Join(args, " ")) + obj = s + default: + return errors.New("statusSubcommand: internal error") + } + + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + if obj == nil { + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(obj, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/stream.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/stream.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,136 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + "io" + + "github.com/spf13/cobra" + + "github.com/McKael/madon" +) + +/* +var streamOpts struct { + local bool +} +*/ + +// streamCmd represents the stream command +var streamCmd = &cobra.Command{ + Use: "stream [user|local|public|:HASHTAG]", + Short: "Listen to an event stream", + Long: ` +The stream command stays connected to the server and listen to a stream of +events (user, local or federated). +It can also get a hashtag-based stream if the keyword or prefixed with +':' or '#'.`, + Example: ` madonctl stream # User timeline stream + madonctl stream local # Local timeline stream + madonctl stream public # Public timeline stream + madonctl stream :mastodon # Hashtag + madonctl stream #madonctl`, + RunE: streamRunE, + ValidArgs: []string{"user", "public"}, + ArgAliases: []string{"home"}, +} + +func init() { + RootCmd.AddCommand(streamCmd) + + //streamCmd.Flags().BoolVar(&streamOpts.local, "local", false, "Events from the local instance") +} + +func streamRunE(cmd *cobra.Command, args []string) error { + streamName := "user" + tag := "" + + if len(args) > 0 { + if len(args) != 1 { + return errors.New("too many parameters") + } + arg := args[0] + switch arg { + case "", "user": + case "public": + streamName = arg + case "local": + streamName = "public:local" + default: + if arg[0] != ':' && arg[0] != '#' { + return errors.New("invalid argument") + } + streamName = "hashtag" + tag = arg[1:] + if len(tag) == 0 { + return errors.New("empty hashtag") + } + } + } + + if err := madonInit(true); err != nil { + return err + } + + evChan := make(chan madon.StreamEvent, 10) + stop := make(chan bool) + done := make(chan bool) + + // StreamListener(name string, hashTag string, events chan<- madon.StreamEvent, stopCh <-chan bool, doneCh chan<- bool) error + err := gClient.StreamListener(streamName, tag, evChan, stop, done) + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + + p, err := getPrinter() + if err != nil { + close(stop) + <-done + close(evChan) + return err + } + +LISTEN: + for { + select { + case _, ok := <-done: + if !ok { // done is closed, end of streaming + done = nil + break LISTEN + } + case ev := <-evChan: + switch ev.Event { + case "error": + if ev.Error != nil { + if ev.Error == io.ErrUnexpectedEOF { + errPrint("The stream connection was unexpectedly closed") + continue + } + errPrint("Error event: [%s] %s", ev.Event, ev.Error) + continue + } + errPrint("Event: [%s]", ev.Event) + case "update": + s := ev.Data.(madon.Status) + p.PrintObj(&s, nil, "") + continue + case "notification": + n := ev.Data.(madon.Notification) + p.PrintObj(&n, nil, "") + continue + case "delete": + // TODO PrintObj ? + errPrint("Event: [%s] Status %d was deleted", ev.Event, ev.Data.(int)) + default: + errPrint("Unhandled event: [%s] %T", ev.Event, ev.Data) + } + } + } + close(evChan) + return nil +} diff -r 000000000000 -r 5abace724584 cmd/timelines.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/timelines.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,62 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var timelineOpts struct { + local bool +} + +// timelineCmd represents the timelines command +var timelineCmd = &cobra.Command{ + Use: "timeline [home|public|:HASHTAG] [--local]", + Aliases: []string{"tl"}, + Short: "Fetch a timeline", + Long: ` +The timeline command fetches a timeline (home, local or federated). +It can also get a hashtag-based timeline if the keyword or prefixed with +':' or '#'.`, + Example: ` madonctl timeline + madonctl timeline public --local + madonctl timeline :mastodon`, + RunE: timelineRunE, + ValidArgs: []string{"home", "public"}, +} + +func init() { + RootCmd.AddCommand(timelineCmd) + + timelineCmd.Flags().BoolVar(&timelineOpts.local, "local", false, "Posts from the local instance") +} + +func timelineRunE(cmd *cobra.Command, args []string) error { + opt := timelineOpts + + tl := "home" + if len(args) > 0 { + tl = args[0] + } + + // The home timeline is the only one requiring to be logged in + if err := madonInit(tl == "home"); err != nil { + return err + } + + sl, err := gClient.GetTimelines(tl, opt.local) + if err != nil { + errPrint("Error: %s", err.Error()) + return nil + } + + p, err := getPrinter() + if err != nil { + return err + } + return p.PrintObj(sl, nil, "") +} diff -r 000000000000 -r 5abace724584 cmd/toot.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/toot.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,79 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/McKael/madon" +) + +// toot is a kind of alias for status post + +func init() { + RootCmd.AddCommand(tootAliasCmd) + + tootAliasCmd.Flags().BoolVar(&statusOpts.sensitive, "sensitive", false, "Mark post as sensitive (NSFW)") + tootAliasCmd.Flags().StringVar(&statusOpts.visibility, "visibility", "", "Visibility (direct|private|unlisted|public)") + tootAliasCmd.Flags().StringVar(&statusOpts.spoiler, "spoiler", "", "Spoiler warning (CW)") + tootAliasCmd.Flags().StringVar(&statusOpts.mediaIDs, "media-ids", "", "Comma-separated list of media IDs") + tootAliasCmd.Flags().StringVarP(&statusOpts.filePath, "file", "f", "", "Media attachment file name") + tootAliasCmd.Flags().IntVarP(&statusOpts.inReplyToID, "in-reply-to", "r", 0, "Status ID to reply to") +} + +var tootAliasCmd = &cobra.Command{ + Use: "toot", + Aliases: []string{"post", "pouet"}, + Short: "Post a message (toot)", + Example: ` madonctl toot message + madonctl toot --spoiler Warning "Hello, World" + madonctl status post --media-ids ID1,ID2 "Here are the photos" + madonctl post --sensitive --file image.jpg Image`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := madonInit(true); err != nil { + return err + } + return statusSubcommandRunE("post", args) + }, +} + +func toot(tootText string) (*madon.Status, error) { + opt := statusOpts + + switch opt.visibility { + case "", "direct", "private", "unlisted", "public": + // OK + default: + return nil, errors.New("invalid visibility argument value") + } + + if opt.inReplyToID < 0 { + return nil, errors.New("invalid in-reply-to argument value") + } + + ids, err := splitIDs(opt.mediaIDs) + if err != nil { + return nil, errors.New("cannot parse media IDs") + } + + if opt.filePath != "" { + if len(ids) > 3 { + return nil, errors.New("too many media attachments") + } + + fileMediaID, err := uploadFile(opt.filePath) + if err != nil { + return nil, errors.New("cannot attach media file: " + err.Error()) + } + if fileMediaID > 0 { + ids = append(ids, fileMediaID) + } + } + + return gClient.PostStatus(tootText, opt.inReplyToID, ids, opt.sensitive, opt.spoiler, opt.visibility) +} diff -r 000000000000 -r 5abace724584 cmd/version.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/version.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,27 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// VERSION of the madonctl application +var VERSION = "0.0.9" + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Display " + AppName + " version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("This is %s version %s\n", AppName, VERSION) + }, +} + +func init() { + RootCmd.AddCommand(versionCmd) +} diff -r 000000000000 -r 5abace724584 main.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/main.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,29 @@ +// Copyright © 2017 Mikael Berthe +// +// 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. + +package main + +import ( + "github.com/McKael/madonctl/cmd" +) + +func main() { + cmd.RootCmd.Execute() +} diff -r 000000000000 -r 5abace724584 printer/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/README.md Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,28 @@ +# madonctl + +Golang command line interface for the Mastodon API + +[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE) + +`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API. + +## Installation + +To install the application (you need to have Go): + + go get github.com/McKael/madonctl + +Some pre-built binaries are available on the home page (see below). + +## Usage + +This section has not been written yet. + +Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an +introduction and a few examples. + +## References + +- [madon](https://github.com/McKael/madon), the Go library for Mastodon API +- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) +- [Mastodon repository](https://github.com/tootsuite/mastodon) diff -r 000000000000 -r 5abace724584 printer/json.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/json.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,34 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package printer + +import ( + "encoding/json" + "io" + "os" +) + +// JSONPrinter represents a JSON printer +type JSONPrinter struct { +} + +// NewPrinterJSON returns a JSON ResourcePrinter +func NewPrinterJSON(option string) (*JSONPrinter, error) { + return &JSONPrinter{}, nil +} + +// PrintObj sends the object as text to the writer +// If the writer w is nil, standard output will be used. +// For JSONPrinter, the option parameter is currently not used. +func (p *JSONPrinter) PrintObj(obj interface{}, w io.Writer, option string) error { + if w == nil { + w = os.Stdout + } + + jsonEncoder := json.NewEncoder(w) + //jsonEncoder.SetIndent("", " ") + return jsonEncoder.Encode(obj) +} diff -r 000000000000 -r 5abace724584 printer/plain.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/plain.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,272 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package printer + +import ( + "fmt" + "io" + "os" + "reflect" + "time" + + "github.com/jaytaylor/html2text" + + "github.com/McKael/madon" +) + +// PlainPrinter is the default "plain text" printer +type PlainPrinter struct { + Indent string + NoSubtitles bool +} + +// NewPrinterPlain returns a plaintext ResourcePrinter +// For PlainPrinter, the option parameter contains the indent prefix. +func NewPrinterPlain(option string) (*PlainPrinter, error) { + indentInc := " " + if option != "" { + indentInc = option + } + return &PlainPrinter{Indent: indentInc}, nil +} + +// PrintObj sends the object as text to the writer +// If the writer w is nil, standard output will be used. +// For PlainPrinter, the option parameter contains the initial indent. +func (p *PlainPrinter) PrintObj(obj interface{}, w io.Writer, initialIndent string) error { + if w == nil { + w = os.Stdout + } + switch o := obj.(type) { + case []madon.Account, []madon.Attachment, []madon.Card, []madon.Context, + []madon.Instance, []madon.Mention, []madon.Notification, + []madon.Relationship, []madon.Report, []madon.Results, + []madon.Status, []madon.StreamEvent, []madon.Tag: + return p.plainForeach(o, w, initialIndent) + case *madon.Account: + return p.plainPrintAccount(o, w, initialIndent) + case madon.Account: + return p.plainPrintAccount(&o, w, initialIndent) + case *madon.Attachment: + return p.plainPrintAttachment(o, w, initialIndent) + case madon.Attachment: + return p.plainPrintAttachment(&o, w, initialIndent) + case *madon.Card: + return p.plainPrintCard(o, w, initialIndent) + case madon.Card: + return p.plainPrintCard(&o, w, initialIndent) + case *madon.Context: + return p.plainPrintContext(o, w, initialIndent) + case madon.Context: + return p.plainPrintContext(&o, w, initialIndent) + case *madon.Instance: + return p.plainPrintInstance(o, w, initialIndent) + case madon.Instance: + return p.plainPrintInstance(&o, w, initialIndent) + case *madon.Notification: + return p.plainPrintNotification(o, w, initialIndent) + case madon.Notification: + return p.plainPrintNotification(&o, w, initialIndent) + case *madon.Relationship: + return p.plainPrintRelationship(o, w, initialIndent) + case madon.Relationship: + return p.plainPrintRelationship(&o, w, initialIndent) + case *madon.Report: + return p.plainPrintReport(o, w, initialIndent) + case madon.Report: + return p.plainPrintReport(&o, w, initialIndent) + case *madon.Results: + return p.plainPrintResults(o, w, initialIndent) + case madon.Results: + return p.plainPrintResults(&o, w, initialIndent) + case *madon.Status: + return p.plainPrintStatus(o, w, initialIndent) + case madon.Status: + return p.plainPrintStatus(&o, w, initialIndent) + case *madon.UserToken: + return p.plainPrintUserToken(o, w, initialIndent) + case madon.UserToken: + return p.plainPrintUserToken(&o, w, initialIndent) + } + // TODO: Mention + // TODO: StreamEvent + // TODO: Tag + + return fmt.Errorf("PlainPrinter not yet implemented for %T (try json or yaml...)", obj) +} + +func (p *PlainPrinter) plainForeach(ol interface{}, w io.Writer, ii string) error { + switch reflect.TypeOf(ol).Kind() { + case reflect.Slice: + s := reflect.ValueOf(ol) + + for i := 0; i < s.Len(); i++ { + o := s.Index(i).Interface() + if err := p.PrintObj(o, w, ii); err != nil { + return err + } + } + } + return nil +} + +func html2string(h string) string { + t, err := html2text.FromString(h) + if err == nil { + return t + } + return h // Failed: return initial string +} + +func indentedPrint(w io.Writer, indent string, title, skipIfEmpty bool, label string, format string, args ...interface{}) { + prefix := indent + if title { + prefix += "- " + } else { + prefix += " " + } + value := fmt.Sprintf(format, args...) + if !title && skipIfEmpty && len(value) == 0 { + return + } + fmt.Fprintf(w, "%s%s: %s\n", prefix, label, value) +} + +func (p *PlainPrinter) plainPrintAccount(a *madon.Account, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Account ID", "%d (%s)", a.ID, a.Username) + indentedPrint(w, indent, false, false, "User ID", "%s", a.Acct) + indentedPrint(w, indent, false, false, "Display name", "%s", a.DisplayName) + indentedPrint(w, indent, false, false, "Creation date", "%v", a.CreatedAt.Local()) + indentedPrint(w, indent, false, false, "URL", "%s", a.URL) + indentedPrint(w, indent, false, false, "Statuses count", "%d", a.StatusesCount) + indentedPrint(w, indent, false, false, "Followers count", "%d", a.FollowersCount) + indentedPrint(w, indent, false, false, "Following count", "%d", a.FollowingCount) + if a.Locked { + indentedPrint(w, indent, false, false, "Locked", "%v", a.Locked) + } + indentedPrint(w, indent, false, true, "User note", "%s", html2string(a.Note)) // XXX too long? + return nil +} + +func (p *PlainPrinter) plainPrintAttachment(a *madon.Attachment, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Attachment ID", "%d", a.ID) + indentedPrint(w, indent, false, false, "Type", "%s", a.Type) + indentedPrint(w, indent, false, false, "Local URL", "%s", a.URL) + indentedPrint(w, indent, false, true, "Remote URL", "%s", a.RemoteURL) + indentedPrint(w, indent, false, true, "Preview URL", "%s", a.PreviewURL) + indentedPrint(w, indent, false, true, "Text URL", "%s", a.PreviewURL) + return nil +} + +func (p *PlainPrinter) plainPrintCard(c *madon.Card, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Card title", "%s", c.Title) + indentedPrint(w, indent, false, true, "Description", "%s", c.Description) + indentedPrint(w, indent, false, true, "URL", "%s", c.URL) + indentedPrint(w, indent, false, true, "Image", "%s", c.Image) + return nil +} + +func (p *PlainPrinter) plainPrintContext(c *madon.Context, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Context", "%d relative(s)", len(c.Ancestors)+len(c.Descendents)) + if len(c.Ancestors) > 0 { + indentedPrint(w, indent, false, false, "Ancestors", "") + p.PrintObj(c.Ancestors, w, indent+p.Indent) + } + if len(c.Descendents) > 0 { + indentedPrint(w, indent, false, false, "Descendents", "") + p.PrintObj(c.Descendents, w, indent+p.Indent) + } + return nil +} + +func (p *PlainPrinter) plainPrintInstance(i *madon.Instance, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Instance title", "%s", i.Title) + indentedPrint(w, indent, false, true, "Description", "%s", html2string(i.Description)) + indentedPrint(w, indent, false, true, "URL", "%s", i.URI) + indentedPrint(w, indent, false, true, "Email", "%s", i.Email) + return nil +} + +func (p *PlainPrinter) plainPrintNotification(n *madon.Notification, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Notification ID", "%d", n.ID) + indentedPrint(w, indent, false, false, "Type", "%s", n.Type) + indentedPrint(w, indent, false, false, "Timestamp", "%v", n.CreatedAt.Local()) + if n.Account != nil { + indentedPrint(w, indent+p.Indent, true, false, "Account", "(%d) @%s - %s", + n.Account.ID, n.Account.Acct, n.Account.DisplayName) + } + if n.Status != nil { + p.plainPrintStatus(n.Status, w, indent+p.Indent) + } + return nil +} + +func (p *PlainPrinter) plainPrintRelationship(r *madon.Relationship, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "ID", "%d", r.ID) + indentedPrint(w, indent, false, false, "Following", "%v", r.Following) + indentedPrint(w, indent, false, false, "Followed-by", "%v", r.FollowedBy) + indentedPrint(w, indent, false, false, "Blocking", "%v", r.Blocking) + indentedPrint(w, indent, false, false, "Muting", "%v", r.Muting) + indentedPrint(w, indent, false, false, "Requested", "%v", r.Requested) + return nil +} + +func (p *PlainPrinter) plainPrintReport(r *madon.Report, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Report ID", "%d", r.ID) + indentedPrint(w, indent, false, false, "Action taken", "%s", r.ActionTaken) + return nil +} + +func (p *PlainPrinter) plainPrintResults(r *madon.Results, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Results", "%d account(s), %d status(es), %d hashtag(s)", + len(r.Accounts), len(r.Statuses), len(r.Hashtags)) + if len(r.Accounts) > 0 { + indentedPrint(w, indent, false, false, "Accounts", "") + p.PrintObj(r.Accounts, w, indent+p.Indent) + } + if len(r.Statuses) > 0 { + indentedPrint(w, indent, false, false, "Statuses", "") + p.PrintObj(r.Statuses, w, indent+p.Indent) + } + if len(r.Hashtags) > 0 { + indentedPrint(w, indent, false, false, "Hashtags", "") + for _, tag := range r.Hashtags { + indentedPrint(w, indent+p.Indent, true, false, "Tag", "%s", tag) + } + } + return nil +} + +func (p *PlainPrinter) plainPrintStatus(s *madon.Status, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "Status ID", "%d", s.ID) + indentedPrint(w, indent, false, false, "From", "%s", s.Account.Acct) + indentedPrint(w, indent, false, false, "Timestamp", "%v", s.CreatedAt.Local()) + + if s.Reblog != nil { + indentedPrint(w, indent, false, false, "Reblogged from", "%s", s.Reblog.Account.Username) + return p.plainPrintStatus(s.Reblog, w, indent+p.Indent) + } + + indentedPrint(w, indent, false, false, "Contents", "%s", html2string(s.Content)) + if s.InReplyToID > 0 { + indentedPrint(w, indent, false, false, "In-Reply-To", "%d", s.InReplyToID) + } + if s.Reblogged { + indentedPrint(w, indent, false, false, "Reblogged", "%v", s.Reblogged) + } + indentedPrint(w, indent, false, false, "URL", "%s", s.URL) + return nil +} + +func (p *PlainPrinter) plainPrintUserToken(s *madon.UserToken, w io.Writer, indent string) error { + indentedPrint(w, indent, true, false, "User token", "%s", s.AccessToken) + indentedPrint(w, indent, false, true, "Type", "%s", s.TokenType) + if s.CreatedAt != 0 { + indentedPrint(w, indent, false, true, "Timestamp", "%v", time.Unix(int64(s.CreatedAt), 0)) + } + indentedPrint(w, indent, false, true, "Scope", "%s", s.Scope) + return nil +} diff -r 000000000000 -r 5abace724584 printer/printer.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/printer.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,33 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package printer + +import ( + "fmt" + "io" +) + +// ResourcePrinter is an interface used to print objects. +type ResourcePrinter interface { + // PrintObj receives a runtime object, formats it and prints it to a writer. + PrintObj(interface{}, io.Writer, string) error +} + +// NewPrinter returns a ResourcePrinter for the specified kind of output. +// It returns nil if the output is not supported. +func NewPrinter(output, option string) (ResourcePrinter, error) { + switch output { + case "", "plain": + return NewPrinterPlain(option) + case "json": + return NewPrinterJSON(option) + case "yaml": + return NewPrinterYAML(option) + case "template": + return NewPrinterTemplate(option) + } + return nil, fmt.Errorf("unhandled output format") +} diff -r 000000000000 -r 5abace724584 printer/templateprinter.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/templateprinter.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,112 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package printer + +import ( + "encoding/json" + "fmt" + "io" + "os" + "reflect" + "text/template" + + "github.com/McKael/madon" +) + +// TemplatePrinter represents a Template printer +type TemplatePrinter struct { + rawTemplate string + template *template.Template +} + +// NewPrinterTemplate returns a Template ResourcePrinter +// For TemplatePrinter, the option parameter contains the template string. +func NewPrinterTemplate(option string) (*TemplatePrinter, error) { + tmpl := option + t, err := template.New("output").Parse(tmpl) + if err != nil { + return nil, err + } + return &TemplatePrinter{ + rawTemplate: tmpl, + template: t, + }, nil +} + +// PrintObj sends the object as text to the writer +// If the writer w is nil, standard output will be used. +func (p *TemplatePrinter) PrintObj(obj interface{}, w io.Writer, tmpl string) error { + if w == nil { + w = os.Stdout + } + + if p.template == nil { + return fmt.Errorf("template not built") + } + + switch ot := obj.(type) { // I wish I knew a better way... + case []madon.Account, []madon.Application, []madon.Attachment, []madon.Card, + []madon.Client, []madon.Context, []madon.Instance, []madon.Mention, + []madon.Notification, []madon.Relationship, []madon.Report, + []madon.Results, []madon.Status, []madon.StreamEvent, []madon.Tag: + return p.templateForeach(ot, w) + } + + return p.templatePrintSingleObj(obj, w) +} + +func (p *TemplatePrinter) templatePrintSingleObj(obj interface{}, w io.Writer) error { + // This code comes from Kubernetes. + data, err := json.Marshal(obj) + if err != nil { + return err + } + out := map[string]interface{}{} + if err := json.Unmarshal(data, &out); err != nil { + return err + } + if err = p.safeExecute(w, out); err != nil { + return fmt.Errorf("error executing template %q: %v", p.rawTemplate, err) + } + return nil +} + +// safeExecute tries to execute the template, but catches panics and returns an error +// should the template engine panic. +// This code comes from Kubernetes. +func (p *TemplatePrinter) safeExecute(w io.Writer, obj interface{}) error { + var panicErr error + // Sorry for the double anonymous function. There's probably a clever way + // to do this that has the defer'd func setting the value to be returned, but + // that would be even less obvious. + retErr := func() error { + defer func() { + if x := recover(); x != nil { + panicErr = fmt.Errorf("caught panic: %+v", x) + } + }() + return p.template.Execute(w, obj) + }() + if panicErr != nil { + return panicErr + } + return retErr +} + +func (p *TemplatePrinter) templateForeach(ol interface{}, w io.Writer) error { + switch reflect.TypeOf(ol).Kind() { + case reflect.Slice: + s := reflect.ValueOf(ol) + + for i := 0; i < s.Len(); i++ { + o := s.Index(i).Interface() + if err := p.templatePrintSingleObj(o, w); err != nil { + return err + } + } + } + return nil +} diff -r 000000000000 -r 5abace724584 printer/yaml.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/printer/yaml.go Wed Apr 19 19:08:47 2017 +0200 @@ -0,0 +1,42 @@ +// Copyright © 2017 Mikael Berthe +// +// Licensed under the MIT license. +// Please see the LICENSE file is this directory. + +package printer + +import ( + "fmt" + "io" + "os" + + "github.com/ghodss/yaml" +) + +// YAMLPrinter represents a YAML printer +type YAMLPrinter struct { +} + +// NewPrinterYAML returns a YAML ResourcePrinter +func NewPrinterYAML(option string) (*YAMLPrinter, error) { + return &YAMLPrinter{}, nil +} + +// PrintObj sends the object as text to the writer +// If the writer w is nil, standard output will be used. +// For YAMLPrinter, the option parameter is currently not used. +func (p *YAMLPrinter) PrintObj(obj interface{}, w io.Writer, option string) error { + if w == nil { + w = os.Stdout + } + + //yamlEncoder := yaml.NewEncoder(w) + //return yamlEncoder.Encode(obj) + + output, err := yaml.Marshal(obj) + if err != nil { + return err + } + _, err = fmt.Fprint(w, string(output)) + return err +}