packages feed

neuron-1.0.0.0: src/lib/Neuron/Zettelkasten/ID.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE NoImplicitPrelude #-}

module Neuron.Zettelkasten.ID
  ( ZettelID (..),
    InvalidID (..),
    unsafeMkZettelID,
    indexZid,
    parseZettelID,
    allowedSpecialChars,
    idParser,
    idParser',
    getZettelID,
    zettelIDSourceFileName,
  )
where

import Data.Aeson
import Data.Aeson.Types (toJSONKeyText)
import qualified Data.Text as T
import Neuron.Reader.Type (ZettelFormat, zettelFormatToExtension)
import Relude
import System.FilePath
import qualified Text.Megaparsec as M
import qualified Text.Megaparsec.Char as M
import Text.Megaparsec.Simple
import qualified Text.Show

data ZettelID = ZettelID
  { -- | Slug must be unique
    zettelIDSlug :: Text,
    -- | Actual ID used by the user, inside `[[..]]`
    zettelIDRaw :: Text
  }
  deriving (Show, Ord, Generic)

-- | Make ZettelID from raw text.
--
-- Assumes that input text is already validated for allowed characters.
unsafeMkZettelID :: Text -> ZettelID
unsafeMkZettelID s =
  let slug = T.intercalate "_" $ T.splitOn " " s
   in ZettelID slug s

indexZid :: ZettelID
indexZid = unsafeMkZettelID "index"

instance Eq ZettelID where
  (==) (ZettelID a _) (ZettelID b _) = a == b

instance Show InvalidID where
  show (InvalidIDParseError s) =
    "Invalid Zettel ID: " <> toString s

instance ToJSON ZettelID where
  toJSON = toJSON . zettelIDRaw

instance FromJSON ZettelID where
  parseJSON = fmap unsafeMkZettelID . parseJSON

instance ToJSONKey ZettelID where
  toJSONKey = toJSONKeyText zettelIDRaw

instance FromJSONKey ZettelID where
  fromJSONKey = FromJSONKeyTextParser $ \s ->
    case parseZettelID s of
      Right v -> pure v
      Left e -> fail $ show e

zettelIDSourceFileName :: ZettelID -> ZettelFormat -> FilePath
zettelIDSourceFileName zid fmt =
  toString (fn <> ext)
  where
    fn = zettelIDRaw zid
    ext = zettelFormatToExtension fmt

---------
-- Parser
---------

data InvalidID = InvalidIDParseError Text
  deriving (Eq, Generic, ToJSON, FromJSON)

parseZettelID :: Text -> Either InvalidID ZettelID
parseZettelID =
  first InvalidIDParseError . parse idParser "parseZettelID"

-- | Characters, aside from alpha numeric characters, to allow in IDs
allowedSpecialChars :: [Char]
allowedSpecialChars =
  [ '_',
    '-',
    '.',
    -- Whitespace is essential for title IDs
    ' ',
    -- Allow some puctuation letters that are common in note titles
    ',',
    ';',
    '(',
    ')',
    ':',
    '"',
    '\''
  ]

idParser :: Parser ZettelID
idParser = idParser' allowedSpecialChars

idParser' :: String -> Parser ZettelID
idParser' cs = do
  s <- M.some $ M.alphaNumChar <|> M.choice (M.char <$> cs)
  pure $ unsafeMkZettelID (toText s)

-- | Parse the ZettelID if the given filepath is a zettel.
getZettelID :: ZettelFormat -> FilePath -> Maybe ZettelID
getZettelID fmt fp = do
  let (name, ext) = splitExtension $ takeFileName fp
  guard $ zettelFormatToExtension fmt == toText ext
  rightToMaybe $ parseZettelID $ toText name