packages feed

okf-core-0.1.0.0: src/Okf/Graph.hs

-- | Markdown link extraction and concept graph construction.
module Okf.Graph
  ( Edge (..),
    Graph (..),
    Node (..),
    buildGraph,
    extractConceptLinks,
    danglingReferences,
    duplicateConceptIds,
  )
where

import CMarkGFM qualified
import Control.Monad (foldM)
import Data.Aeson (ToJSON (..), object, (.=))
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Okf.Bundle
import Okf.ConceptId
import Okf.Document (body)
import Okf.Prelude hiding ((.=))
import System.FilePath ((</>))
import System.FilePath qualified as FilePath

-- | A graph node for one concept.
data Node = Node
  { id :: !ConceptId,
    label :: !Text,
    type_ :: !Text,
    description :: !(Maybe Text),
    resource :: !(Maybe Text),
    tags :: ![Text]
  }
  deriving stock (Generic, Eq, Show)

-- | A directed concept-to-concept link.
data Edge = Edge
  { source :: !ConceptId,
    target :: !ConceptId
  }
  deriving stock (Generic, Eq, Ord, Show)

-- | Bundle graph data with presentation-free nodes and edges.
data Graph = Graph
  { nodes :: ![Node],
    edges :: ![Edge]
  }
  deriving stock (Generic, Eq, Show)

instance ToJSON Node where
  toJSON Node {id = nodeId, label = nodeLabel, type_ = nodeType, description = nodeDescription, resource = nodeResource, tags = nodeTags} =
    object
      [ "id" .= nodeId,
        "label" .= nodeLabel,
        "type" .= nodeType,
        "description" .= nodeDescription,
        "resource" .= nodeResource,
        "tags" .= nodeTags
      ]

instance ToJSON Edge where
  toJSON Edge {source, target} =
    object
      [ "source" .= source,
        "target" .= target
      ]

instance ToJSON Graph where
  toJSON Graph {nodes, edges} =
    object
      [ "nodes" .= nodes,
        "edges" .= edges
      ]

-- | Build a graph, excluding links whose targets are not known concepts.
buildGraph :: [Concept] -> Graph
buildGraph concepts =
  Graph
    { nodes = conceptNode <$> sortedConcepts,
      edges = Set.toAscList knownEdges
    }
  where
    sortedConcepts = List.sortOn (renderConceptId . conceptIdOf) concepts
    knownIds = Set.fromList (conceptIdOf <$> concepts)
    knownEdges =
      Set.fromList
        [ Edge {source = conceptIdOf concept, target}
        | concept <- concepts,
          target <- extractConceptLinks concept,
          target `Set.member` knownIds
        ]

-- | Extract OKF concept links from a concept body.
extractConceptLinks :: Concept -> [ConceptId]
extractConceptLinks concept =
  foldMap (resolveLink concept) (extractMarkdownLinks (body (conceptDocument concept)))

-- | Every @(source, target)@ pair where a document links to a @.md@ concept ID
-- that is not present in the bundle. These are the edges 'buildGraph' silently
-- drops. An empty list means every internal link resolves to a real concept.
danglingReferences :: [Concept] -> [(ConceptId, ConceptId)]
danglingReferences concepts =
  [ (conceptIdOf concept, target)
  | concept <- concepts,
    target <- extractConceptLinks concept,
    not (target `Set.member` knownIds)
  ]
  where
    knownIds = Set.fromList (conceptIdOf <$> concepts)

-- | Concept IDs that appear more than once in a concept list. Always empty for
-- a bundle read from disk (paths are unique) but possible for an in-memory
-- producer assembling concepts before writing.
duplicateConceptIds :: [Concept] -> [ConceptId]
duplicateConceptIds concepts =
  [ conceptId
  | (conceptId, count) <- Map.toList counts,
    count > (1 :: Int)
  ]
  where
    counts = Map.fromListWith (+) [(conceptIdOf concept, 1) | concept <- concepts]

conceptNode :: Concept -> Node
conceptNode concept =
  Node
    { id = conceptIdOf concept,
      label = fromMaybe (renderConceptId (conceptIdOf concept)) (conceptTitle concept),
      type_ = conceptType concept,
      description = conceptDescription concept,
      resource = conceptResource concept,
      tags = conceptTags concept
    }

extractMarkdownLinks :: Text -> [Text]
extractMarkdownLinks markdown =
  walk (CMarkGFM.commonmarkToNode [] [] markdown)
  where
    walk (CMarkGFM.Node _ nodeType childNodes) =
      case nodeType of
        CMarkGFM.LINK url _title -> [url]
        _ -> foldMap walk childNodes

resolveLink :: Concept -> Text -> [ConceptId]
resolveLink concept rawUrl
  | isExternalUrl rawUrl = []
  | FilePath.takeExtension cleanPath /= ".md" = []
  | otherwise = maybe [] (either (const []) pure . conceptIdFromFilePath) bundleRelativePath
  where
    cleanPath = Text.unpack (stripUrlSuffix rawUrl)
    sourceDirectory = FilePath.takeDirectory (conceptIdToFilePath (conceptIdOf concept))
    bundleRelativePath
      | "/" `Text.isPrefixOf` rawUrl = collapseBundlePath (dropWhile (== '/') cleanPath)
      | otherwise = collapseBundlePath (sourceDirectory </> cleanPath)

stripUrlSuffix :: Text -> Text
stripUrlSuffix =
  Text.takeWhile (\char -> char /= '#' && char /= '?')

isExternalUrl :: Text -> Bool
isExternalUrl rawUrl =
  let lower = Text.toLower rawUrl
   in "http://" `Text.isPrefixOf` lower
        || "https://" `Text.isPrefixOf` lower
        || "mailto:" `Text.isPrefixOf` lower

collapseBundlePath :: FilePath -> Maybe FilePath
collapseBundlePath =
  fmap FilePath.joinPath . foldM step [] . FilePath.splitDirectories
  where
    step [] "." = Just []
    step acc "." = Just acc
    step [] ".." = Nothing
    step acc ".." = Just (init acc)
    step acc segment = Just (acc <> [segment])