~samwhited/blogsync

dc119433ec7be7c08c50148969e797ae707e58e0 — Sam Whited 1 year, 9 months ago 1e06884
blogsync: factor out publish function body

This change will make it easier to call publish with different options
from preview in the future without having to do something jank like pass
command line flags as strings. Good separation of concerns means the Run
function should only exist to handle converting command line flags to
actual function arguments (or an options struct in this case).
3 files changed, 281 insertions(+), 261 deletions(-)

M main.go
M preview.go
M publish.go
M main.go => main.go +1 -1
@@ 115,7 115,7 @@ To get a token, use the "token" command.`, os.Args[0], userConfig, envToken),
			collectionsCmd(client, logger, debug),
			convertCmd(logger, debug),
			previewCmd(siteConfig, logger, debug),
			publishCmd(false, siteConfig, client, logger, debug),
			publishCmd(siteConfig, client, logger, debug),
			tokenCmd(apiBase, torPort, logger, debug),

			// Help articles

M preview.go => preview.go +4 -1
@@ 202,7 202,10 @@ https://writefreely.org/
			}
			debug.Printf("logged in as: %+v", authUser)

			err = publishCmd(true, siteConfig, client, logger, debug).Exec()
			opts := newPublishOpts(siteConfig)
			opts.createCollections = true

			err = publish(opts, siteConfig, client, logger, debug)
			if err != nil {
				return err
			}

M publish.go => publish.go +276 -259
@@ 33,24 33,35 @@ type tmplData struct {
	Config Config
}

func publishCmd(createCollections bool, siteConfig Config, client *writeas.Client, logger, debug *log.Logger) *cli.Command {
	var (
		collection = ""
		del        = false
		dryRun     = false
		force      = false
		content    = "content/"
		tmpl       = defTmpl
	)
type publishOptions struct {
	createCollections bool
	del               bool
	dryRun            bool
	force             bool
	collection        string
	content           string
	tmpl              string
}

func newPublishOpts(siteConfig Config) publishOptions {
	return publishOptions{
		collection: siteConfig.Collection,
		content:    orDef(siteConfig.Content, "content/"),
		tmpl:       orDef(siteConfig.Tmpl, defTmpl),
	}
}

func publishCmd(siteConfig Config, client *writeas.Client, logger, debug *log.Logger) *cli.Command {
	opts := newPublishOpts(siteConfig)

	flags := flag.NewFlagSet("publish", flag.ContinueOnError)
	flags.BoolVar(&del, "delete", del, "Delete pages for which matching files cannot be found")
	flags.BoolVar(&dryRun, "dry-run", dryRun, "Perform a trial run with no changes made")
	flags.BoolVar(&force, "f", force, "Force publishing, even if no updates exist")
	flags.StringVar(&collection, "collection", siteConfig.Collection, "The default collection for pages that don't include `collection' in their frontmatter")
	flags.StringVar(&content, "content", orDef(siteConfig.Content, content), "A directory containing pages")
	flags.StringVar(&tmpl, "tmpl", orDef(siteConfig.Tmpl, tmpl), "A template using Go's html/template format, to load from a file use @filename")
	flags.BoolVar(&opts.del, "delete", opts.del, "Delete pages for which matching files cannot be found")
	flags.BoolVar(&opts.dryRun, "dry-run", opts.dryRun, "Perform a trial run with no changes made")
	flags.BoolVar(&opts.force, "f", opts.force, "Force publishing, even if no updates exist")
	flags.StringVar(&opts.collection, "collection", opts.collection, "The default collection for pages that don't include `collection' in their frontmatter")
	flags.StringVar(&opts.content, "content", opts.content, "A directory containing pages")
	flags.StringVar(&opts.tmpl, "tmpl", opts.tmpl, "A template using Go's html/template format, to load from a file use @filename")

	var collections []writeas.Collection
	return &cli.Command{
		Usage: "publish [options]",
		Description: fmt.Sprintf(`Publishes Markdown files to write.as.


@@ 58,273 69,279 @@ func publishCmd(createCollections bool, siteConfig Config, client *writeas.Clien
Expects an API token to be exported as $%s.`, envToken),
		Flags: flags,
		Run: func(cmd *cli.Command, args ...string) error {
			if createCollections {
				colls, err := client.GetUserCollections()
				if err != nil {
					logger.Printf("error fetching existing collections: %v", err)
				}
				collections = *colls
			return publish(opts, siteConfig, client, logger, debug)
		},
	}
}

				collections = createCollectionIfNotExist(collections, client, debug, &writeas.CollectionParams{
					Alias:       siteConfig.Collection,
					Title:       siteConfig.Title,
					Description: siteConfig.Description,
				})
			}
func publish(opts publishOptions, siteConfig Config, client *writeas.Client, logger, debug *log.Logger) error {
	var collections []writeas.Collection
	if opts.createCollections {
		colls, err := client.GetUserCollections()
		if err != nil {
			logger.Printf("error fetching existing collections: %v", err)
		}
		collections = *colls

			compiledTmpl := template.New(defTmplName).Funcs(map[string]interface{}{
				"join": path.Join,
			})
			tmplFile := strings.TrimPrefix(tmpl, "@")
			var err error
			if tmpl != tmplFile {
				// If the template argument starts with "@" it is a filename that we
				// should load.
				compiledTmpl, err = compiledTmpl.ParseFiles(tmplFile)
				if err != nil {
					return fmt.Errorf("error compiling template file %s: %v", tmplFile, err)
				}
			} else {
				tmplFile = defTmplName
				// Otherwise, it is a raw template and we should compile it.
				compiledTmpl, err = compiledTmpl.Parse(tmpl)
				if err != nil {
					return fmt.Errorf("error compiling template: %v", err)
				}
			}
		collections = createCollectionIfNotExist(collections, client, debug, &writeas.CollectionParams{
			Alias:       siteConfig.Collection,
			Title:       siteConfig.Title,
			Description: siteConfig.Description,
		})
	}

			var posts []writeas.Post
			p, err := client.GetUserPosts()
			if err != nil {
				return fmt.Errorf("error fetching users posts: %v", err)
	compiledTmpl := template.New(defTmplName).Funcs(map[string]interface{}{
		"join": path.Join,
	})
	tmplFile := strings.TrimPrefix(opts.tmpl, "@")
	var err error
	if opts.tmpl != tmplFile {
		// If the template argument starts with "@" it is a filename that we
		// should load.
		compiledTmpl, err = compiledTmpl.ParseFiles(tmplFile)
		if err != nil {
			return fmt.Errorf("error compiling template file %s: %v", tmplFile, err)
		}
	} else {
		tmplFile = defTmplName
		// Otherwise, it is a raw template and we should compile it.
		compiledTmpl, err = compiledTmpl.Parse(opts.tmpl)
		if err != nil {
			return fmt.Errorf("error compiling template: %v", err)
		}
	}

	var posts []writeas.Post
	p, err := client.GetUserPosts()
	if err != nil {
		return fmt.Errorf("error fetching users posts: %v", err)
	}
	// For now, the writeas SDK returns things with a lot of unnecessary
	// indirection that makes the library hard to use.
	// Go ahead and unwrap this and we can remove this workaround if they ever
	// fix it.
	// See: https://github.com/writeas/go-writeas/pull/19
	posts = *p

	err = blog.WalkPages(opts.content, func(pagePath string, info os.FileInfo, err error) error {
		debug.Printf("opening %s", pagePath)
		fd, err := os.Open(pagePath)
		if err != nil {
			logger.Printf("error opening %s, skipping: %v", pagePath, err)
			return nil
		}
		defer func() {
			if err := fd.Close(); err != nil {
				debug.Printf("error closing %s: %v", pagePath, err)
			}
			// For now, the writeas SDK returns things with a lot of unnecessary
			// indirection that makes the library hard to use.
			// Go ahead and unwrap this and we can remove this workaround if they ever
			// fix it.
			// See: https://github.com/writeas/go-writeas/pull/19
			posts = *p

			err = blog.WalkPages(content, func(pagePath string, info os.FileInfo, err error) error {
				debug.Printf("opening %s", pagePath)
				fd, err := os.Open(pagePath)
				if err != nil {
					logger.Printf("error opening %s, skipping: %v", pagePath, err)
					return nil
				}
				defer func() {
					if err := fd.Close(); err != nil {
						debug.Printf("error closing %s: %v", pagePath, err)
					}
				}()

				f := bufio.NewReader(fd)
				meta := make(blog.Metadata)
				header, err := meta.Decode(f)
				if err != nil {
					logger.Printf("error decoding metadata for %s, skipping: %v", pagePath, err)
					return nil
				}
				// This may seem unnecessary, but I don't plan on supporting YAML
				// headers forever to keep things simple, so go ahead and forbid
				// publishing with them to encourage people to convert their blogs over.
				if header == blog.HeaderYAML {
					logger.Printf(`file %s has a YAML header, try converting it by running "%s convert", skipping`, pagePath, os.Args[0])
					return nil
				}
		}()

				draft := meta.GetBool("draft")
				if draft {
					debug.Printf("skipping draft %s", pagePath)
					return nil
				}
		f := bufio.NewReader(fd)
		meta := make(blog.Metadata)
		header, err := meta.Decode(f)
		if err != nil {
			logger.Printf("error decoding metadata for %s, skipping: %v", pagePath, err)
			return nil
		}
		// This may seem unnecessary, but I don't plan on supporting YAML
		// headers forever to keep things simple, so go ahead and forbid
		// publishing with them to encourage people to convert their blogs over.
		if header == blog.HeaderYAML {
			logger.Printf(`file %s has a YAML header, try converting it by running "%s convert", skipping`, pagePath, os.Args[0])
			return nil
		}

				title := meta.GetString("title")
				if title == "" {
					logger.Printf("invalid or empty title in %s, skipping", pagePath)
					return nil
				}
		draft := meta.GetBool("draft")
		if draft {
			debug.Printf("skipping draft %s", pagePath)
			return nil
		}

				// Deliberately shadow collection so that we don't end up mutating
				// something outside the closure.
				collection := collection
				if col := meta.GetString("collection"); col != "" {
					collection = col
				}
		title := meta.GetString("title")
		if title == "" {
			logger.Printf("invalid or empty title in %s, skipping", pagePath)
			return nil
		}

				body, err := ioutil.ReadAll(f)
				if err != nil {
					logger.Printf("error reading body from %s, skipping: %v", pagePath, err)
					return nil
				}
				body = bytes.TrimSpace(body)
				body = blackfriday.Run(body,
					blackfriday.WithNoExtensions(),
					blackfriday.WithExtensions(
						blackfriday.CommonExtensions|blackfriday.Footnotes,
					),
					blackfriday.WithRenderer(&unwrapRenderer{
						debug: debug,
						htmlRenderer: blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{
							Flags: blackfriday.FootnoteReturnLinks,
						}),
					}))

				var bodyBuf strings.Builder
				err = compiledTmpl.ExecuteTemplate(&bodyBuf, tmplFile, tmplData{
					Body:   string(body),
					Meta:   meta,
					Config: siteConfig,
				})
				if err != nil {
					logger.Printf("error executing template for file %s: %v", pagePath, err)
					return nil
				}
				if bodyBuf.Len() == 0 {
					// Apparently write.as doesn't like posts that don't have a body.
					logger.Printf("post %s has no body, skipping", pagePath)
					return nil
				}
		// Deliberately shadow collection so that we don't end up mutating the
		// options struct.
		collection := opts.collection
		if col := meta.GetString("collection"); col != "" {
			collection = col
		}

				slug := blog.Slug(pagePath, meta)
				var existingPost *writeas.Post
				for i, post := range posts {
					var postCollection string
					if post.Collection != nil {
						postCollection = post.Collection.Alias
					}

					if slug == post.Slug && collection == postCollection {
						existingPost = &post
						posts = append(posts[:i], posts[i+1:]...)
						break
					}
				}
		body, err := ioutil.ReadAll(f)
		if err != nil {
			logger.Printf("error reading body from %s, skipping: %v", pagePath, err)
			return nil
		}
		body = bytes.TrimSpace(body)
		body = blackfriday.Run(body,
			blackfriday.WithNoExtensions(),
			blackfriday.WithExtensions(
				blackfriday.CommonExtensions|blackfriday.Footnotes,
			),
			blackfriday.WithRenderer(&unwrapRenderer{
				debug: debug,
				htmlRenderer: blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{
					Flags: blackfriday.FootnoteReturnLinks,
				}),
			}))

				created := timeOrDef(meta.GetTime("publishDate"), meta.GetTime("date"))
				createdPtr := &created
				if created.IsZero() {
					createdPtr = nil
				}
				rtl := meta.GetBool("rtl")
				lang := meta.GetString("lang")
				if lang == "" {
					lang = siteConfig.Language
				}
				updated := timeOrDef(meta.GetTime("lastmod"), created)
		var bodyBuf strings.Builder
		err = compiledTmpl.ExecuteTemplate(&bodyBuf, tmplFile, tmplData{
			Body:   string(body),
			Meta:   meta,
			Config: siteConfig,
		})
		if err != nil {
			logger.Printf("error executing template for file %s: %v", pagePath, err)
			return nil
		}
		if bodyBuf.Len() == 0 {
			// Apparently write.as doesn't like posts that don't have a body.
			logger.Printf("post %s has no body, skipping", pagePath)
			return nil
		}

				var postID, postTok string
				if existingPost != nil {
					postID = existingPost.ID
					postTok = existingPost.Token
				}
				params := &writeas.PostParams{
					ID:    postID,
					Token: postTok,

					Content:  bodyBuf.String(),
					Created:  createdPtr,
					Font:     orDef(meta.GetString("font"), "norm"),
					IsRTL:    &rtl,
					Language: &lang,
					Slug:     slug,
					Title:    title,
					Updated:  &updated,

					Collection: collection,
				}
		slug := blog.Slug(pagePath, meta)
		var existingPost *writeas.Post
		for i, post := range posts {
			var postCollection string
			if post.Collection != nil {
				postCollection = post.Collection.Alias
			}

				var skipUpdate bool
				if existingPost == nil {
					debug.Printf("publishing %s from %s", slug, pagePath)
				} else {
					if eqParams(existingPost, params) && !force {
						debug.Printf("no updates needed for %s, skipping", slug)
						skipUpdate = true
					} else {
						debug.Printf("updating /%s (%q) from %s", slug, postID, pagePath)
					}
				}
			if slug == post.Slug && collection == postCollection {
				existingPost = &post
				posts = append(posts[:i], posts[i+1:]...)
				break
			}
		}

				if !dryRun && !skipUpdate {
					if createCollections {
						collections = createCollectionIfNotExist(collections, client, debug, &writeas.CollectionParams{
							Alias: params.Collection,
							Title: params.Collection,
						})
					}
					if postID == "" {
						post, err := client.CreatePost(params)
						if err != nil {
							logger.Printf("error creating post from %s: %v", pagePath, err)
							return nil
						}
						postID = post.ID
					} else {
						// Write.as returns a generic 500 error if you set Created when
						// updating a post, even if it's unchanged.
						params.Created = nil
						post, err := client.UpdatePost(postID, postTok, params)
						if err != nil {
							logger.Printf("error updating post %q from %s: %v", postID, pagePath, err)
							return nil
						}
						postID = post.ID
					}
				}
		created := timeOrDef(meta.GetTime("publishDate"), meta.GetTime("date"))
		createdPtr := &created
		if created.IsZero() {
			createdPtr = nil
		}
		rtl := meta.GetBool("rtl")
		lang := meta.GetString("lang")
		if lang == "" {
			lang = siteConfig.Language
		}
		updated := timeOrDef(meta.GetTime("lastmod"), created)

				// Right now there is no way to check if a post is pinned, so we have to
				// assume that all posts may be pinned and always attempt to unpin them
				// then re-pin any that should actually be pinned every time.
				// This is not ideal.
				debug.Printf("attempting to unpin post %s…", slug)
				if !dryRun {
					err = client.UnpinPost(collection, &writeas.PinnedPostParams{
						ID: postID,
					})
					if err != nil {
						debug.Printf("error unpinning post %s: %v", slug, err)
					}
				}
		var postID, postTok string
		if existingPost != nil {
			postID = existingPost.ID
			postTok = existingPost.Token
		}
		params := &writeas.PostParams{
			ID:    postID,
			Token: postTok,

			Content:  bodyBuf.String(),
			Created:  createdPtr,
			Font:     orDef(meta.GetString("font"), "norm"),
			IsRTL:    &rtl,
			Language: &lang,
			Slug:     slug,
			Title:    title,
			Updated:  &updated,

				pin, pinExists := meta["pin"]
				ipin, pinInt := pin.(int64)
				if pinExists && pinInt {
					debug.Printf("attempting to pin post %s to position %d…", slug, int(ipin))
					if !dryRun {
						err = client.PinPost(collection, &writeas.PinnedPostParams{
							ID:       postID,
							Position: int(ipin),
						})
						if err != nil {
							debug.Printf("error pinning post %s to position %d: %v", slug, int(ipin), err)
						}
					}
			Collection: collection,
		}

		var skipUpdate bool
		if existingPost == nil {
			debug.Printf("publishing %s from %s", slug, pagePath)
		} else {
			if eqParams(existingPost, params) && !opts.force {
				debug.Printf("no updates needed for %s, skipping", slug)
				skipUpdate = true
			} else {
				debug.Printf("updating /%s (%q) from %s", slug, postID, pagePath)
			}
		}

		if !opts.dryRun && !skipUpdate {
			if opts.createCollections {
				collections = createCollectionIfNotExist(collections, client, debug, &writeas.CollectionParams{
					Alias: params.Collection,
					Title: params.Collection,
				})
			}
			if postID == "" {
				post, err := client.CreatePost(params)
				if err != nil {
					logger.Printf("error creating post from %s: %v", pagePath, err)
					return nil
				}
				postID = post.ID
			} else {
				// Write.as returns a generic 500 error if you set Created when
				// updating a post, even if it's unchanged.
				params.Created = nil
				post, err := client.UpdatePost(postID, postTok, params)
				if err != nil {
					logger.Printf("error updating post %q from %s: %v", postID, pagePath, err)
					return nil
				}
				postID = post.ID
			}
		}

				return nil
		// Right now there is no way to check if a post is pinned, so we have to
		// assume that all posts may be pinned and always attempt to unpin them
		// then re-pin any that should actually be pinned every time.
		// This is not ideal.
		debug.Printf("attempting to unpin post %s…", slug)
		if !opts.dryRun {
			err = client.UnpinPost(collection, &writeas.PinnedPostParams{
				ID: postID,
			})
			if err != nil {
				return err
				debug.Printf("error unpinning post %s: %v", slug, err)
			}
		}

			// Delete remaining posts for which we couldn't find a matching file.
			for _, post := range posts {
				if del {
					debug.Printf("no file found matching post %q, deleting", post.Slug)
					if !dryRun {
						err := client.DeletePost(post.ID, post.Token)
						if err != nil {
							logger.Printf("error deleting post %q: %v", post.Slug, err)
						}
					}
					continue
		pin, pinExists := meta["pin"]
		ipin, pinInt := pin.(int64)
		if pinExists && pinInt {
			debug.Printf("attempting to pin post %s to position %d…", slug, int(ipin))
			if !opts.dryRun {
				err = client.PinPost(collection, &writeas.PinnedPostParams{
					ID:       postID,
					Position: int(ipin),
				})
				if err != nil {
					debug.Printf("error pinning post %s to position %d: %v", slug, int(ipin), err)
				}
				logger.Printf("no file found matching post %q, re-run with --delete to remove", post.Slug)
			}
			return nil
		},
		}

		return nil
	})
	if err != nil {
		return err
	}

	// Delete remaining posts for which we couldn't find a matching file.
	for _, post := range posts {
		if opts.del {
			debug.Printf("no file found matching post %q, deleting", post.Slug)
			if !opts.dryRun {
				err := client.DeletePost(post.ID, post.Token)
				if err != nil {
					logger.Printf("error deleting post %q: %v", post.Slug, err)
				}
			}
			continue
		}
		logger.Printf("no file found matching post %q, re-run with --delete to remove", post.Slug)
	}

	return nil
}

func createCollectionIfNotExist(colls []writeas.Collection, client *writeas.Client, debug *log.Logger, coll *writeas.CollectionParams) []writeas.Collection {