Crossposting to dev.to
Last week I migrated all my articles from dev.to. I wrote a Haskell script to do that (duh!)
Although I'm posting in CollectedNotes now I do want to continue crossposting on dev.to. So is time for another script 🤓
The Script
Very similar to the previous one. But now it accepts a few config options like the dev.to article series, tags and what article to publish (defaulting to the latest public one).
👇 The help text produced by optparse-applicative
$ ./collected-to-dev.hs --help
Post a note from CollectedNotes to dev.to
Usage: collected-to-dev.hs --site-id SITE_ID --email EMAIL --token TOKEN
--api-key API_KEY [--publish] [--tags TAGS]
[--series SERIES] [--note NOTE_SLUG]
Available options:
--site-id SITE_ID CollectedNotes notes site ID
--email EMAIL Your CollectedNotes email
--token TOKEN CollectedNotes API Token
--api-key API_KEY dev.to API Key
--publish Publish the article (creates as a draft by default)
--tags TAGS Comma separated list of tags (max 4)
--series SERIES dev.to article series
--note NOTE_SLUG The path of the note to publish
(collectednotes.com/my-site/<NOTE_SLUG>). If missing
the latest note will be published
-h,--help Show this help text
collected-to-dev.hs
(see on GitHub)
#!/usr/bin/env stack
{- stack
script
--resolver lts-13.27
--package aeson
--package sort
--package lens-aeson
--package wreq
--package lens
--package text
--package bytestring
--package parsec
--package optparse-applicative
-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
import Control.Applicative (optional)
import Control.Lens hiding ((.=))
import Data.Aeson
import Data.Aeson.Lens
import qualified Data.ByteString.Internal as BS
import qualified Data.ByteString.Lazy.Internal as LazyBS
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NonEmpty
import Data.Maybe
import Data.Ord
import Data.Sort
import qualified Data.Text as Text
import Data.Text (Text)
import GHC.Generics
import Network.Wreq
import qualified Options.Applicative as Opt
import Options.Applicative ((<**>))
import Text.Parsec hiding (optional)
import Text.Parsec.Char
import Text.Parsec.Text
data DevtoArticle = DevtoArticle
{ title :: Text,
published :: Bool,
main_image :: Text,
canonical_url :: Text,
tags :: Maybe (NonEmpty Text),
series :: Maybe Text,
body_markdown :: Text
}
deriving (Show, Generic, ToJSON)
eitherToMaybe :: Either e a -> Maybe a
eitherToMaybe (Right a) = Just a
eitherToMaybe _ = Nothing
stripTitleAndFirstImage :: Parser Text
stripTitleAndFirstImage = do
_ <- spaces
_ <- manyTill anyChar $ try newline
_ <- spaces
before <- manyTill anyChar $ try $ string "!["
_ <- manyTill anyChar $ try $ char ')'
Text.pack . (before <>) <$> many anyChar
parseTags :: Text -> [Text]
parseTags =
take 4 . filter (not . Text.null) . fmap Text.strip . Text.splitOn ","
collectedToDev :: Config -> CollectedArticle -> Either ParseError DevtoArticle
collectedToDev Config {..} (title, body, _, url, image, _) =
DevtoArticle title optPublished image url tags' optSeries . Text.strip
<$> parse stripTitleAndFirstImage "" body
where
tags' = NonEmpty.nonEmpty =<< parseTags <$> optTags
sixth :: (a, b, c, d, e, f) -> f
sixth (_, _, _, _, _, f) = f
processPosts :: Config -> Response LazyBS.ByteString -> [DevtoArticle]
processPosts config =
mapMaybe eitherToMaybe . fmap (collectedToDev config) . findNoteToPublish . extract
where
findNoteToPublish = case optNote config of
Just note -> filter ((note ==) . sixth)
Nothing -> take 1
type CollectedArticle =
( Text, -- title
Text, -- body
Text, -- visibility ("public" | "private")
Text, -- url
Text, -- poster (picture)
Text -- path (ie. slug)
)
extract :: Response LazyBS.ByteString -> [CollectedArticle]
extract r =
let Fold article =
(,,,,,) <$> Fold (key "title" . _String)
<*> Fold (key "body" . _String)
<*> Fold (key "visibility" . _String)
<*> Fold (key "url" . _String)
<*> Fold (key "poster" . _String)
<*> Fold (key "path" . _String)
in r ^.. responseBody . values . article
getCollectedArticles :: Config -> IO (Response LazyBS.ByteString)
getCollectedArticles Config {..} =
getWith reqConfg url
where
-- curl -H "Authorization: your@email.com your-secret-token" \
-- -H "Accept: application/json" \
-- -H "Content-Type: application/json" \
-- https://collectednotes.com/sites/1/notes
reqConfg =
defaults
& header "Authorization" .~ [email <> " " <> collectednotesToken]
& header "Content-Type" .~ ["application/json"]
& header "Accept" .~ ["application/json"]
url = "https://collectednotes.com/sites/" <> siteId <> "/notes"
postDevtoArticle :: Config -> DevtoArticle -> IO ()
postDevtoArticle Config {..} article = do
putStrLn $ Text.unpack $ "Posting: " <> title article
r <- postWith reqConfg url body
putStrLn $ Text.unpack $ successMsg r
where
-- curl -X POST -H "Content-Type: application/json" \
-- -H "api-key: API_KEY" \
-- -d '{"article":{"title":"Title","body_markdown":"Body","published":true,"tags":["discuss", "javascript"]}}' \
-- https://dev.to/api/articles
reqConfg =
defaults
& header "api-key" .~ [devtoApiKey]
& header "Content-Type" .~ ["application/json"]
& header "Accept" .~ ["application/json"]
url = "https://dev.to/api/articles"
body = object ["article" .= article]
successMsg r =
"✓ dev.to post created: ("
<> fromMaybe "<no response body>" (r ^? responseBody . key "url" . _String)
<> ")\n"
data Config = Config
{ siteId :: String,
email :: BS.ByteString,
collectednotesToken :: BS.ByteString,
devtoApiKey :: BS.ByteString,
optPublished :: Bool,
optTags :: Maybe Text,
optSeries :: Maybe Text,
optNote :: Maybe Text
}
deriving (Show)
configP :: Opt.Parser Config
configP =
Config
<$> Opt.strOption
( Opt.long "site-id"
<> Opt.metavar "SITE_ID"
<> Opt.help "CollectedNotes notes site ID"
)
<*> Opt.strOption
( Opt.long "email"
<> Opt.metavar "EMAIL"
<> Opt.help "Your CollectedNotes email"
)
<*> Opt.strOption
( Opt.long "token"
<> Opt.metavar "TOKEN"
<> Opt.help "CollectedNotes API Token"
)
<*> Opt.strOption
( Opt.long "api-key"
<> Opt.metavar "API_KEY"
<> Opt.help "dev.to API Key"
)
<*> Opt.switch
( Opt.long "publish"
<> Opt.help "Publish the article (creates as a draft by default)"
<> Opt.showDefault
)
<*> optional
( Opt.strOption
( Opt.long "tags"
<> Opt.metavar "TAGS"
<> Opt.help "Comma separated list of tags (max 4)"
)
)
<*> optional
( Opt.strOption
( Opt.long "series"
<> Opt.metavar "SERIES"
<> Opt.help "dev.to article series"
)
)
<*> optional
( Opt.strOption
( Opt.long "note"
<> Opt.metavar "NOTE_SLUG"
<> Opt.help "The path of the note to publish (collectednotes.com/my-site/<NOTE_SLUG>). If missing the latest note will be published"
)
)
opts :: Opt.ParserInfo Config
opts =
Opt.info
(configP <**> Opt.helper)
( Opt.fullDesc
<> Opt.header "Post a note from CollectedNotes to dev.to"
)
main :: IO ()
main = do
config <- Opt.execParser opts
r <- getCollectedArticles config
processPosts config r
& ( \case
[a] -> postDevtoArticle config a
[] -> putStrLn "404: Note not found"
_ -> putStrLn "500: Found more than one note"
)
Happy and safe coding! 🦄