packages feed

zim-to-markdown-0.3.0.2: src/ZimToMarkdown/Links.hs

-- | Resolution of Zim links against notebook context.
module ZimToMarkdown.Links (
    LinkContext (..),
    isExternalTarget,
    renderZimLink,
) where

import Data.Char (toLower)
import Data.List (find)
import Data.Maybe (fromMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import System.FilePath (
    addExtension,
    dropExtension,
    normalise,
    takeDirectory,
    takeExtension,
    (</>),
 )

import ZimToMarkdown.Domain (RelativePath (..))
import ZimToMarkdown.Inline (renderMarkdownPath)
import ZimToMarkdown.Paths (relativePathFrom, toPosixPath)

-- | The notebook context needed to resolve internal Zim links.
data LinkContext = LinkContext
    { linkCurrentPage :: RelativePath
    , linkKnownPages :: Set.Set RelativePath
    }
    deriving (Eq, Show)

{- | Converts the inner text of a Zim link into a Markdown link. The label
renderer converts inline markup inside an explicit link label.
-}
renderZimLink :: LinkContext -> (Text -> Text) -> Text -> Text
renderZimLink context renderLabel rawInner =
    let inner = Text.strip rawInner
     in if Text.null inner
            then ""
            else
                let (target, label) = splitLink inner
                 in if Text.null target
                        then escapeLabel (maybe "" renderLabel label)
                        else
                            if isExternalTarget target
                                then renderExternal renderLabel target label
                                else
                                    if isPseudoLink target
                                        then renderPlain target label
                                        else renderInternal context renderLabel target label

-- | Tests whether a link target points outside the notebook.
isExternalTarget :: Text -> Bool
isExternalTarget target =
    any
        (`Text.isPrefixOf` target)
        ["http://", "https://", "ftp://", "ftps://", "mailto:", "file:"]
        || "://" `Text.isInfixOf` target

{- | Tests whether a target looks like a user or host identifier rather than
a notebook page, such as an SSH destination.
-}
isPseudoLink :: Text -> Bool
isPseudoLink target =
    "@" `Text.isInfixOf` target
        && not ("/" `Text.isInfixOf` target)
        && not ("\\" `Text.isInfixOf` target)
        && not (" " `Text.isInfixOf` target)

renderPlain :: Text -> Maybe Text -> Text
renderPlain target = maybe target stripInlineVerbatim

stripInlineVerbatim :: Text -> Text
stripInlineVerbatim text =
    let trimmed = Text.strip text
     in fromMaybe
            trimmed
            ( Text.stripPrefix "''" trimmed
                >>= Text.stripSuffix "''"
            )

splitLink :: Text -> (Text, Maybe Text)
splitLink inner =
    let (target, labelWithPipe) = Text.breakOn "|" inner
     in ( Text.strip target
        , if Text.null labelWithPipe
            then Nothing
            else Just (Text.strip (Text.drop 1 labelWithPipe))
        )

renderExternal :: (Text -> Text) -> Text -> Maybe Text -> Text
renderExternal renderLabel target label =
    "["
        <> escapeLabel (maybe target renderLabel label)
        <> "]("
        <> renderMarkdownPath target
        <> ")"

renderInternal :: LinkContext -> (Text -> Text) -> Text -> Maybe Text -> Text
renderInternal context renderLabel target label =
    let (pathPart, anchor) = Text.breakOn "#" target
        cleanTarget = Text.strip pathPart
        display =
            case label of
                Just value -> renderLabel value
                Nothing -> stripLinkPrefix target
     in if Text.null cleanTarget
            then escapeLabel display
            else
                let resolved = resolveTarget context (Text.unpack cleanTarget)
                    withAnchor = resolved <> Text.unpack anchor
                 in "["
                        <> escapeLabel display
                        <> "]("
                        <> renderMarkdownPath (Text.pack withAnchor)
                        <> ")"

stripLinkPrefix :: Text -> Text
stripLinkPrefix text =
    let cleaned =
            Text.dropWhile
                (\character -> character == ':' || character == '+')
                (Text.strip text)
     in if Text.null cleaned then Text.strip text else cleaned

escapeLabel :: Text -> Text
escapeLabel = Text.replace "]" "\\]"

resolveTarget :: LinkContext -> FilePath -> FilePath
resolveTarget context rawTarget =
    let currentPage = unRelativePath (linkCurrentPage context)
        currentDirectory = takeDirectory currentPage
        target = stripSourceExtension (normalise rawTarget)
        (candidates, fallback) =
            targetCandidates currentPage currentDirectory target
        chosen =
            fromMaybe
                fallback
                (find (isKnownPage context) candidates)
        markdownPath = addExtension chosen "md"
        relativePath =
            normalise
                (relativePathFrom currentDirectory markdownPath)
     in toPosixPath relativePath

targetCandidates :: FilePath -> FilePath -> FilePath -> ([FilePath], FilePath)
targetCandidates currentPage currentDirectory target =
    case target of
        ':' : rest ->
            let rooted =
                    normalise
                        (dropLeadingSeparators (namespacePath rest))
             in ([rooted], rooted)
        '+' : rest ->
            let child =
                    normalise
                        (dropExtension currentPage </> namespacePath rest)
             in ([child], child)
        _ ->
            let normalizedTarget = namespacePath target
                parents = parentDirectories currentDirectory
                candidates =
                    [ normalise (directory </> normalizedTarget)
                    | directory <- parents
                    ]
                        <> [normalise normalizedTarget]
                fallback =
                    if currentDirectory == "."
                        then normalise normalizedTarget
                        else
                            normalise
                                (currentDirectory </> normalizedTarget)
             in (candidates, fallback)

namespacePath :: FilePath -> FilePath
namespacePath =
    map (\character -> if character == ':' then '/' else character)

parentDirectories :: FilePath -> [FilePath]
parentDirectories directory
    | directory == "."
        || directory == "/"
        || null directory =
        []
    | otherwise = directory : parentDirectories (takeDirectory directory)

dropLeadingSeparators :: FilePath -> FilePath
dropLeadingSeparators = dropWhile (== '/')

stripSourceExtension :: FilePath -> FilePath
stripSourceExtension path =
    if map toLower (takeExtension path) == ".txt"
        then dropExtension path
        else path

isKnownPage :: LinkContext -> FilePath -> Bool
isKnownPage context candidate =
    Set.member
        (RelativePath (normalise (addExtension candidate "txt")))
        (linkKnownPages context)