From bf4e6ef7e4aad10f574b587cfdf9edce8c51195f Mon Sep 17 00:00:00 2001 From: Rob Whitaker Date: Sun, 12 Jan 2020 02:54:19 -0500 Subject: [PATCH] Initial commit. --- .gitignore | 2 + CHANGELOG.md | 5 + LICENSE | 21 ++++ README.md | 47 ++++++++ Setup.hs | 2 + default.nix | 17 +++ hasknote.cabal | 47 ++++++++ pinned-packages.nix | 9 ++ shell.nix | 13 +++ src/Main.hs | 269 ++++++++++++++++++++++++++++++++++++++++++++ taskwarrior.nix | 21 ++++ 11 files changed, 453 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Setup.hs create mode 100644 default.nix create mode 100644 hasknote.cabal create mode 100644 pinned-packages.nix create mode 100644 shell.nix create mode 100644 src/Main.hs create mode 100644 taskwarrior.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..127abeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +result diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..07c867f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Revision history for hasknote + +## 0.1.0.0 -- 2020-01-12 + +* First version. Released on an unsuspecting world. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b433e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Robert Whitaker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a856742 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +hasknote +======== + +A Haskell clone of [tasknote](https://github.com/mikebobroski/tasknote) with extra features and nicer configuration. It allows the user to associate a text file with a [Taskwarrior](https://taskwarrior.org/) task. When a note is added, the task will be annotated with the first line of the note. This annotation will be updated with each call to `hasknote edit`. + +## Installation + +### Building with Nix + +Using the [Nix package manager](https://nixos.org/nix/): + +1. Clone the project and `cd` into its directory. +2. Run `nix build` +3. The compiled executable will be located at `./result/bin/hasknote`. + +### Building with Cabal + +Using Haskell's [Cabal](https://www.haskell.org/cabal/): + +1. Clone the project and `cd` into its directory. +2. Run `cabal --enable-nix v1-configure && cabal --enable-nix v1-build`. +3. The compiled executable will be located at `./dist/build/hasknote/hasknote`. + +## Usage + +``` +Usage: hasknote TASK_ID COMMAND + +Available options: + -h,--help Show this help text + +Available commands: + edit [--stdin] Create or edit a note for a task, optionally reading + from stdin instead of opening an editor + view View the note for a task if it exists + remove Remove the note for a task if it exists +``` + +## Configuration + +You can configure hasknote via a `~/.hasknoterc` file. Options are: + +- `editor` - The text editor you edit your note in. Default: `editor = vi`. +- `viewer` - The program used to view your note. Default: `viewer = cat`. +- `location` - The directory where your notes will be stored. If the directory does not exist, it will be created. Default: `location = ~/.task/notes`. +- `extension` - The file extension for your notes. Default: `extension = .txt`. +- `prefix` - The prefix of hasknote's task annotations, used by hasknote to detect and remove the annotations later. Default: `prefix = [hasknote]`. diff --git a/Setup.hs b/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..329ba2d --- /dev/null +++ b/default.nix @@ -0,0 +1,17 @@ +{ compiler ? null +, pkgs ? import {} +}: + +let + haskellPackages = + if builtins.isNull compiler + then pkgs.haskellPackages + else pkgs.haskell.packages.${compiler}; + overriddenPackages = haskellPackages.override { + overrides = self: super: { + taskwarrior = + self.callPackage ./taskwarrior.nix {}; + }; + }; +in + overriddenPackages.callCabal2nix "hasknote" ./. {} diff --git a/hasknote.cabal b/hasknote.cabal new file mode 100644 index 0000000..f467665 --- /dev/null +++ b/hasknote.cabal @@ -0,0 +1,47 @@ +name: hasknote +version: 0.1.0.0 +license: MIT +license-file: LICENSE +copyright: (c) 2020 Rob Whitaker +category: CLI +author: Rob Whitaker +maintainer: Rob Whitaker +cabal-version: >= 1.10 +synopsis: A Haskell implementation of Tasknote for Taskwarrior. +-- homepage: +-- bug-reports: +build-type: Simple + +extra-source-files: + CHANGELOG.md + README.md + +executable hasknote + main-is: Main.hs + default-extensions: OverloadedStrings + , GeneralizedNewtypeDeriving + ghc-options: -Wall + -fwarn-incomplete-uni-patterns + -fwarn-tabs + -fwarn-incomplete-record-updates + -fno-warn-missing-signatures + -fno-warn-type-defaults + -Werror + -Wwarn=unused-imports + -Wwarn=unused-local-binds + -Wwarn=unused-matches + -Wwarn=unused-do-bind + build-depends: base + , config-ini + , data-default + , directory + , filepath + , mtl + , optparse-applicative + , process + , taskwarrior + , text + , time + , uuid + hs-source-dirs: src + default-language: Haskell2010 diff --git a/pinned-packages.nix b/pinned-packages.nix new file mode 100644 index 0000000..30edbf0 --- /dev/null +++ b/pinned-packages.nix @@ -0,0 +1,9 @@ +let + pkgs1909 = fetchTarball { + url = https://github.com/NixOS/nixpkgs/archive/19.09.tar.gz; + sha256 = "0mhqhq21y5vrr1f30qd2bvydv4bbbslvyzclhw0kdxmkgg3z4c92"; + }; +in + { + pkgs1909 = import pkgs1909 {}; + } diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..459c181 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +let + pkgs = (import ./pinned-packages.nix).pkgs1909; + drv = import ./. { inherit pkgs; }; +in + pkgs.haskellPackages.shellFor { + packages = p: [drv]; + buildInputs = with pkgs.haskellPackages; [ + cabal-install + ghcid + stylish-haskell + hlint + ]; + } diff --git a/src/Main.hs b/src/Main.hs new file mode 100644 index 0000000..748a553 --- /dev/null +++ b/src/Main.hs @@ -0,0 +1,269 @@ +{-# LANGUAGE RecordWildCards #-} + +module Main where + +import qualified Taskwarrior.Annotation as Annot +import qualified Taskwarrior.IO as Task +import Taskwarrior.Task (Task) +import qualified Taskwarrior.Task as Task + +import Control.Monad.Except (ExceptT, MonadError) +import qualified Control.Monad.Except as Except +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Reader (MonadReader, ReaderT) +import qualified Control.Monad.Reader as Reader + +import Data.Default (Default, def) +import Data.Ini.Config (IniParser) +import qualified Data.Ini.Config as Ini +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Time (UTCTime, getCurrentTime) +import qualified Data.UUID as UUID + +import Options.Applicative (Parser) +import qualified Options.Applicative as Opt + +import qualified System.Directory as Dir +import System.FilePath ((<.>), ()) +import qualified System.IO as IO +import qualified System.Process as Process + +-- CONFIG: Allow configuration via ~/.hasknoterc file -- + +data Config = Config + { confEditor :: Text + , confViewer :: Text + , confLocation :: Text + , confExtension :: Text + , confPrefix :: Text + } deriving (Show) + +instance Default Config where + def = Config "vi" "cat" "~/.task/notes" ".txt" "[hasknote]" + +configParser :: IniParser Config +configParser = + Ini.section "MAIN" $ do + confEditor <- Ini.fieldDef "editor" (confEditor def) + confViewer <- Ini.fieldDef "viewer" (confViewer def) + confLocation <- Ini.fieldDef "location" (confLocation def) + confExtension <- Ini.fieldDef "extension" (confExtension def) + confPrefix <- Ini.fieldDef "prefix" (confPrefix def) + return Config{..} + +getConfig :: IO Config +getConfig = do + confFilePath <- expandPath "~/.hasknoterc" + confFileExists <- Dir.doesFileExist confFilePath + if confFileExists + then do + confFile <- TIO.readFile confFilePath + let parsedFile = Ini.parseIniFile ("[MAIN]\n" <> confFile) configParser + case parsedFile of + Right config -> + return config + + Left err -> + error $ "Failed to parse config file: " <> err + else return def + +-- COMMAND LINE ARGUMENTS -- + +type UseStdin = Bool + +data Command + = Edit UseStdin + | View + | Remove + deriving (Show, Eq) + +data Arguments = Arguments + { argTaskId :: Text + , argCommand :: Command + } deriving (Show) + +argParser :: Parser Arguments +argParser = + Arguments <$> taskIdArg <*> cmdArg + where + taskIdArg = + Opt.argument Opt.str (Opt.metavar "TASK_ID") + + cmdArg = + Opt.hsubparser $ mconcat + [ Opt.command "edit" + (Opt.info + (fmap Edit $ + Opt.switch $ mconcat + [ Opt.long "stdin" + , Opt.help "Read note from stdin instead of opening an editor" + ]) + (Opt.progDesc "Create or edit a note for a task")) + , Opt.command "view" + (Opt.info + (pure View) + (Opt.progDesc "View the note for a task if it exists")) + , Opt.command "remove" + (Opt.info + (pure Remove) + (Opt.progDesc "Remove the note for a task if it exists")) + ] + +parseArgs :: IO Arguments +parseArgs = + Opt.customExecParser p opts + where + opts = Opt.info (Opt.helper <*> argParser) Opt.idm + p = Opt.prefs Opt.disambiguate + +-- WIRING -- + +data Env + = Env Config Arguments + deriving (Show) + +getEnv :: IO Env +getEnv = + Env <$> getConfig <*> parseArgs + +newtype App a = App { unApp :: ReaderT Env (ExceptT Text IO) a } + deriving (Functor, Applicative, Monad, MonadReader Env, MonadError Text, MonadIO) + +runApp :: Env -> App a -> IO (Either Text a) +runApp env app = + Except.runExceptT $ Reader.runReaderT (unApp app) env + +-- MAIN -- + +main :: IO () +main = do + env@(Env Config{..} Arguments{..}) <- getEnv + liftIO $ expandPath (T.unpack confLocation) >>= Dir.createDirectoryIfMissing True + tasks <- Task.getTasks [argTaskId] + result <- case tasks of + [] -> + error $ "No task with ID '" <> T.unpack argTaskId <> "'" + + [task] -> + runApp env $ + case argCommand of + Edit useStdin -> + edit useStdin task + + View -> + view task + + Remove -> + remove task + + _ -> + error "Found multiple tasks. Are you sure you gave me a task ID?" + case result of + Right _ -> + return () + + Left err -> + putStrLn $ T.unpack err + +edit :: Bool -> Task -> App () +edit useStdin task = do + (Env Config{..} _) <- Reader.ask + notePath <- getNotePath task + if useStdin + then liftIO $ do + stdin <- getStdin + TIO.writeFile notePath stdin + else do + let editorCmd = T.unpack confEditor + liftIO $ Process.callProcess editorCmd [notePath] + noteCreated <- liftIO $ Dir.doesFileExist notePath + if noteCreated + then do + let taskNoAnnot = removeAnnotation confPrefix task + liftIO $ do + noteLines <- T.lines <$> TIO.readFile notePath + entryTime <- getCurrentTime + let + firstLine = + case noteLines of + [] -> + confPrefix + + line:_ -> + confPrefix <> " " <> line + newTask = addAnnotation firstLine entryTime taskNoAnnot + Task.saveTasks [ newTask ] + else liftIO $ putStrLn "No note created." + + +view :: Task -> App () +view task = + ifNoteExists task $ do + (Env Config{..} _) <- Reader.ask + let viewCmd = T.unpack confViewer + notePath <- getNotePath task + liftIO $ Process.callProcess viewCmd [notePath] + +remove :: Task -> App () +remove task = + ifNoteExists task $ do + (Env Config{..} _) <- Reader.ask + notePath <- getNotePath task + liftIO $ do + Process.callProcess "rm" [notePath] + Task.saveTasks [ removeAnnotation confPrefix task ] + +-- HELPERS -- + +ifNoteExists :: Task -> App () -> App () +ifNoteExists task app = do + notePath <- getNotePath task + noteExists <- liftIO $ Dir.doesFileExist notePath + if noteExists + then app + else liftIO $ putStrLn "There is no note for that task." + +getNotePath :: Task -> App FilePath +getNotePath task = do + (Env config _) <- Reader.ask + let noteDir = confLocation config + noteExt = confExtension config + taskUuid = UUID.toText (Task.uuid task) + notePath = T.unpack noteDir T.unpack taskUuid <.> T.unpack noteExt + liftIO $ expandPath notePath + +removeAnnotation :: Text -> Task -> Task +removeAnnotation prefix task = + task { + Task.annotations = + filter + ((/=prefix) . T.take (T.length prefix) . Annot.description) + (Task.annotations task) + } + +addAnnotation :: Text -> UTCTime -> Task -> Task +addAnnotation description entryTime task = + task { + Task.annotations = + Annot.Annotation entryTime description : Task.annotations task + } + +expandPath :: FilePath -> IO FilePath +expandPath path = + case path of + '~':rest -> do + homeDir <- Dir.getHomeDirectory + return (homeDir dropWhile (=='/') rest) + p -> return p + +getStdin :: IO Text +getStdin = + T.strip <$> loop "" + where + loop acc = do + done <- IO.isEOF + if done + then return acc + else TIO.getLine >>= (\s -> loop (acc <> "\n" <> s)) diff --git a/taskwarrior.nix b/taskwarrior.nix new file mode 100644 index 0000000..dcf7e38 --- /dev/null +++ b/taskwarrior.nix @@ -0,0 +1,21 @@ +{ mkDerivation, aeson, base, bytestring, hspec, hspec-discover +, process, QuickCheck, quickcheck-instances, random, stdenv +, string-interpolate, text, time, unordered-containers, uuid +}: +mkDerivation { + pname = "taskwarrior"; + version = "0.1.2.0"; + sha256 = "972b4f4d070fd2174935a88a58f6d8d5b371fb1f1b6dbae921ccadd4c6a2b5ce"; + libraryHaskellDepends = [ + aeson base bytestring process random string-interpolate text time + unordered-containers uuid + ]; + testHaskellDepends = [ + aeson base hspec QuickCheck quickcheck-instances text time + unordered-containers uuid + ]; + testToolDepends = [ hspec-discover ]; + homepage = "https://github.com/maralorn/haskell-taskwarrior"; + description = "Types and aeson instances for taskwarrior tasks"; + license = stdenv.lib.licenses.agpl3Plus; +}