nbparts-0.1.1.0: src/Nbparts/Unpack.hs
module Nbparts.Unpack where
import Control.Arrow (left)
import Control.Monad qualified as Monad
import Control.Monad.Error.Class (MonadError (throwError), liftEither)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Trans.Maybe (runMaybeT)
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty (confIndent)
import Data.Aeson.Encode.Pretty qualified as AesonPretty
import Data.Aeson.KeyMap qualified as Aeson.KeyMap
import Data.ByteString qualified as ByteString
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Ipynb qualified as Ipynb
import Data.Map qualified as Map
import Data.Maybe qualified as Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Data.Yaml qualified as Yaml
import Nbparts.Types
( Format (FormatJson, FormatMarkdown, FormatYaml),
NotebookMetadata (NotebookMetadata),
SomeNotebook,
UnpackError (UnpackParseNotebookError, UnpackUnsupportedNotebookFormat),
defManifest,
formatExtension,
withSomeNotebook,
)
import Nbparts.Types.Manifest qualified as Manifest
import Nbparts.Unpack.Metadata (collectMetadata, extractNotebookVersion)
import Nbparts.Unpack.Outputs (collectOutputs)
import Nbparts.Unpack.Sources (collectSources)
import Nbparts.Unpack.Sources.Markdown (sourcesToMarkdown)
import Nbparts.Util.Prompt (confirm)
import System.Directory qualified as Directory
import System.FilePath ((<.>), (</>))
import System.IO (stderr)
import Text.Libyaml qualified as Libyaml
minNotebookFormat :: (Int, Int)
minNotebookFormat = (4, 0)
data UnpackOptions = UnpackOptions
{ notebookPath :: FilePath,
sourcesFormat :: Format,
metadataFormat :: Format,
outputsFormat :: Format,
outputPath :: Maybe FilePath,
force :: Bool
}
unpack :: (MonadError UnpackError m, MonadIO m) => UnpackOptions -> m ()
unpack opts = fmap (Maybe.fromMaybe ()) . runMaybeT $ do
let exportDirectory = Maybe.fromMaybe (mkDefOutputPath opts.notebookPath) opts.outputPath
-- Check if we should overwrite the export directory (if it already exists and is non-empty).
cont <-
liftIO $
if opts.force
then pure True
else
shouldConfirmOverwrite exportDirectory >>= \case
True -> confirm $ "Directory \"" <> Text.pack exportDirectory <> "\" exists and is not empty. Overwrite?"
False -> pure True
Monad.unless cont $ liftIO (Text.hPutStrLn stderr "Operation cancelled: directory not overwritten")
Monad.guard cont
-- Parse the notebook.
notebookBytes <- liftIO $ LazyByteString.readFile opts.notebookPath
(nb :: SomeNotebook) <-
liftEither $
left (UnpackParseNotebookError . Text.pack) $
Aeson.eitherDecode notebookBytes
let withNb = withSomeNotebook nb
-- Check notebook version.
let format = withNb extractNotebookVersion
Monad.when (format < minNotebookFormat) $ throwError (UnpackUnsupportedNotebookFormat format)
-- Collect manifest, sources, metadata and outputs.
let manifest =
defManifest
{ Manifest.sourcesFormat = opts.sourcesFormat,
Manifest.metadataFormat = opts.metadataFormat,
Manifest.outputsFormat = opts.outputsFormat
}
metadata <- liftEither $ withNb collectMetadata
let sourceMediaSubdir = "media"
(sources, sourceMedia) <- liftEither $ withNb (collectSources sourceMediaSubdir)
let outputMediaSubdir = "outputs-media"
(outputs, outputMedia) <- liftEither $ withNb (collectOutputs outputMediaSubdir)
liftIO $ Directory.createDirectoryIfMissing True exportDirectory
let yamlOptions = Yaml.setStringStyle nbpartsYamlStringStyle Yaml.defaultEncodeOptions
let mkExportPath :: FilePath -> Format -> FilePath
mkExportPath fname fmt = exportDirectory </> fname <.> formatExtension fmt
-- Export manifest.
let manifestPath = mkExportPath "nbparts" FormatYaml
liftIO $ Yaml.encodeFile manifestPath manifest
-- Export sources.
let sourcesPath = mkExportPath "sources" opts.sourcesFormat
case opts.sourcesFormat of
FormatYaml -> liftIO $ Yaml.encodeFileWith yamlOptions sourcesPath sources
FormatJson -> liftIO $ exportJson sourcesPath sources
FormatMarkdown -> do
let lang = Maybe.fromMaybe "" $ extractLanguage metadata
markdownText <- liftEither $ sourcesToMarkdown lang sources
liftIO $ Text.writeFile sourcesPath markdownText
-- Export source media.
liftIO $ Monad.unless (null sourceMedia) $ do
Directory.createDirectoryIfMissing True (exportDirectory </> sourceMediaSubdir)
mapM_ (\(path, bytes) -> ByteString.writeFile (exportDirectory </> path) bytes) sourceMedia
-- Export metadata.
let metadataPath = mkExportPath "metadata" opts.metadataFormat
liftIO $ case opts.metadataFormat of
FormatYaml -> Yaml.encodeFileWith yamlOptions metadataPath metadata
FormatJson -> exportJson metadataPath metadata
_ -> error $ "Illegal metadata format: " <> show opts.metadataFormat
-- Export outputs.
let outputsPath = mkExportPath "outputs" opts.outputsFormat
liftIO $ case opts.outputsFormat of
FormatYaml -> Yaml.encodeFileWith yamlOptions outputsPath outputs
FormatJson -> exportJson outputsPath outputs
_ -> error $ "Illegal outputs format: " <> show opts.outputsFormat
-- Export output media.
liftIO $ Monad.unless (null outputMedia) $ do
Directory.createDirectoryIfMissing True (exportDirectory </> outputMediaSubdir)
mapM_ (\(path, bytes) -> ByteString.writeFile (exportDirectory </> path) bytes) outputMedia
liftIO $ Text.putStrLn ("Unpacked \"" <> Text.pack opts.notebookPath <> "\" to \"" <> Text.pack exportDirectory <> "\"")
shouldConfirmOverwrite :: FilePath -> IO Bool
shouldConfirmOverwrite exportDirectory = do
exists <- Directory.doesDirectoryExist exportDirectory
if exists
then
-- Check that the directory is not empty.
not . null <$> Directory.listDirectory exportDirectory
else
pure False
mkDefOutputPath :: FilePath -> FilePath
mkDefOutputPath nbPath = nbPath <.> "nbparts"
hasOnlyOneNewline :: Text -> Bool
hasOnlyOneNewline text = Text.length (Text.filter (== '\n') text) == 1
hasNewlineSuffix :: Text -> Bool
hasNewlineSuffix = Text.isSuffixOf "\n"
-- Based on Yaml's default string style.
nbpartsYamlStringStyle :: Text -> (Libyaml.Tag, Libyaml.Style)
nbpartsYamlStringStyle s
| hasOnlyOneNewline s && hasNewlineSuffix s = (Libyaml.NoTag, Libyaml.DoubleQuoted)
| "\n" `Text.isInfixOf` s = (Libyaml.NoTag, Libyaml.Literal)
| Yaml.isSpecialString s = (Libyaml.NoTag, Libyaml.SingleQuoted)
| otherwise = (Libyaml.NoTag, Libyaml.PlainNoTag)
extractLanguage :: NotebookMetadata -> Maybe Text
extractLanguage (NotebookMetadata _ _ (Ipynb.JSONMeta nbMeta) _) = do
kernelspec <- Map.lookup "kernelspec" nbMeta
langFromKernelSpec kernelspec
langFromKernelSpec :: Aeson.Value -> Maybe Text
langFromKernelSpec (Aeson.Object obj) = case Aeson.KeyMap.lookup "language" obj of
Just (Aeson.String lang) -> Just lang
_ -> Nothing
langFromKernelSpec _ = Nothing
exportJson :: (Aeson.ToJSON (a)) => FilePath -> a -> IO ()
exportJson fp = LazyByteString.writeFile fp . AesonPretty.encodePretty' aesonPrettyConfig
aesonPrettyConfig :: AesonPretty.Config
aesonPrettyConfig = AesonPretty.defConfig {confIndent = AesonPretty.Spaces 2}