diff --git a/.gitignore b/.gitignore index 1826666..3a49983 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -test* -output.* -.env* \ No newline at end of file +config.toml +*_markdown/ +.env \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..be362ec --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,52 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "--help for publish", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "args": ["publish", "--help"], + "console": "integratedTerminal" + }, + { + "name": "Publish from blogger", + "type": "go", + "request": "launch", + "program": "${workspaceFolder}", + // https://www.blogger.com/edit-profile.g + "args": ["publish", "blogger", "${input:PostURL}", "${input:Destination}"], + // "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" + }, + { + "name": "Publish from Markdown", + "type": "go", + "request": "launch", + "program": "${workspaceFolder}", + "args": ["publish", "markdown", "${input:MarkdownPath}", "${input:Destination}"], + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "PostURL", + "type": "promptString", + "description": "Enter the URL of the Blogger post to publish", + "default": "https://itsfrommars.blogspot.com/2024/06/hello-world_11.html" + }, + { + "id": "MarkdownPath", + "type": "promptString", + "description": "Enter the path of the Markdown file to publish", + "default": "hello-world.md" + }, + { + "id": "Destination", + "type": "promptString", + "description": "Enter the destination to publish", + "default": "markdown" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 9bc9df6..56c9f12 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # cross-blogger -Cross-service (and cross-platform) blog posting utility +Soon-to-be headless CMS for static site generators powered by Google's Blogger. ### Installation #### Compiled Binaries @@ -11,25 +11,28 @@ Using `go install`, you can compile and add the program to the PATH. Either run `go install github.com/slashtechno/cross-blogger@latest`, follow the same process as compiling the program locally, but replace `go build` with `go install`. ### Usage -To use this program, just run the executable in the terminal. +Sources and destinations should first be configured in the `config.yaml` file. +For Google OAuth, the `--client-id` and `--client-secret` flags are required but can be set as environment variables (`CROSS_BLOGGER_GOOGLE_CLIENT_ID`/`CROSS_BLOGGER_GOOGLE_CLIENT_SECRET`). However these can also be set in the `config.yaml` file, passed as environment variables, or put in a `.env` file. When a refresh token is not provided, the program will commence the OAuth flow. This will write the refresh token, along with any other configuration, to the `config.yaml` file. If you prefer to use other methods to pass the credentials, you can remove the lines and use the other methods. #### Help Output -From `cross-blogger --help` +From `cross-blogger publish --help` ```text -Usage: cross-blogger.exe --client-id CLIENT-ID --client-secret CLIENT-SECRET [--refresh-token REFRESH-TOKEN] [--log-level LOG-LEVEL] [--log-color] [] - -Options: - --client-id CLIENT-ID - Google OAuth client ID [env: CLIENT_ID] - --client-secret CLIENT-SECRET - Google OAuth client secret [env: CLIENT_SECRET] - --refresh-token REFRESH-TOKEN - Google OAuth refresh token [env: REFRESH_TOKEN] - --log-level LOG-LEVEL - "debug", "info", "warning", "error", or "fatal" [default: info, env: LOG_LEVEL] - --log-color Force colored logs [default: false, env: LOG_COLOR] - --help, -h display this help and exit +Publish to a destination from a source. + Specify the source with the first positional argument. + The second positional argument is the specifier, such as a Blogger post URL or a file path. + All arguments after the first are treated as destinations. + Destinations should be the name of the destinations specified in the config file -Commands: - google-oauth Store Google OAuth refresh token - publish Publish to a destination +Usage: + cross-blogger publish [flags] + +Flags: + -r, --dry-run Don't actually publish + --google-client-id string Google OAuth client ID + --google-client-secret string Google OAuth client secret + --google-refresh-token string Google OAuth refresh token + -h, --help help for publish + -t, --title string Specify custom title instead of using the default + +Global Flags: + --config string config file path (default "config.toml") ``` diff --git a/args.go b/args.go deleted file mode 100644 index fb3ad28..0000000 --- a/args.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -type BloggerCmd struct { - BlogAddress string `arg:"positional, required" help:"Blog address to get content from"` - PostAddress string `arg:"positional, required" help:"Post slug to get content from"` -} - -type FileCmd struct { - Filepath string `arg:"positional, required" help:"Filepath to get content from"` -} - -type PublishCmd struct { - File *FileCmd `arg:"subcommand:file" help:"Publish from a file"` - Blogger *BloggerCmd `arg:"subcommand:blogger" help:"Publish from Blogger"` - // Perhaps instead of needing both a key and a value for destinations, parse a single value - // For example, check if it's a file, and if so, check the file ending to determine the type - // Maybe check if it contains blogger.com - // Of course, an override would be nice - Destinations map[string]string `arg:"--destinations, required" help:"Destination(s) to publish to\nAvailable destinations: blogger, markdown, html\nMake sure to specify with ="` - Title string `arg:"-t,--title" help:"Specify custom title instead of using the default"` - DryRun bool `arg:"-d,--dry-run" help:"Don't actually publish"` -} - -type GoogleOauthCmd struct { -} - -var Args struct { - // Subcommands - GoogleOauth *GoogleOauthCmd `arg:"subcommand:google-oauth" help:"Store Google OAuth refresh token"` - Publish *PublishCmd `arg:"subcommand:publish" help:"Publish to a destination"` - - // Google OAuth flags - ClientId string `arg:"--client-id, env:CLIENT_ID" help:"Google OAuth client ID"` - ClientSecret string `arg:"--I client-secret, env:CLIENT_SECRET" help:"Google OAuth client secret"` - RefreshToken string `arg:"--refresh-token, env:REFRESH_TOKEN" help:"Google OAuth refresh token" default:""` - - // Misc flags - LogLevel string `arg:"--log-level, env:LOG_LEVEL" help:"\"debug\", \"info\", \"warning\", \"error\", or \"fatal\"" default:"info"` - LogColor bool `arg:"--log-color, env:LOG_COLOR" help:"Force colored logs" default:"false"` -} diff --git a/cmd/platforms.go b/cmd/platforms.go new file mode 100644 index 0000000..ef9379e --- /dev/null +++ b/cmd/platforms.go @@ -0,0 +1,402 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gosimple/slug" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "gopkg.in/yaml.v2" + + md "github.com/JohannesKaufmann/html-to-markdown" + "github.com/charmbracelet/log" + "github.com/go-resty/resty/v2" + "github.com/slashtechno/cross-blogger/pkg/oauth" + "github.com/spf13/afero" + "go.abhg.dev/goldmark/frontmatter" +) + +type Destination interface { + Push(PostData, PushPullOptions) error + GetName() string + GetType() string +} + +type Source interface { + Pull(PushPullOptions) (PostData, error) + GetName() string + GetType() string +} + +type PushPullOptions struct { + AccessToken string + BlogId string + PostUrl string + Filepath string +} + +type Frontmatter struct { + Title string `yaml:"title"` + CanonicalUrl string `yaml:"canonicalURL"` +} + +type PostData struct { + Title string + Html string + Markdown string + // Other fields that are probably needed are canonical URL, publish date, and description + CanonicalUrl string +} + +// type PlatformParent struct { +// Name string +// } + +// func (p PlatformParent) Push() { +// log.Error("child class must implement this method") +// } + +type Blogger struct { + Name string + BlogUrl string + // https://developers.google.com/blogger/docs/3.0/reference/posts/delete + Overwrite bool +} + +// Return the access token, refresh token (if one was not provided), and an error (if one occurred). +// The access and refresh tokens are only returned if an error did not occur. +// In Google Cloud, create OAuth client credentials for a desktop app and enable the Blogger API. +func (b Blogger) authorize(clientId string, clientSecret string, providedRefreshToken string) (string, string, error) { + oauthConfig := oauth.Config{ + ClientID: clientId, + ClientSecret: clientSecret, + Port: "8080", + } + var refreshToken string + var err error + if providedRefreshToken != "" { + log.Info("Using provided refresh token") + refreshToken = providedRefreshToken + } else { + log.Info("No refresh token provided, starting OAuth flow") + refreshToken, err = oauth.GetGoogleRefreshToken(oauthConfig) + if err != nil { + return "", "", err + } + } + accessToken, err := oauth.GetGoogleAccessToken(oauthConfig, refreshToken) + if err != nil { + // Not returning the refresh token because it may have been invalid + return "", "", err + } + log.Info("", "access token", accessToken) + if providedRefreshToken != "" { + return accessToken, providedRefreshToken, nil + } + return accessToken, refreshToken, nil + +} +func (b Blogger) getBlogId(accessToken string) (string, error) { + client := resty.New() + resp, err := client.R().SetHeader("Authorization", fmt.Sprintf("Bearer %s", accessToken)).SetResult(&map[string]interface{}{}).Get("https://www.googleapis.com/blogger/v3/blogs/byurl?url=" + b.BlogUrl) + if err != nil { + return "", err + } + if resp.StatusCode() != 200 { + return "", fmt.Errorf("failed to get blog id: %s", resp.String()) + } + // Get the key "id" from the response + result := (*resp.Result().(*map[string]interface{})) + id, ok := result["id"] + if !ok { + return "", fmt.Errorf("id not found in response") + } + return id.(string), nil +} +func (b Blogger) Pull(options PushPullOptions) (PostData, error) { + log.Info("Blogger pull called", "options", options) + postPath := strings.Replace(options.PostUrl, b.BlogUrl, "", 1) + client := resty.New() + resp, err := client.R().SetHeader("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken)).SetResult(&map[string]interface{}{}).Get("https://www.googleapis.com/blogger/v3/blogs/" + options.BlogId + "/posts/bypath?path=" + postPath) + if err != nil { + return PostData{}, err + } + if resp.StatusCode() != 200 { + return PostData{}, fmt.Errorf("failed to get post: %s", resp.String()) + } + // Get the keys "title" and "content" from the response + result := (*resp.Result().(*map[string]interface{})) + title, ok := result["title"].(string) + if !ok { + return PostData{}, fmt.Errorf("title not found in response or is not a string") + } + html, ok := result["content"].(string) + if !ok { + return PostData{}, fmt.Errorf("content not found in response or is not a string") + } + canonicalUrl, ok := result["url"].(string) + if !ok { + return PostData{}, fmt.Errorf("url not found in response or is not a string") + } + // Convert the HTML to Markdown + markdown, err := md.NewConverter("", true, nil).ConvertString(html) + if err != nil { + return PostData{}, err + } + return PostData{ + Title: title, + Html: html, + Markdown: markdown, + CanonicalUrl: canonicalUrl, + }, nil + +} +func (b Blogger) Push(data PostData, options PushPullOptions) error { + // Set the client + client := resty.New() + blogId := options.BlogId + + // Delete any post with the same ID + if b.Overwrite { + // Get the list of existing posts + resp, err := client.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken)). + SetResult(&map[string]interface{}{}). + Get("https://www.googleapis.com/blogger/v3/blogs/" + blogId + "/posts") + if err != nil { + return err + } + posts := (*resp.Result().(*map[string]interface{}))["items"].([]interface{}) + + // Check if a post with the same title already exists + for _, p := range posts { + post := p.(map[string]interface{}) + if post["title"].(string) == data.Title { + // Delete the post + _, err := client.R(). + SetQueryParam("useTrash", "true"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken)). + Delete("https://www.googleapis.com/blogger/v3/blogs/" + blogId + "/posts/" + post["id"].(string)) + if err != nil { + return err + } + log.Info("Moved post with the same title to trash", "title", data.Title) + // If break is used and there are multiple posts with the same title, only the first one will be deleted + // break + } + } + } + log.Warn("Blogger does not support setting the canonical URL") + // Prepare the request + req := client.R().SetHeader("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken)).SetBody(map[string]interface{}{ + "title": data.Title, + "content": data.Html, + // "url": data.CanonicalUrl, + }).SetResult(&map[string]interface{}{}) + // Make the request + resp, err := req.Post("https://www.googleapis.com/blogger/v3/blogs/" + blogId + "/posts") + if err != nil { + return err + } + if resp.StatusCode() != 200 { + return fmt.Errorf("failed to post: %s", resp.String()) + } + result := (*resp.Result().(*map[string]interface{})) + log.Debug("Posted successfully", "result", result) + return nil +} +func (b Blogger) GetName() string { return b.Name } +func (b Blogger) GetType() string { return "blogger" } + +type Markdown struct { + Name string + // ContentDir, for retrieving, should only be used if treating the passed post path as relative results in no file found + ContentDir string + Overwrite bool +} + +func (m Markdown) GetName() string { return m.Name } +func (m Markdown) GetType() string { return "markdown" } + +// Push the data to the contentdir with the title as the filename using gosimple/slug. +// The markdown file should have YAML frontmatter compatible with Hugo. +func (m Markdown) Push(data PostData, options PushPullOptions) error { + // Create the file, if it exists, log an error and return + fs := afero.NewOsFs() + slug := slug.Make(data.Title) + // Clean the slug to remove any characters that may cause issues with the filesystem + slug = filepath.Clean(slug) + filePath := filepath.Join(m.ContentDir, slug+".md") + // Create parent directories if they don't exist + dirPath := filepath.Dir(filePath) + if _, err := fs.Stat(dirPath); os.IsNotExist(err) { + errDir := fs.MkdirAll(dirPath, 0755) + if errDir != nil { + log.Error("failed to create directory", "directory", dirPath) + return errDir + } + } + // Check if the file already exists + if _, err := fs.Stat(filePath); err == nil && !m.Overwrite { + return fmt.Errorf("file already exists and overwrite is false for file: %s", filePath) + } else if err != nil && !os.IsNotExist(err) { // If the error is not a "file does not exist" error + return err + } else if err == nil && m.Overwrite { // If the file exists and overwrite is true, remove the file + log.Info("Removing file as overwrite is true", "file", filePath) + err := fs.Remove(filePath) + if err != nil { + return err + } + } + + // Create the file + file, err := fs.Create(filePath) + if err != nil { + return err + } + // After the function returns, close the file + defer file.Close() + // Create the frontmatter + postFrontmatter := Frontmatter{ + Title: data.Title, + CanonicalUrl: data.CanonicalUrl, + } + // Convert the frontmatter to YAML + frontmatterYaml, err := yaml.Marshal(postFrontmatter) + if err != nil { + return err + } + content := fmt.Sprintf("---\n%s---\n\n%s", frontmatterYaml, data.Markdown) + log.Debug("Writing content", "content", content, "file", filePath) + _, err = file.WriteString(content) + if err != nil { + return err + } + return nil + +} +func (m Markdown) Pull(options PushPullOptions) (PostData, error) { + // Get the file path + fs := afero.NewOsFs() + // Treat the post path as relative to the content dir + // However, if the content dir does not exist or the file is not found, treat the post path as a normal path without the content dir + filePath := filepath.Join(m.ContentDir, options.Filepath) + if _, err := fs.Stat(filePath); os.IsNotExist(err) { + filePath = options.Filepath + } + // Read the file + data, err := afero.ReadFile(fs, filePath) + if err != nil { + return PostData{}, err + } + markdown := string(data) + // Convert the markdown to HTML with Goldmark + // Use the Frontmatter extension to get the frontmatter + mdParser := goldmark.New(goldmark.WithExtensions(&frontmatter.Extender{})) + ctx := parser.NewContext() + var buf bytes.Buffer + err = mdParser.Convert([]byte(markdown), &buf, parser.WithContext(ctx)) + if err != nil { + return PostData{}, err + } + // Get the frontmatter + markdownFrontmatter := Frontmatter{} + frontmatterData := frontmatter.Get(ctx) + if err := frontmatterData.Decode(&markdownFrontmatter); err != nil { + return PostData{}, err + } + // Check if title and canonical URL are set + if markdownFrontmatter.Title == "" { + return PostData{}, fmt.Errorf("title is required in frontmatter") + } + if markdownFrontmatter.CanonicalUrl == "" { + log.Warn("canonical_url is not set in frontmatter") + } + // Convert the HTML to Markdown + html := buf.String() + // The frontmatter is stripped before converting to HTML + // Just convert the HTML to Markdown so the Markdown doesn't have the frontmatter (otherwise it would be duplicated) + markdown, err = md.NewConverter("", true, nil).ConvertString(html) + if err != nil { + return PostData{}, err + } + return PostData{ + Title: markdownFrontmatter.Title, + Html: html, + Markdown: markdown, + CanonicalUrl: markdownFrontmatter.CanonicalUrl, + }, nil + +} + +func CreateDestination(destMap map[string]interface{}) (Destination, error) { + name, ok := destMap["name"].(string) + if !ok || name == "" { + return nil, fmt.Errorf("name is required") + } + + switch destMap["type"] { + case "blogger": + blogUrl, ok := destMap["blog_url"].(string) + if !ok || blogUrl == "" { + return nil, fmt.Errorf("blog_url is required for blogger") + } + + overwrite, _ := destMap["overwrite"].(bool) // If not set or not a bool, defaults to false + + return Blogger{ + Name: name, + BlogUrl: blogUrl, + Overwrite: overwrite, + }, nil + case "markdown": + contentDir, ok := destMap["content_dir"].(string) + if !ok || contentDir == "" { + return nil, fmt.Errorf("content_dir is required for markdown") + } + + overwrite, _ := destMap["overwrite"].(bool) // If not set or not a bool, defaults to false + + return Markdown{ + Name: name, + ContentDir: contentDir, + Overwrite: overwrite, + }, nil + default: + return nil, fmt.Errorf("unknown destination type: %s", destMap["type"]) + } +} + +func CreateSource(sourceMap map[string]interface{}) (Source, error) { + // In Go, ifa type assertion fails, it will return the zero value of the type and false. + name, ok := sourceMap["name"].(string) + if !ok || name == "" { + return nil, fmt.Errorf("name is required") + } + + switch sourceMap["type"] { + case "blogger": + blogUrl, ok := sourceMap["blog_url"].(string) + if !ok || blogUrl == "" { + return nil, fmt.Errorf("blog_url is required for blogger") + } + + return Blogger{ + Name: name, + BlogUrl: blogUrl, + }, nil + case "markdown": + // If the content_dir is not set, set it to null as its not required + contentDir, _ := sourceMap["content_dir"].(string) + return Markdown{ + Name: name, + ContentDir: contentDir, + }, nil + default: + return nil, fmt.Errorf("unknown source type: %s", sourceMap["type"]) + } +} diff --git a/cmd/publish.go b/cmd/publish.go new file mode 100644 index 0000000..ec8f38b --- /dev/null +++ b/cmd/publish.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var publishCmd = &cobra.Command{ + Use: "publish", + Short: "Publish to a destination", + Long: `Publish to a destination from a source. + Specify the source with the first positional argument. + The second positional argument is the specifier, such as a Blogger post URL or a file path. + All arguments after the first are treated as destinations. + Destinations should be the name of the destinations specified in the config file`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + destinations := viper.Get("destinations") + sources := viper.Get("sources") + if destinations == nil { + log.Fatal("Failed to get destinations from config") + } + + // Make a list of the Destination structs if the destination name is in the args + var destinationSlice []Destination + // _ ignores the index. `dest` is the map + for _, dest := range destinations.([]interface{}) { + destMap, ok := dest.(map[string]interface{}) + if !ok { + log.Fatal("Failed to convert destination to map") + } + destination, err := CreateDestination(destMap) + if err != nil { + log.Fatal(err) + } + // Check if the destination name is in the third argument or onwards + for _, arg := range args[2:] { + if destination.GetName() == arg { + log.Info("Adding destination", "destination", destination.GetName()) + destinationSlice = append(destinationSlice, destination) + } else { + log.Info("Not adding destination as it isn't specified in args", "destination", destination.GetName()) + } + } + } + log.Debug("Destination slice", "destinations", destinationSlice) + // Create a list of all the sources. If the source name is the first arg, use that source + var source Source + var found bool = false + for _, src := range sources.([]interface{}) { + sourceMap, ok := src.(map[string]interface{}) + if !ok { + log.Fatal("Failed to convert source to map") + } + src, err := CreateSource(sourceMap) + if err != nil { + log.Fatal(err) + } + if src.GetName() == args[0] { + source = src + found = true + log.Info("Found source", "source", source.GetName()) + } + } + if !found { + log.Fatal("Failed to find source in config") + } + + // Check if OAuth works (remove this later!) + // if blogger, ok := destinationSlice[0].(Blogger); ok { + // token, err := blogger.authorize(viper.GetString("google-client-id"), viper.GetString("google-client-secret")) + // if err != nil { + // log.Fatal(err) + // } + // log.Info("", "token", token) + // blogId, err := blogger.getBlogId(token) + // if err != nil { + // log.Fatal(err) + // } + // log.Info("", "blog id", blogId) + // } + + // Pull the data from the source + var options PushPullOptions + switch source.GetType() { + case "blogger": + _, accessToken, blogId, err := prepareBlogger(source, nil) + if err != nil { + log.Fatal(err) + } + + options = PushPullOptions{ + AccessToken: accessToken, + BlogId: blogId, + PostUrl: args[1], + } + case "markdown": + options = PushPullOptions{ + Filepath: args[1], + } + } + // Pull the data from the source + postData, err := source.Pull(options) + if err != nil { + log.Fatal(err) + } + log.Info("Successfully pulled data", "title", postData.Title, "url", postData.CanonicalUrl, "markdown", postData.Markdown) + + // For each destination, push the data + for _, destination := range destinationSlice { + var found bool = true + switch destination.GetType() { + case "markdown": + options = PushPullOptions{} + + case "blogger": + _, accessToken, blogId, err := prepareBlogger(nil, destination) + if err != nil { + log.Fatal(err) + } + options = PushPullOptions{ + AccessToken: accessToken, + BlogId: blogId, + } + default: + found = false + } + if found { + // Check if this is a dry run + if viper.GetBool("dry-run") { + log.Info("Dry run - not pushing data") + continue + } + err := destination.Push(postData, options) + if err != nil { + log.Fatal(err) + } + } else { + log.Error("Destination type not found", "type", destination.GetType()) + } + } + }, +} + +func init() { + RootCmd.AddCommand(publishCmd) + publishCmd.Flags().StringP("title", "t", "", "Specify custom title instead of using the default") + publishCmd.Flags().BoolP("dry-run", "r", false, "Don't actually publish") + publishCmd.Flags().String("google-client-id", "", "Google OAuth client ID") + publishCmd.Flags().String("google-client-secret", "", "Google OAuth client secret") + publishCmd.Flags().String("google-refresh-token", "", "Google OAuth refresh token") + // Keep in mind that if the refresh token is not set in the config file, the program will request one + // It will then write the refresh token to the config file, along with any flags or env vars that have been set. + // You could always go back and remove those lines and continue using environment variables or flags as it won't write to the config file as long as the refresh token is set + // Allow the OAuth stuff to be set via viper + viper.BindPFlag("google-client-id", publishCmd.Flags().Lookup("google-client-id")) + viper.BindPFlag("google-client-secret", publishCmd.Flags().Lookup("google-client-secret")) + viper.BindPFlag("google-refresh-token", publishCmd.Flags().Lookup("google-refresh-token")) + // Keep in mind that these should be prefixed with CROSS_BLOGGER + viper.BindEnv("google-client-id", "CROSS_BLOGGER_GOOGLE_CLIENT_ID") + viper.BindEnv("google-client-secret", "CROSS_BLOGGER_GOOGLE_CLIENT_SECRET") + viper.BindEnv("google-refresh-token", "GOOGLE_REFRESH_TOKEN") +} + +// Return the Blogger object and a string with the access token, the blog ID, and an error (if one occurred) +func prepareBlogger(source Source, destination Destination) (Blogger, string, string, error) { + // Check if the user passed a source or destination. Exactly one should be passed. + var platform interface{} + if source == nil && destination == nil { + return Blogger{}, "", "", fmt.Errorf("no source or destination passed") + } else if source != nil && destination != nil { + return Blogger{}, "", "", fmt.Errorf("both source and destination passed") + } else if source != nil { + platform = source + } else if destination != nil { + platform = destination + } else { + return Blogger{}, "", "", fmt.Errorf("failed to determine if source or destination was passed") + } + + // Convert source to Blogger + var blogger Blogger + if tmpBlogger, ok := platform.(Blogger); ok { + log.Debug("Asserted that source is Blogger successfully") + blogger = tmpBlogger + } else { + return Blogger{}, "", "", fmt.Errorf("failed to assert that source is Blogger - potentially due to being called on a non-Blogger source") + } + // If the refresh token exists in Viper, pass that to Blogger.Authorize. Otherwise, pass an empty string + refreshToken := viper.GetString("google-refresh-token") + var accessToken string + var err error + if refreshToken == "" { + log.Warn("No refresh token found in Viper") + accessToken, refreshToken, err = blogger.authorize(viper.GetString("google-client-id"), viper.GetString("google-client-secret"), "") + if err != nil { + return Blogger{}, "", "", err + } + // Write the refresh token to the config file + log.Info("Writing refresh token to Viper") + viper.Set("google-refresh-token", refreshToken) + err = viper.WriteConfig() + if err != nil { + return Blogger{}, "", "", err + } + } else { + log.Info("Found refresh token in Viper") + accessToken, _, err = blogger.authorize(viper.GetString("google-client-id"), viper.GetString("google-client-secret"), refreshToken) + } + if err != nil { + return Blogger{}, "", "", err + } + + blogId, err := blogger.getBlogId(accessToken) + if err != nil { + return Blogger{}, "", "", err + } + return blogger, accessToken, blogId, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..2a7d10f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "cross-blogger", + Short: "A utility to cross-publish content between different platforms", + Long: `cross-blogger is a utility to cross-publish content between different platforms.`, + + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := RootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobra.yaml)") + + // Cobra also supports local flags, which will only run + // when this action is called directly. + // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..de67c60 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,26 @@ +# type is a required field that specifies the type of the source or destination. +# name is the name of the source or destination. It is used to refer to the source or destination when running the command. +# overwrite is a boolean field that specifies whether to overwrite the file/post if it already exists. This is done by removing old files/posts that have the same title. +# blog_url is the URL of the blog +# content_dir is the directory where the markdown files are located +[[destinations]] +blog_url = 'https://example.com' +name = 'blog' +overwrite = false +type = 'blogger' + +[[destinations]] +content_dir = 'content' +name = 'otherblog' +overwrite = false +type = 'markdown' + +[[sources]] +blog_url = 'https://example.com' +name = 'someblog' +type = 'blogger' + +[[sources]] +content_dir = 'content' +name = 'aBlogInMarkdown' +type = 'markdown' diff --git a/go.mod b/go.mod index 03b6318..60474f6 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,62 @@ go 1.19 require ( github.com/JohannesKaufmann/html-to-markdown v1.4.0 github.com/alexflint/go-arg v1.4.3 + github.com/charmbracelet/log v0.4.0 + github.com/go-resty/resty/v2 v2.13.1 github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c + github.com/gosimple/slug v1.14.0 github.com/imdario/mergo v0.3.16 github.com/sirupsen/logrus v1.9.2 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/spf13/afero v1.11.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + github.com/subosito/gotenv v1.6.0 github.com/tidwall/gjson v1.16.0 + github.com/yuin/goldmark v1.7.2 + go.abhg.dev/goldmark/frontmatter v0.2.0 + golang.org/x/oauth2 v0.18.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( + cloud.google.com/go/compute v1.24.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + github.com/BurntSushi/toml v1.2.1 // indirect github.com/PuerkitoBio/goquery v1.8.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/go.sum b/go.sum index 90a1478..2797f0d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/JohannesKaufmann/html-to-markdown v1.4.0 h1:uaIPDub6VrBsQP0r5xKjpPo9lxMcuQF1L1pT6BiBdmw= github.com/JohannesKaufmann/html-to-markdown v1.4.0/go.mod h1:3p+lDUqSw+cxolZl7OINYzJ70JHXogXjyCl9UnMQ5gU= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= @@ -8,21 +14,78 @@ github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4Pnl github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= +github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c h1:iyaGYbCmcYK0Ja9a3OUa2Fo+EaN0cbLu0eKpBwPFzc8= github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= +github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -32,12 +95,33 @@ github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -45,10 +129,21 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc= +github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ5JnxRw= +go.abhg.dev/goldmark/frontmatter v0.2.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -58,8 +153,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -71,26 +170,48 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/main.go b/main.go index 9a8adc7..323ff3f 100644 --- a/main.go +++ b/main.go @@ -1,425 +1,83 @@ -// When running, use `go run .` package main import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" + "io/fs" - htmltomd "github.com/JohannesKaufmann/html-to-markdown" - "github.com/alexflint/go-arg" - mdlib "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/parser" - "github.com/imdario/mergo" - "github.com/joho/godotenv" - "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - "github.com/tidwall/gjson" + "github.com/charmbracelet/log" + "github.com/slashtechno/cross-blogger/cmd" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/subosito/gotenv" ) -func main() { - godotenv.Load(".env") - arg.MustParse(&Args) - - logrus.SetOutput(os.Stdout) - logrus.SetFormatter(&logrus.TextFormatter{PadLevelText: true, DisableQuote: true, ForceColors: Args.LogColor, DisableColors: !Args.LogColor}) - if Args.LogLevel == "debug" { - logrus.SetLevel(logrus.DebugLevel) - // Enable line numbers in debug logs - Doesn't help too much since a fatal error still needs to be debugged - logrus.SetReportCaller(true) - } else if Args.LogLevel == "info" { - logrus.SetLevel(logrus.InfoLevel) - } else if Args.LogLevel == "warning" { - logrus.SetLevel(logrus.WarnLevel) - } else if Args.LogLevel == "error" { - logrus.SetLevel(logrus.ErrorLevel) - } else if Args.LogLevel == "fatal" { - logrus.SetLevel(logrus.FatalLevel) - } else { - logrus.SetLevel(logrus.InfoLevel) - } +var cfgFile string - switch { - case Args.GoogleOauth != nil: - _, err := getAccessToken() - if err != nil { - logrus.Fatal(err) - } - case Args.Publish != nil: - var ( - title string - html string - markdown string - err error - ) - switch { - case Args.Publish.Blogger != nil: - title, html, markdown, err = getBloggerPost(Args.Publish.Blogger.BlogAddress, Args.Publish.Blogger.PostAddress) - if err != nil { - logrus.Fatal(err) - } - case Args.Publish.File != nil: - title, html, markdown, err = getFilePost(Args.Publish.File.Filepath, Args.Publish.Title) - if err != nil { - logrus.Fatal(err) - } - default: - // Could add an interactive mode here for user-friendliness - logrus.Fatal("No subcommand specified") - } - if Args.Publish.DryRun { - logrus.Debugf("Title: %s | HTML: %s | Markdown: %s", title, html, markdown) - } else { - err = publishPost(title, html, markdown, Args.Publish.Destinations) - } - if err != nil { - logrus.Fatal(err) - } - } +func init() { + cobra.OnInitialize(initConfig) + cmd.RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "config.toml", "config file path") } -func publishPost(title string, html string, markdown string, destinations map[string]string) error { - for destination, destinationSpecifier := range destinations { - switch destination { - case "blogger": - // Get the blog ID - blogId, err := getBlogId(destinationSpecifier) - if err != nil { - return err - } - // Publish to Blogger - logrus.Info("Publishing to Blogger") - url := "https://www.googleapis.com/blogger/v3/blogs/" + blogId + "/posts/" - payloadMap := map[string]interface{}{ - "kind": "blogger#post", - "blog": map[string]string{ - "id": blogId, - }, - "title": title, - "content": html, - } - accessToken, err := getAccessToken() - if err != nil { - return err - } - _, err = request(url, "POST", accessToken, payloadMap) - if err != nil { - return err - } - case "markdown": - logrus.Info("Publishing to Markdown") - cleanPathToFile := filepath.Clean(destinationSpecifier) - // Open the file in write-only mode (600) with append and create - file, err := os.OpenFile(cleanPathToFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) - if err != nil { - return err - } - // Write markdown to file - _, err = file.WriteString(markdown) - if err != nil { - return err - } - // Close the file - err = file.Close() - if err != nil { - return err - } - - } - } - return nil -} -func storeRefreshToken() (string, error) { // Rename to getRefreshToken(), perhaps? - err := checkNeededFlags(map[string]string{"clientId": Args.ClientId, "clientSecret": Args.ClientSecret}) - if err != nil { - return "", err - } - // Get the authorization code from the user - url := "https://accounts.google.com/o/oauth2/v2/auth?client_id=" + Args.ClientId + "&redirect_uri=https%3A%2F%2Foauthcodeviewer.netlify.app&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fblogger&response_type=code&access_type=offline&prompt=consent" - // Open the URL in the default browser - err = open.Run(url) - fmt.Println("If the link didn't open, please manually go to the following link in your browser:") - // Print the URL - fmt.Printf("\n%v\n\n", url) - if err != nil { - return "", err - } - fmt.Println("Input the authorization code below") - authorizationCode, err := singleLineInput() - if err != nil { - return "", err - } - - // Get refresh token using the authorization code given by the user - url = "https://oauth2.googleapis.com/token?client_id=" + Args.ClientId + "&client_secret=" + Args.ClientSecret + "&code=" + authorizationCode + "&redirect_uri=https%3A%2F%2Foauthcodeviewer.netlify.app&grant_type=authorization_code" - // Send a POST request to the URL with no authorization headers - resultBody, err := request(url, "POST", "", nil) - if err != nil { - return "", err - } - googleRefreshToken := gjson.Get(resultBody, "refresh_token").String() - logrus.Debugf("Refresh token: %s", googleRefreshToken) - // Merge the new environment variable with the existing environment variables using Mergo - env := map[string]string{"REFRESH_TOKEN": googleRefreshToken} - oldEnv, err := godotenv.Read() - if err != nil { - return "", err - } - err = mergo.Merge(&env, oldEnv) - // for key, value := range oldEnv { - // env[key] = value - // } - - if err != nil { - return "", err - } - - logrus.Debugf("New environment variables: %v | Old enviroment variables %v", env, oldEnv) - // May want to use filepath.Join() here - err = godotenv.Write(env, ".env") - if err != nil { - return "", err - } - return googleRefreshToken, nil -} - -func getAccessToken() (string, error) { - err := checkNeededFlags(map[string]string{"clientId": Args.ClientId, "clientSecret": Args.ClientSecret}) - if err != nil { - return "", err - } - var googleRefreshToken string - // Check if there is a refresh token present - if Args.RefreshToken == "" { - logrus.Print("No refresh token found. Please input the following information to get a refresh token.\n") - googleRefreshToken, err = storeRefreshToken() - if err != nil { - return "", err - } +func initConfig() { + // Load a .env file if it exists + gotenv.Load() + // Tell Viper to use the prefix "CROSS_BLOGGER" for environment variables + viper.SetEnvPrefix("CROSS_BLOGGER") + // log.Debug(cfgFile) + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) } else { - googleRefreshToken = Args.RefreshToken - - } - - // Get access token using the refresh token - url := "https://oauth2.googleapis.com/token?client_id=" + Args.ClientId + "&client_secret=" + Args.ClientSecret + "&refresh_token=" + googleRefreshToken + "&redirect_uri=https%3A%2F%2Foauthcodeviewer.netlify.app&grant_type=refresh_token" - // Send a POST request to the URL with no authorization headers - resultBody, err := request(url, "POST", "", nil) - if err != nil { - return "", err - } - // Get the authorization token - accessToken := gjson.Get(resultBody, "access_token").String() - if accessToken == "" { - return "", errors.New("no access token found") - } else { - logrus.Debugf("Access token: %s", accessToken) - - } - return accessToken, nil -} - -func singleLineInput() (string, error) { - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return "", err + // Use config.yaml in the current working directory. + viper.SetConfigFile("./config.toml") } - input = strings.TrimSpace(input) - // fmt.Print("\n") - return input, nil -} -func request(url string, requestType string, bearerAuth string, payloadMap map[string]interface{}) (string, error) { - // Send a request to the URL, with the URL which was passed to the function - var req *http.Request - var err error - // If payloadMap is nil, don't send a payload - if payloadMap == nil { - req, err = http.NewRequest(requestType, url, nil) - if err != nil { - return "", err - } + if err := viper.ReadInConfig(); err == nil { + log.Debug("", "config file:", viper.ConfigFileUsed()) } else { - // logrus.Debugf("Payload map: %v", payloadMap) - payloadBytes, err := json.Marshal(payloadMap) - if err != nil { - return "", err - } - payload := strings.NewReader(string(payloadBytes)) - req, err = http.NewRequest(requestType, url, payload) - if err != nil { - return "", err - } - } - - // If the bearerAuth parameter is true, set the Authorization header with an access token - if bearerAuth != "" { - req.Header.Add("Authorization", "Bearer "+bearerAuth) - } - // Make the actual request - res, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - // Convert the result body to a string and then return it - resultBodyBytes, err := io.ReadAll(res.Body) - if err != nil { - return "", err - } - err = res.Body.Close() - if err != nil { - return "", err - } - // Debug the status code - // logrus.Debugf("Sending %s request to %s with payload %v, bearer authorization %s. Got status code %d", requestType, url, payloadMap, bearerAuth, res.StatusCode) - return string(resultBodyBytes), nil -} - -func getBlogId(blogAddress string) (string, error) { - // Send a GET request to the URL with bearer authorization - accessToken, err := getAccessToken() - logrus.Debugf("Blog address: %s", blogAddress) - url := "https://www.googleapis.com/blogger/v3/blogs/byurl?url=https%3A%2F%2F" + blogAddress - if err != nil { - return "", err - } - resultBody, err := request(url, "GET", accessToken, nil) - if err != nil { - return "", err - } - // logrus.Debugf("Result body: %s", resultBody) - // Get the blog ID - id := gjson.Get(resultBody, "id").String() - if id == "" { - return "", errors.New("no blog ID found") - } - return id, nil -} - -func getBloggerPost(blogAddress string, postAddress string) (string, string, string, error) { - path := strings.Replace(postAddress, blogAddress, "", 1) - blogID, err := getBlogId(blogAddress) - logrus.Debugf("Blog ID: %s | Path: %s", blogID, path) - if err != nil { - return "", "", "", err - } - accessToken, err := getAccessToken() - if err != nil { - return "", "", "", err - } - // https://www.googleapis.com/blogger/v3/blogs/[BLOG_ID]/posts/bypath?path=/{YEAR}/{MONTH}/{POST}.html - url := "https://www.googleapis.com/blogger/v3/blogs/" + blogID + "/posts/bypath?path=" + path - resultBody, err := request(url, "GET", accessToken, nil) - if err != nil { - return "", "", "", err - } - html := gjson.Get(resultBody, "content").String() - title := gjson.Get(resultBody, "title").String() - markdown, err := htmltomd.NewConverter("", true, nil).ConvertString(html) - if title == "" && html == "" && markdown == "" { - logrus.Debug(url) - return "", "", "", errors.New("no post found") - } - if err != nil { - return "", "", "", err - } - return title, html, markdown, nil -} - -func getFilePost(pathToFile string, defaultTitle string) (string, string, string, error) { - // If the file path is empty, ask for it (might be a good idea to remove this) - if pathToFile == "" { - fmt.Println("Enter the path to the file") - var err error - pathToFile, err = singleLineInput() - if err != nil { - return "", "", "", err - } - } - - cleanPathToFile := filepath.Clean(pathToFile) - - // Check if the file exists - _, err := os.Stat(cleanPathToFile) - if errors.Is(err, os.ErrNotExist) { - return "", "", "", errors.New("file does not exist") - } - - // Read the file - fileBytes, err := os.ReadFile(cleanPathToFile) - if err != nil { - return "", "", "", err - } - fileContent := string(fileBytes) - logrus.Debug("The file was read successfully") - var ( - title string - html string - markdown string - ) + // If the config file is not found, create a file, write the default values and exit + // Since viper.ConfigFileNotFoundError doesn't always work, also use fs.PathError + if _, ok := err.(*fs.PathError); ok { + log.Debug("Config file not found, creating a new one") + // Destinations + viper.SetDefault("destinations", []map[string]interface{}{ + { + "name": "blog", + "type": "blogger", + "blog_url": "https://example.com", + "overwrite": false, + }, + { + "name": "otherblog", + "type": "markdown", + "content_dir": "content", + "overwrite": false, + }, + }) + // Sources + viper.SetDefault("sources", []map[string]interface{}{ + { + "name": "someblog", + "type": "blogger", + "blog_url": "https://example.com", + }, + { + "name": "aBlogInMarkdown", + "type": "markdown", + "content_dir": "content", + }, + }) - // Check file extension - fileExtension := filepath.Ext(pathToFile) - switch fileExtension { - case ".html", ".htm": - // Set HTML to the file content - html = fileContent - // Convert to Markdown - markdown, err = htmltomd.NewConverter("", true, nil).ConvertString(fileContent) - if err != nil { - return "", "", "", err + if err := viper.WriteConfigAs(cfgFile); err != nil { + log.Fatal("Failed to write config file:", err) + } + log.Fatal("Failed to read config file. Created a config file with default values. Please edit the file and run the command again.", "path", cfgFile) + } else { + log.Fatal("Failed to read config file:", err) } - - case ".md", ".markdown": - // Set Markdown to the file content - markdown = fileContent - // Convert to HTML - extensions := parser.CommonExtensions | parser.AutoHeadingIDs - parser := parser.NewWithExtensions(extensions) - html = string(mdlib.ToHTML([]byte(fileContent), parser, nil)) - - case ".txt": - // Not sure if plain text should be supported but it can easily be removed later - - // Set Markdown to the file content - markdown = fileContent - // Convert to HTML - extensions := parser.CommonExtensions | parser.AutoHeadingIDs - parser := parser.NewWithExtensions(extensions) - html = string(mdlib.ToHTML([]byte(fileContent), parser, nil)) - default: - return "", "", "", errors.New("file extension not supported") - } - if defaultTitle != "" { - title = defaultTitle - } else { - // Get the file name without the extension - fileName := filepath.Base(pathToFile) - fileNameWithoutExtension := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - // Replace underscores with spaces (Might be a good idea to make this optional) - title = strings.ReplaceAll(fileNameWithoutExtension, "_", " ") } - return title, html, markdown, nil } -func checkNeededFlags(flags map[string]string) error { - message := "The following must be set" - for name, value := range flags { - if value == "" { - message += "\n- " + name - } - if message != "The following must be set" { - return errors.New(message) - } - } - return nil +func main() { + log.SetLevel(log.DebugLevel) + cmd.Execute() } - -// Check if the file exists -> Check file extension -> Convert to the other formats -> Check if user-defined title is set -> If user-defined title is not set, use the file name as the title -> Return the title, HTML, and Markdown and hopefully no errors. diff --git a/pkg/oauth/google.go b/pkg/oauth/google.go new file mode 100644 index 0000000..865485f --- /dev/null +++ b/pkg/oauth/google.go @@ -0,0 +1,90 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + + "github.com/charmbracelet/log" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// Config holds the configuration for the Google OAuth2 client. +type Config struct { + ClientID string + ClientSecret string + Port string +} + +// GetToken starts the OAuth2 flow, opens a temporary web server to handle the callback, +// and returns the refresh token as a string. +func GetGoogleRefreshToken(cfg Config) (string, error) { + // Configure the Google OAuth2 client. + googleOauthConfig := &oauth2.Config{ + RedirectURL: "http://localhost:8080/callback", + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Scopes: []string{"https://www.googleapis.com/auth/blogger"}, + Endpoint: google.Endpoint, + } + + // This channel will receive the refresh token when the auth flow is done. + tokenCh := make(chan string) + + // This is the handler for the login route. + http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + url := googleOauthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) + }) + + // This is the handler for the callback route. + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + token, err := googleOauthConfig.Exchange(context.Background(), code) + if err != nil { + log.Fatal(err) + } + + // Send the refresh token to the channel. + tokenCh <- token.RefreshToken + + // Send a response to close the window + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("")) + }) + + // Start the server in a separate goroutine. + go http.ListenAndServe(":"+cfg.Port, nil) + fmt.Println("Go to http://localhost:" + cfg.Port + "/login to get the refresh token.") + + // Wait for the refresh token and return it. + return <-tokenCh, nil +} + +// Return an access token from a refresh token +func GetGoogleAccessToken(cfg Config, refreshToken string) (string, error) { + // Configure the Google OAuth2 client. + googleOauthConfig := &oauth2.Config{ + RedirectURL: "http://localhost:" + cfg.Port + "/callback", + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Scopes: []string{"https://www.googleapis.com/auth/blogger"}, + Endpoint: google.Endpoint, + } + + // Create a new token from the refresh token + token := &oauth2.Token{ + RefreshToken: refreshToken, + } + + // Get a new access token + tokenSource := googleOauthConfig.TokenSource(context.Background(), token) + newToken, err := tokenSource.Token() + if err != nil { + log.Fatal(err) + } + + return newToken.AccessToken, nil +}