packages feed

mywork-1.0.1.0: lib/Defs.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

module Defs where

import           Brick hiding (Location)
import           Brick.Focus
import           Brick.Panes
import           Control.Applicative ( (<|>) )
import           Control.Lens
import           Control.Monad ( guard )
import qualified Data.List as DL
import           Data.Text ( Text, pack, unpack )
import qualified Data.Text as T
import           Data.Time.Calendar
import           Data.Time.Clock ( getCurrentTime, utctDay )
import           GHC.Generics ( Generic )
import           Numeric.Natural
import           Path ( Path, Abs, Dir, File, toFilePath )
import           Text.Read ( readMaybe )

import           Defs.Lenses


type family ProjectCore a
type family LocationCore a
type family NoteCore a

newtype Projects_ core = Projects { projects :: [Project_ core] }
  deriving (Generic, Monoid, Semigroup)

newtype ProjectName = ProjectName Text deriving (Eq, Ord)

data Project_ core =
  Project { projName :: ProjectName
          , group :: Group
          , role :: Role
          , description :: Text
          , language :: Either Text Language
          , locations :: [Location_ core]
          , projCore :: ProjectCore core
          }
  deriving Generic

data Group = Personal | Work | OtherGroup Text
  deriving (Eq, Generic)

data Role = Author | Maintainer | Contributor | User
  deriving (Show, Enum, Bounded, Eq, Ord, Generic)

data Language = Haskell | Rust | C | CPlusPlus | Python | JavaScript | Prolog
  deriving (Show, Eq, Generic)

data LocationSpec = LocalSpec (Path Abs Dir)
                  | RemoteSpec Text
                  deriving (Eq, Ord, Generic)

instance Show LocationSpec where
  show = \case
    LocalSpec p -> toFilePath p
    RemoteSpec r -> T.unpack r

data Location_ core = Location { location :: LocationSpec
                               , locatedOn :: Maybe Day
                               , notes :: [Note_ core]
                               , locCore :: LocationCore core
                               }
  deriving Generic

data Note_ core = Note { notedOn :: Day
                       , note :: Text
                       , noteCore :: NoteCore core
                       }
  deriving (Generic)

data NoteSource = MyWorkDB | ProjLoc | MyWorkGenerated
  deriving (Eq, Ord)

newtype NoteTitle = NoteTitle Text deriving (Eq, Ord)

noteTitle :: Note_ core -> NoteTitle
noteTitle = noteTitle' . note

noteTitle' :: Text -> NoteTitle
noteTitle' t = case T.lines t of
                 [] -> NoteTitle ""
                 (l:_) -> NoteTitle l

noteBody :: Note_ core -> Text
noteBody = T.unlines . DL.drop 1 . T.lines . note

data NoteKeyword = INPROG
                 | TODO_ Day | TODO
                 | FUTURE_ Day | FUTURE
                 | BLOCKING
  deriving (Eq, Ord)

newtype NoteRemTitle = NoteRemTitle Text

nullaryNoteKeywords :: [ NoteKeyword ]
nullaryNoteKeywords = [ INPROG, TODO, FUTURE, BLOCKING ]


----------------------------------------------------------------------

data Live

type Mutation = Natural

data ProjRT = ProjRT

data LocRT = LocRT { locValid :: Bool }

locValidL :: Lens' (Location_ Live) Bool
locValidL f l = (\x -> l { locCore = (locCore l) { locValid = x } })
                <$> f (locValid (locCore l))

data NoteRT = NoteRT { noteSource :: NoteSource
                     -- , noteMuta :: Mutation
                     }

noteSourceL :: Lens' (Note_ Live) NoteSource
noteSourceL f n = (\x -> n { noteCore = (noteCore n) { noteSource = x } })
                  <$> f (noteSource (noteCore n))

type instance ProjectCore Live = ProjRT

type instance LocationCore Live = LocRT

type instance NoteCore Live = NoteRT

type Projects = Projects_ Live
type Project = Project_ Live
type Location = Location_ Live
type Note = Note_ Live

----------------------------------------------------------------------

makeLensL ''Project_
makeLensL ''Location_
makeLensL ''Note_

----------------------------------------------------------------------

numProjects :: Projects -> Int
numProjects = length . projects

languageText :: Either Text Language -> Text
languageText = either id (pack . show)

canEditNote :: Note -> Bool
canEditNote n = n ^. noteSourceL == MyWorkDB


instance Show Group where
  show = \case
    Personal -> "Personal"
    Work -> "Work"
    OtherGroup g -> unpack g


noteKeyword :: Note_ core -> (Maybe NoteKeyword, NoteRemTitle)
noteKeyword n =
  let NoteTitle t = noteTitle n
  in case T.words t of
       ("FUTURE":dw:r)
         | Just d <- textToDay dw
           -> (Just $ FUTURE_ d, NoteRemTitle $ T.unwords r)
       ("TODO":dw:r)
         | Just d <- textToDay dw
           -> (Just $ TODO_ d, NoteRemTitle $ T.unwords r)
       (kw:r) -> let k = DL.find ((kw ==) . tshow) nullaryNoteKeywords
                     rt = NoteRemTitle $ maybe t (const $ T.unwords r) k
                 in (k, rt)
       [] -> (Nothing, NoteRemTitle t)

instance Show NoteKeyword where
  show = \case
    INPROG -> "IN-PROGRESS"
    TODO_ d -> "TODO " <> show d
    FUTURE_ d -> "FUTURE " <> show d
    TODO -> "TODO"
    FUTURE -> "FUTURE"
    BLOCKING -> "BLOCKING"

instance Eq (Note_ core) where
  n1 == n2 = noteTitle n1 == noteTitle n2 && notedOn n1 == notedOn n2

instance Ord (Note_ core) where
  compare n1 n2 =
    let noKWor (kw,_) = kw <|> Just BLOCKING
    in case compare (noKWor $ noteKeyword n1) (noKWor $ noteKeyword n2) of
         EQ -> case compare (notedOn n1) (notedOn n2) of
                 EQ -> compare (noteTitle n1) (noteTitle n2)
                 GT -> LT
                 LT -> GT
         o -> o


----------------------------------------------------------------------

data MyWorkCore = MyWorkCore { myWorkFocus :: FocusRing WName
                             , today :: Day
                             }

initMyWorkCore :: IO MyWorkCore
initMyWorkCore = do
  t <- utctDay <$> getCurrentTime
  return $ MyWorkCore { myWorkFocus = focusRing [ WProjList
                                                , WLocations
                                                ]
                      , today = t
                      }

coreWorkFocusL :: Lens' MyWorkCore (FocusRing WName)
coreWorkFocusL f c = (\f' -> c { myWorkFocus = f' }) <$> f (myWorkFocus c)

todayL :: Lens' MyWorkCore Day
todayL f c = (\d -> c { today = d }) <$> f (today c)


data WName = WProjList | WLocations | WNotes | WName Text
  deriving (Eq, Ord)

instance Show WName where
  show = \case
    WProjList -> "Projects"
    WLocations -> "Location"
    WNotes -> "Notes"
    WName n -> unpack n


type MyWorkEvent = ()  -- No app-specific event for this simple app


class HasDate s where
  getToday :: s -> Day

instance HasDate MyWorkCore where
  getToday = view todayL

instance HasDate (Panel WName MyWorkEvent MyWorkCore panes) where
  getToday = view (onBaseState . todayL)


class HasProjects s where
  -- getProjects returns the current set of projects, along with either a
  -- confirmation that should be performed (which will update the Projects if
  -- accepted) or a boolena indication of whether the projects have been changed
  -- or not.
  getProjects :: s -> (Either Confirm Bool, Projects)


class HasMessage s where
  getMessage :: s -> [Widget WName]


instance HasFocus MyWorkCore WName where
  getFocus f s =
    let setFocus jn = case focused jn of
          Nothing -> s
          Just n -> s & coreWorkFocusL %~ focusSetCurrent n
    in setFocus <$> (f $ Focused $ focusGetCurrent (s^.coreWorkFocusL))


class HasSelection s where
  selectedProject :: s -> Maybe ProjectName

instance ( PanelOps Projects WName MyWorkEvent panes MyWorkCore
         , HasSelection (PaneState Projects MyWorkEvent)
         )
  => HasSelection (Panel WName MyWorkEvent MyWorkCore panes) where
  selectedProject = selectedProject . view (onPane @Projects)

class HasLocation s where
  -- | Returns the currently selected project and location
  selectedLocation :: s -> Maybe (ProjectName, LocationSpec)

instance ( PanelOps Location WName MyWorkEvent panes MyWorkCore
         , HasLocation (PaneState Location MyWorkEvent)
         )
  => HasLocation (Panel WName MyWorkEvent MyWorkCore panes) where
  selectedLocation = selectedLocation . view (onPane @Location)

class HasNote s where
  -- | Returns the currently selected location and note
  selectedNote :: s -> Maybe (LocationSpec, NoteTitle)

instance ( PanelOps Note WName MyWorkEvent panes MyWorkCore
         , HasNote (PaneState Note MyWorkEvent)
         )
  => HasNote (Panel WName MyWorkEvent MyWorkCore panes) where
  selectedNote = selectedNote . view (onPane @Note)


getCurrentProject :: HasSelection s => HasProjects s => s -> Maybe Project
getCurrentProject s = do pnm <- selectedProject s
                         let (_, prjs) = getProjects s
                         DL.find ((== pnm) . view projNameL) (projects prjs)

getCurrentLocation :: HasSelection s
                   => HasLocation s
                   => HasProjects s
                   => s -> Maybe (Project, Maybe Location)
getCurrentLocation s =
  do (p,l) <- selectedLocation s
     let (_,prjs) = getProjects s
     prj <- DL.find ((== p) . view projNameL) (projects prjs)
     return (prj, DL.find ((== l) . view locationL) (prj ^. locationsL))


getCurrentNote :: HasNote s => s -> Location -> Maybe Note
getCurrentNote s l = do (l',n) <- selectedNote s
                        guard (l ^. locationL == l')
                        DL.find ((== n) . noteTitle) (l ^. notesL)

isLocationLocal :: Location -> Bool
isLocationLocal = isLocationLocal' . view locationL

isLocationLocal' :: LocationSpec -> Bool
isLocationLocal' = \case
  LocalSpec _ -> True
  RemoteSpec _ -> False

isLocationTextLocal :: Text -> Bool
isLocationTextLocal t =
  not $ or [ "http://" `T.isPrefixOf` t
           , "https://" `T.isPrefixOf` t
           , "git@" `T.isPrefixOf` t
           , ":/" `T.isInfixOf` t
           ]

updateProject :: Maybe ProjectName -> Project -> Projects -> Projects
updateProject onm p (Projects ps) =
  let oldName = maybe (p ^. projNameL) id onm
      (match, other) = DL.partition ((== oldName) . view projNameL) ps
      p' = foldr (updateLocation Nothing) p (concatMap (view locationsL) match)
  in Projects $ p' : other


-- | Adds the specified Location to the Project, merging with the previous
-- Location (with the same name or the previous name indicated by a Just in the
-- the Maybe parameter).
updateLocation :: Maybe LocationSpec -> Location -> Project -> Project
updateLocation ol l p =
  let oldName = maybe (l ^. locationL) id ol
      isOldName = (oldName ==) . view locationL
      (match, other) = DL.partition isOldName (p ^. locationsL)
      l' = foldr addNote l (concatMap (view notesL) match)
      addNote n = notesL %~ (n :) . filter ((/= noteTitle n) . noteTitle)
  in p & locationsL .~ l' : other


updateLocNote :: Maybe NoteTitle -> Note -> Location -> Location
updateLocNote oldn n =
  let oldName = maybe (noteTitle n) id oldn
  in notesL %~ (n :) . filter ((/= oldName) . noteTitle)


updateNote :: Maybe NoteTitle -> Note -> Location -> Project
           -> (Project, Location)
updateNote oldn n l p = let newL = updateLocNote oldn n l
                        in (updateLocation Nothing newL p, newL)


data OpOn = ProjectOp | LocationOp | NoteOp
  deriving (Eq, Enum, Bounded)

opOnSelection :: HasSelection s
              => HasLocation s
              => HasFocus s WName
              => s -> OpOn
opOnSelection s =
  case s ^. getFocus of
    Focused (Just WProjList) -> ProjectOp
    Focused (Just WLocations) -> LocationOp
    Focused (Just WNotes) -> NoteOp
    _ -> ProjectOp


data Confirm = ConfirmProjectDelete ProjectName
             | ConfirmLocationDelete ProjectName LocationSpec
             | ConfirmNoteDelete ProjectName LocationSpec NoteTitle
             | ConfirmLoad (Path Abs File)
             | ConfirmQuit

-- The Show instance for Confirm is the message presented to the user in the
-- confirmation window.
instance Show Confirm where
  show = \case
    ConfirmProjectDelete pname ->
      let ProjectName pnm = pname
      in "Are you sure you want to delete project " <> show pnm
         <> " and all associated locations and notes?"
    ConfirmLocationDelete pname locn ->
      let ProjectName pnm = pname
      in "Are you sure you want to remove location " <> show locn
         <> " from project " <> show pnm <> "?"
    ConfirmNoteDelete pname locn nt ->
      let ProjectName pnm = pname
          NoteTitle ntitle = nt
      in "Remove the following note from project " <> show pnm
         <> ", location " <> show locn <> "?\n\n  " <> show ntitle
    ConfirmLoad fp ->
      "Discard local changes and load projects from " <> show fp <> "?"
    ConfirmQuit -> "There are unsaved changes.  Are you sure you want to quit?"


----------------------------------------------------------------------

textToDay :: Text -> Maybe Day
textToDay t =
  case T.split (`T.elem` "-/.") t of
    [y,m,d] ->
      let validYear x = if x < (1800 :: Integer) then x + 2000 else x
          validMonth x = not (x < 1 || x > (12 :: Int))
          validDayOfMonth x = not (x < 1 || x > (31 :: Int))
          months = [ "january", "february", "march", "april"
                   , "may", "june", "july", "august"
                   , "september", "october", "november", "december"
                   ]
          ml = T.toLower m
          matchesMonth x = or [ ml == x, ml == T.take 3 x]
      in do y' <- validYear <$> readMaybe (T.unpack y)
            m' <- readMaybe (T.unpack m)
                  <|> (snd <$> (DL.find (matchesMonth . fst) $ zip months [1..]))
            guard (validMonth m')
            d' <- readMaybe (T.unpack d)
            guard (validDayOfMonth d')
            fromGregorianValid y' m' d'
    _ -> Nothing


tshow :: Show a => a -> Text
tshow = T.pack . show

----------------------------------------------------------------------

a'Operation :: AttrName
a'Operation = attrName "Oper"

a'RoleAuthor, a'RoleContributor, a'RoleMaintainer, a'RoleUser :: AttrName
a'RoleAuthor = attrName "auth"
a'RoleContributor = attrName "contrib"
a'RoleMaintainer = attrName "maint"
a'RoleUser = attrName "user"

roleAttr :: Role -> AttrName
roleAttr = \case
  Author -> a'RoleAuthor
  Contributor -> a'RoleContributor
  Maintainer -> a'RoleMaintainer
  User -> a'RoleUser


a'ProjName :: AttrName
a'ProjName = attrName "projname"

a'Disabled :: AttrName
a'Disabled = attrName "disabled"

a'Selected :: AttrName
a'Selected = attrName "selected"

a'Error :: AttrName
a'Error = attrName "Error"

a'Notice :: AttrName
a'Notice = attrName "Notice"

a'NoteSourceMyWork, a'NoteSourceProjLoc, a'NoteSourceGenerated :: AttrName
a'NoteSourceMyWork = attrName "Note:main"
a'NoteSourceProjLoc = attrName "Note:projloc"
a'NoteSourceGenerated = attrName "gen note"

a'NoteWordTODO, a'NoteWordInProg, a'NoteWordFuture, a'NoteWordBlocking
  , a'NoteWordExpired :: AttrName
a'NoteWordTODO = attrName "Note:TODO"
a'NoteWordInProg = attrName "Note:InProg"
a'NoteWordFuture = attrName "Note:Future"
a'NoteWordBlocking = attrName "Note:Blocking"
a'NoteWordExpired = attrName "Note:Expired"

noteKeywordAttr :: NoteKeyword -> AttrName
noteKeywordAttr = \case
  FUTURE_ _ -> a'NoteWordFuture
  FUTURE -> a'NoteWordFuture
  TODO_ _ -> a'NoteWordTODO
  TODO -> a'NoteWordTODO
  BLOCKING -> a'NoteWordBlocking
  INPROG -> a'NoteWordInProg