packages feed

nbparts-0.1.0.0: src/Nbparts/Unpack/Mime.hs

module Nbparts.Unpack.Mime where

import Control.Monad.State.Strict (MonadState)
import Control.Monad.State.Strict qualified as State
import Crypto.Hash qualified as Hash
import Data.ByteArray qualified as ByteArray
import Data.ByteString (ByteString)
import Data.ByteString.Base64.URL qualified as Base64.URL
import Data.ByteString.Char8 qualified as ByteString
import Data.Coerce (coerce)
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.Encoding qualified as Text
import Nbparts.Types
  ( UnembeddedMimeAttachments (UnembeddedMimeAttachments),
    UnembeddedMimeBundle (UnembeddedMimeBundle),
    UnembeddedMimeData (BinaryData, JsonData, TextualData),
  )
import Network.Mime qualified as Mime
import System.FilePath ((</>))

unembedMimeAttachments :: (MonadState [(FilePath, ByteString)] m) => FilePath -> Ipynb.MimeAttachments -> m UnembeddedMimeAttachments
unembedMimeAttachments subdir mimeAtts = UnembeddedMimeAttachments <$> Map.traverseWithKey go (coerce mimeAtts)
  where
    go :: (MonadState [(FilePath, ByteString)] m) => Text -> Ipynb.MimeBundle -> m UnembeddedMimeBundle
    go attName = unembedMimeBundleWith (genFileName attName) subdir

    -- Include the attachment name in the bytes before hashing so that
    -- different attachments with the same content have different hashes.
    -- This is needed so that we know which attachment to reference when
    -- repacking the notebook.
    genFileName :: Text -> Text -> ByteString -> FilePath
    genFileName attName mimeType bytes = binaryOutputFileName mimeType (Text.encodeUtf8 attName <> bytes)

unembedMimeBundleWith ::
  (MonadState [(FilePath, ByteString)] m) =>
  (Text -> ByteString -> FilePath) ->
  FilePath ->
  Ipynb.MimeBundle ->
  m UnembeddedMimeBundle
unembedMimeBundleWith genFileName subdir mimeBundle = UnembeddedMimeBundle <$> Map.traverseWithKey go (coerce mimeBundle)
  where
    go :: (MonadState [(FilePath, ByteString)] m) => Ipynb.MimeType -> Ipynb.MimeData -> m UnembeddedMimeData
    go mimeType mimeData = do
      let (uMimeData, maybeExport) = unembedMimeDataWith genFileName subdir mimeType mimeData
      State.modify (Maybe.maybe id (:) maybeExport)
      pure uMimeData

unembedMimeBundle :: (MonadState [(FilePath, ByteString)] m) => FilePath -> Ipynb.MimeBundle -> m UnembeddedMimeBundle
unembedMimeBundle = unembedMimeBundleWith binaryOutputFileName

unembedMimeDataWith ::
  (Text -> ByteString -> FilePath) ->
  FilePath ->
  Ipynb.MimeType ->
  Ipynb.MimeData ->
  (UnembeddedMimeData, Maybe (FilePath, ByteString))
unembedMimeDataWith genFileName subdir mimetype (Ipynb.BinaryData bytes) =
  let filename = genFileName mimetype bytes
      filepath = subdir </> filename
   in (BinaryData filepath, Just (filepath, bytes))
unembedMimeDataWith _ _ _ (Ipynb.TextualData text) = (TextualData text, Nothing)
unembedMimeDataWith _ _ _ (Ipynb.JsonData value) = (JsonData value, Nothing)

unembedMimeData ::
  FilePath ->
  Ipynb.MimeType ->
  Ipynb.MimeData ->
  (UnembeddedMimeData, Maybe (FilePath, ByteString))
unembedMimeData = unembedMimeDataWith binaryOutputFileName

binaryOutputFileName :: Text -> ByteString -> FilePath
binaryOutputFileName mimetype bytes =
  digestToBase64 (Hash.hashWith Hash.SHA256 bytes)
    <> case extensionFromMimeType mimetype of
      Nothing -> ""
      Just ext -> "." <> Text.unpack ext
  where
    digestToBase64 :: Hash.Digest a -> String
    digestToBase64 digest = ByteString.unpack . Base64.URL.encode $ ByteArray.convert digest

extensionFromMimeType :: Text -> Maybe Text
extensionFromMimeType "text/plain" = Just "txt"
extensionFromMimeType "image/tiff" = Just "tiff"
extensionFromMimeType "image/jpeg" = Just "jpg"
extensionFromMimeType mt = do
  exts <- Map.lookup (Text.encodeUtf8 mt) Mime.defaultExtensionMap
  Maybe.listToMaybe exts