packages feed

okf-core-0.2.0.0: src/Okf/Bundle.hs

-- | Bundle-level discovery for OKF concept documents.
module Okf.Bundle
  ( BundleError (..),
    Concept,
    LogFile (..),
    conceptFromDocument,
    conceptDescription,
    conceptDocument,
    conceptIdOf,
    conceptResource,
    conceptSourcePath,
    conceptTags,
    conceptTitle,
    conceptType,
    findConcept,
    findConceptsByDocumentId,
    isReservedMarkdownFile,
    serializeConcept,
    walkBundle,
    walkLogs,
    writeBundle,
  )
where

import Control.Exception (IOException, try)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Okf.ConceptId
import Okf.Document
import Okf.Log
import Okf.Prelude
import System.Directory
  ( createDirectoryIfMissing,
    doesDirectoryExist,
    listDirectory,
  )
import System.FilePath ((</>))
import System.FilePath qualified as FilePath
import System.IO.Error (ioeGetErrorString)

-- | A parsed concept document discovered in a bundle.
data Concept = Concept
  { id :: !ConceptId,
    sourcePath :: !FilePath,
    document :: !OKFDocument,
    type_ :: !Text,
    title :: !(Maybe Text),
    description :: !(Maybe Text),
    resource :: !(Maybe Text),
    tags :: ![Text]
  }
  deriving stock (Generic, Eq, Show)

-- | Filesystem or parser failures while walking a bundle.
data BundleError
  = InvalidConceptPath FilePath ConceptIdError
  | InvalidConceptDocument FilePath DocumentParseError
  | BundleIoError FilePath Text
  deriving stock (Generic, Eq, Show)

-- | A parsed @log.md@ reserved file discovered in a bundle.
data LogFile = LogFile
  { logSourcePath :: !FilePath,
    logContent :: !Log
  }
  deriving stock (Generic, Eq, Show)

-- | Discover and parse every non-reserved Markdown concept in a bundle.
walkBundle :: FilePath -> IO (Either BundleError [Concept])
walkBundle root = do
  discovered <- discoverMarkdownFiles root ""
  case discovered of
    Left bundleError -> pure (Left bundleError)
    Right paths -> do
      results <- mapM (readConcept root) paths
      pure (List.sortOn (renderConceptId . conceptIdOf) <$> sequenceA results)

-- | Discover and parse every @log.md@ reserved file in a bundle.
walkLogs :: FilePath -> IO (Either BundleError [LogFile])
walkLogs root = do
  discovered <- discoverLogFiles root ""
  case discovered of
    Left bundleError -> pure (Left bundleError)
    Right paths -> do
      results <- mapM (readLog root) paths
      pure (List.sortOn logSourcePath <$> sequenceA results)

-- | Find a concept by identifier in an already walked bundle.
findConcept :: ConceptId -> [Concept] -> Maybe Concept
findConcept conceptId =
  List.find (\concept -> conceptIdOf concept == conceptId)

-- | Find concepts whose frontmatter carries the given document handle. When a
-- field is supplied, only that key is examined; otherwise every frontmatter
-- value is searched. All matches are returned so callers can report ambiguity.
findConceptsByDocumentId :: Maybe Text -> Text -> [Concept] -> [Concept]
findConceptsByDocumentId fieldFilter handle =
  filter conceptMatches
  where
    conceptMatches concept =
      case fieldFilter of
        Just fieldName ->
          valueMatches (frontmatterLookup fieldName (documentFrontmatter concept))
        Nothing ->
          any (valueMatches . Just) (KeyMap.elems (fields (documentFrontmatter concept)))
    valueMatches (Just (String value)) = Text.strip value == handle
    valueMatches _ = False
    documentFrontmatter concept = frontmatter (conceptDocument concept)

-- | Extract a concept identifier without colliding with Prelude's `id`.
conceptIdOf :: Concept -> ConceptId
conceptIdOf Concept {id = conceptId} = conceptId

-- | Bundle-relative path the concept was read from or would be written to.
conceptSourcePath :: Concept -> FilePath
conceptSourcePath Concept {sourcePath} = sourcePath

-- | Parsed Markdown document backing the concept.
conceptDocument :: Concept -> OKFDocument
conceptDocument Concept {document} = document

-- | Required @type@ frontmatter field projected as text, or empty when invalid.
conceptType :: Concept -> Text
conceptType Concept {type_} = type_

conceptTitle :: Concept -> Maybe Text
conceptTitle Concept {title} = title

conceptDescription :: Concept -> Maybe Text
conceptDescription Concept {description} = description

conceptResource :: Concept -> Maybe Text
conceptResource Concept {resource} = resource

conceptTags :: Concept -> [Text]
conceptTags Concept {tags} = tags

-- | Reserved Markdown filenames are not normal concept documents.
isReservedMarkdownFile :: FilePath -> Bool
isReservedMarkdownFile path =
  FilePath.takeFileName path `List.elem` ["index.md", "log.md"]

discoverMarkdownFiles :: FilePath -> FilePath -> IO (Either BundleError [FilePath])
discoverMarkdownFiles root relativeDir = do
  let absoluteDir = root </> relativeDir
      displayDir = if null relativeDir then root else relativeDir
  listed <- tryBundleIo displayDir (listDirectory absoluteDir)
  case listed of
    Left bundleError -> pure (Left bundleError)
    Right entries -> do
      discovered <-
        for
          (List.sort entries)
          ( \entry -> do
              let relativePath = relativeDir </> entry
                  absolutePath = root </> relativePath
              isDirectory <- tryBundleIo relativePath (doesDirectoryExist absolutePath)
              case isDirectory of
                Left bundleError -> pure (Left bundleError)
                Right True -> discoverMarkdownFiles root relativePath
                Right False ->
                  pure
                    ( Right
                        [ FilePath.normalise relativePath
                        | FilePath.takeExtension entry == ".md",
                          not (isReservedMarkdownFile entry)
                        ]
                    )
          )
      pure (concat <$> sequenceA discovered)

discoverLogFiles :: FilePath -> FilePath -> IO (Either BundleError [FilePath])
discoverLogFiles root relativeDir = do
  let absoluteDir = root </> relativeDir
      displayDir = if null relativeDir then root else relativeDir
  listed <- tryBundleIo displayDir (listDirectory absoluteDir)
  case listed of
    Left bundleError -> pure (Left bundleError)
    Right entries -> do
      discovered <-
        for
          (List.sort entries)
          ( \entry -> do
              let relativePath = relativeDir </> entry
                  absolutePath = root </> relativePath
              isDirectory <- tryBundleIo relativePath (doesDirectoryExist absolutePath)
              case isDirectory of
                Left bundleError -> pure (Left bundleError)
                Right True -> discoverLogFiles root relativePath
                Right False ->
                  pure
                    ( Right
                        [ FilePath.normalise relativePath
                        | entry == "log.md"
                        ]
                    )
          )
      pure (concat <$> sequenceA discovered)

-- | Write every concept to @root/\<conceptId\>.md@, creating parent directories
-- as needed, using 'serializeDocument' for the file contents. Existing files for
-- the given concepts are overwritten; files NOT corresponding to a supplied
-- concept are left untouched (a producer wanting a pristine output directory
-- should clear it first). Does not validate; run 'Okf.Validation.validateBundle'
-- first if you want referential-integrity guarantees.
writeBundle :: FilePath -> [Concept] -> IO ()
writeBundle root concepts =
  mapM_ writeConcept concepts
  where
    writeConcept concept = do
      let relativePath = conceptIdToFilePath (conceptIdOf concept)
          absolutePath = root </> relativePath
      createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)
      Text.IO.writeFile absolutePath (serializeConcept concept)

-- | Serialize a single concept's document to a Markdown string.
serializeConcept :: Concept -> Text
serializeConcept = serializeDocument . document

readConcept :: FilePath -> FilePath -> IO (Either BundleError Concept)
readConcept root relativePath = do
  loaded <- tryBundleIo relativePath (Text.IO.readFile (root </> relativePath))
  pure
    ( do
        content <- loaded
        conceptId <- first (InvalidConceptPath relativePath) (conceptIdFromFilePath relativePath)
        document <- first (InvalidConceptDocument relativePath) (parseDocument content)
        pure (conceptAt conceptId relativePath document)
    )

readLog :: FilePath -> FilePath -> IO (Either BundleError LogFile)
readLog root relativePath = do
  loaded <- tryBundleIo relativePath (Text.IO.readFile (root </> relativePath))
  pure
    ( do
        content <- loaded
        pure (LogFile {logSourcePath = relativePath, logContent = parseLog content})
    )

tryBundleIo :: FilePath -> IO value -> IO (Either BundleError value)
tryBundleIo path action = do
  result <- try action
  pure
    ( case result of
        Right value -> Right value
        Left (exception :: IOException) -> Left (BundleIoError path (Text.pack (ioeGetErrorString exception)))
    )

-- | Build a 'Concept' from its identity and document. The typed projection
-- fields (@type_@, @title@, @description@, @resource@, @tags@) are derived from
-- the document's frontmatter, so they can never disagree with it. The source
-- path is derived from the concept ID. Use this when assembling concepts in
-- memory (for 'writeBundle' or 'Okf.Validation.validateBundle').
conceptFromDocument :: ConceptId -> OKFDocument -> Concept
conceptFromDocument conceptId =
  conceptAt conceptId (conceptIdToFilePath conceptId)

-- | Build a 'Concept' with an explicit on-disk source path (used by the reader).
conceptAt :: ConceptId -> FilePath -> OKFDocument -> Concept
conceptAt conceptId relativePath document =
  Concept
    { id = conceptId,
      sourcePath = relativePath,
      document,
      type_ = textField "type" (frontmatter document),
      title = optionalTextField "title" (frontmatter document),
      description = optionalTextField "description" (frontmatter document),
      resource = optionalTextField "resource" (frontmatter document),
      tags = tagsField (frontmatter document)
    }

textField :: Text -> Frontmatter -> Text
textField key frontmatter =
  fromMaybe "" (optionalTextField key frontmatter)

optionalTextField :: Text -> Frontmatter -> Maybe Text
optionalTextField key frontmatter =
  case frontmatterLookup key frontmatter of
    Just (String value) -> Just value
    _ -> Nothing

tagsField :: Frontmatter -> [Text]
tagsField frontmatter =
  case frontmatterLookup "tags" frontmatter of
    Just (Array values) -> foldMap tagValue values
    Just (String value) -> [value]
    _ -> []
  where
    tagValue (String value) = [value]
    tagValue _ = []