packages feed

emanote-1.4.0.0: src/Emanote/Model/Graph.hs

module Emanote.Model.Graph where

import Commonmark.Extensions.WikiLink qualified as WL
import Data.IxSet.Typed ((@+), (@=))
import Data.IxSet.Typed qualified as Ix
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Tree (Forest, Tree (Node))
import Emanote.Model.Calendar qualified as Calendar
import Emanote.Model.Link.Rel qualified as Rel
import Emanote.Model.Link.Resolve qualified as Resolve
import Emanote.Model.Meta (lookupRouteMeta)
import Emanote.Model.Note qualified as MN
import Emanote.Model.Note qualified as N
import Emanote.Model.Type (Model, modelIndexRoute, modelNotes, modelRels, parentLmlRoute)
import Emanote.Route qualified as R
import Emanote.Route.SiteRoute qualified as SR
import Optics.Operators as Lens ((^.))
import Relude hiding (empty)
import Text.Pandoc.Definition qualified as B

-- TODO: Do breadth-first instead of depth-first
modelFolgezettelAncestorTree :: Model -> R.LMLRoute -> Forest R.LMLRoute
modelFolgezettelAncestorTree model =
  fst . usingState mempty . go
  where
    go :: (MonadState (Set R.LMLRoute) m) => R.LMLRoute -> m (Forest R.LMLRoute)
    go r =
      fmap catMaybes . forM (folgezettelParentsFor model r) $ \parentR -> do
        gets (parentR `Set.member`) >>= \case
          True -> pure Nothing -- already visited
          False -> do
            modify $ Set.insert parentR
            sub <- go parentR
            pure $ Just $ Node parentR sub

folgezettelParentsFor :: Model -> R.LMLRoute -> [R.LMLRoute]
folgezettelParentsFor model r = do
  let folgezettelBacklinks =
        backlinkRels r model
          & filter (isFolgezettel . (^. Rel.relTo))
          <&> (^. Rel.relFrom)
      -- Handle reverse folgezettel links (`#[[..]]`) here
      folgezettelFrontlinks =
        frontlinkRels r model
          & mapMaybe
            ( \rel ->
                lookupNoteByWikiLink model (rel ^. Rel.relFrom)
                  <=< selectReverseFolgezettel
                  . (^. Rel.relTo)
                  $ rel
            )
      -- Folders are automatically made a folgezettel
      folgezettelFolder =
        maybeToList $ do
          guard $ folderFolgezettelEnabledFor model r
          parentLmlRoute model r
      folgezettelParents =
        ordNub
          $ mconcat
            [ folgezettelBacklinks
            , folgezettelFrontlinks
            , folgezettelFolder
            ]
   in folgezettelParents
  where
    isFolgezettel = \case
      Rel.URTWikiLink (WL.WikiLinkBranch, _wl) ->
        True
      _ ->
        False
    selectReverseFolgezettel :: Rel.UnresolvedRelTarget -> Maybe WL.WikiLink
    selectReverseFolgezettel = \case
      Rel.URTWikiLink (WL.WikiLinkTag, wl) -> Just wl
      _ -> Nothing

folgezettelChildrenFor :: Model -> R.LMLRoute -> [R.LMLRoute]
folgezettelChildrenFor model r = do
  let folgezettelBacklinks =
        backlinkRels r model
          & filter (isReverseFolgezettel . (^. Rel.relTo))
          <&> (^. Rel.relFrom)
      folgezettelFrontlinks =
        frontlinkRels r model
          & mapMaybe
            ( \rel ->
                lookupNoteByWikiLink model (rel ^. Rel.relFrom)
                  <=< selectFolgezettel
                  . (^. Rel.relTo)
                  $ rel
            )
      -- Folders are automatically made a folgezettel
      folgezettelFolderChildren :: [R.LMLRoute] =
        if folderFolgezettelEnabledFor model r
          then
            let isIndexRoute = r == modelIndexRoute model
                parentR :: Maybe (R.R 'R.Folder) = if isIndexRoute then Nothing else Just (R.withLmlRoute coerce r)
                allowed route = not isIndexRoute || (route /= r) -- Exclude index.md from being a children of itself
                notes = Ix.toList $ (model ^. modelNotes) @= parentR
                rs = notes <&> (^. MN.noteRoute) & filter allowed
             in rs
          else []
      folgezettelChildren =
        ordNub
          $ mconcat
            [ folgezettelBacklinks
            , folgezettelFrontlinks
            , folgezettelFolderChildren
            ]
   in folgezettelChildren
  where
    isReverseFolgezettel = \case
      Rel.URTWikiLink (WL.WikiLinkTag, _wl) ->
        True
      _ ->
        False
    selectFolgezettel :: Rel.UnresolvedRelTarget -> Maybe WL.WikiLink
    selectFolgezettel = \case
      Rel.URTWikiLink (WL.WikiLinkBranch, wl) -> Just wl
      _ -> Nothing

{- | Returns a tree of all folgezettel notes, starting from the given route.

If a note has two parents, that note will be positioned in *both* the sub-trees.

Cycles are discarded.

Returns the tree, while considering unvisited (disconnected) notes as sibling trees.

`fromRoute` node itself is ignored fro the returned tree.
-}
folgezettelTreesFrom :: Model -> R.LMLRoute -> Forest R.LMLRoute
folgezettelTreesFrom model fromRoute =
  snd $ usingState (mempty :: Forest R.LMLRoute) $ do
    let loop fromR rs = do
          let (tree@(Node _ children), unvisited) = usingState rs $ go Set.empty fromR
          if fromR == fromRoute
            then modify (<> children) -- ignore `fromRoute` itself
            else modify (<> one tree)
          case nonEmpty (Set.toList unvisited) of
            Nothing -> pass
            Just ne -> loop (head ne) unvisited
    loop fromRoute allRoutes
  where
    allRoutes = Set.fromList $ fmap N._noteRoute $ Ix.toList $ model ^. modelNotes
    -- Run in a state monad of unvisited routes, taking visited routes as argument.
    go :: (MonadState (Set R.LMLRoute) m) => Set R.LMLRoute -> R.LMLRoute -> m (Tree R.LMLRoute)
    go visitedRoutes route
      | route `Set.member` visitedRoutes = do
          modify $ Set.delete route
          pure $ Node route []
      | otherwise = do
          modify $ Set.delete route
          let children = folgezettelChildrenFor model route
          cs <- go (Set.insert route visitedRoutes) `traverse` children
          pure $ Node route cs

folderFolgezettelEnabledFor :: Model -> R.LMLRoute -> Bool
folderFolgezettelEnabledFor model r =
  lookupRouteMeta defaultValue ("emanote" :| ["folder-folgezettel"]) r model
  where
    -- We don't treat the top-level folder as folgezettel, to support the "flat
    -- list of notes" use case (popularized by original neuron).
    defaultValue = r /= modelIndexRoute model

lookupNoteByWikiLink :: Model -> R.LMLRoute -> WL.WikiLink -> Maybe R.LMLRoute
lookupNoteByWikiLink model currentRoute wl = do
  (_, note) <- leftToMaybe <=< getFound $ Resolve.resolveWikiLinkMustExist model currentRoute wl
  pure $ note ^. MN.noteRoute
  where
    getFound :: Rel.ResolvedRelTarget a -> Maybe a
    getFound = \case
      Rel.RRTFound x -> Just x
      _ -> Nothing

modelLookupBacklinks :: R.LMLRoute -> Model -> [(R.LMLRoute, NonEmpty [B.Block])]
modelLookupBacklinks r model =
  sortOn (Calendar.backlinkSortKey model . fst)
    $ groupNE
    $ backlinkRels r model
    <&> \rel ->
      (rel ^. Rel.relFrom, rel ^. Rel.relCtx)
  where
    groupNE :: forall a b. (Ord a) => [(a, b)] -> [(a, NonEmpty b)]
    groupNE =
      Map.toList . foldl' f Map.empty
      where
        f :: Map a (NonEmpty b) -> (a, b) -> Map a (NonEmpty b)
        f m (x, y) =
          case Map.lookup x m of
            Nothing -> Map.insert x (one y) m
            Just ys -> Map.insert x (ys <> one y) m

-- | Rels pointing *to* this route
backlinkRels :: R.LMLRoute -> Model -> [Rel.Rel]
backlinkRels r model =
  let allPossibleLinks = Rel.unresolvedRelsTo $ toModelRoute r
      rels = Ix.toList $ (model ^. modelRels) @+ allPossibleLinks
   in filter isUnambiguousBacklink rels
  where
    toModelRoute = R.ModelRoute_LML R.LMLView_Html
    -- Check that 'rel' points to 'r' even after resolving any ambiguous wiki links
    isUnambiguousBacklink rel = isJust $ do
      SR.SiteRoute_ResourceRoute (SR.ResourceRoute_LML _ r') <-
        Rel.getResolved $ Resolve.resolveRel model rel
      guard $ r == r'
      pass

-- | Rels pointing *from* this route
frontlinkRels :: R.LMLRoute -> Model -> [Rel.Rel]
frontlinkRels r model =
  maybeToMonoid $ do
    pure $ Ix.toList $ (model ^. modelRels) @= r