packages feed

nbparts-0.1.0.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

  let sourceMediaSubdir = "media"
  let outputMediaSubdir = "outputs-media"
  liftIO $ do
    Directory.createDirectoryIfMissing True (exportDirectory </> sourceMediaSubdir)
    Directory.createDirectoryIfMissing True exportDirectory
    Directory.createDirectoryIfMissing True (exportDirectory </> outputMediaSubdir)

  -- 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
  (sources, sourceMedia) <- liftEither $ withNb (collectSources sourceMediaSubdir)
  (outputs, outputMedia) <- liftEither $ withNb (collectOutputs outputMediaSubdir)

  -- Export manifest, sources, metadata and outputs.
  let yamlOptions = Yaml.setStringStyle nbpartsYamlStringStyle Yaml.defaultEncodeOptions
  let mkExportPath :: FilePath -> Format -> FilePath
      mkExportPath fname fmt = exportDirectory </> fname <.> formatExtension fmt

  let manifestPath = mkExportPath "nbparts" FormatYaml
  liftIO $ Yaml.encodeFile manifestPath manifest

  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
  liftIO $ mapM_ (\(path, bytes) -> ByteString.writeFile (exportDirectory </> path) bytes) sourceMedia

  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

  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
  liftIO $ 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}