diff --git a/docs-source/cli/hooks.md b/docs-source/cli/hooks.md index 8dec3c7..80203c2 100644 --- a/docs-source/cli/hooks.md +++ b/docs-source/cli/hooks.md @@ -14,15 +14,40 @@ Hooks can either be specified via the config file or via hook files. If the files have an extension (e.g. `post-add.lua`) the corresponding interpreter will be used. Otherwise the file will be executed as a shell script. -Currently supported interpreters are: `lua`, `python3`, `ruby`, `node`. + +Currently supported interpreters are: +`lua`, `python3`, `ruby`, `node`, and [`v`][vlang]. +It's recommended to write you scripts in the [V programming language][vlang] +due to its high performance (script is compiled on first execution), +its great ergonomics, and its comprehensive standard library. +The compiled versions of the script will be cached as +`_v_executable_.` in the hooks directory. +? +[vlang]: https://vlang.io + +Another good alternative is [Lua](https://www.lua.org/) +as it is simple, lightweight, and fast. +Futhermore, future versions of TaskLite will include the Lua interpreter +to make it independent of the system's installed Lua interpreter. If the hook files are shell scripts, they must be executable (`chmod +x`). Otherwise they can't be executed directly by TaskLite. -It's recommended to use [Lua](https://www.lua.org/) for hooks -as it's simple, lightweight, and has the best performance. -Futhermore, future versions of TaskLite will include a Lua interpreter -for even better performance. +The file names must start with the stage they are for (`pre` or `post`), +followed by a dash and the stage name (e.g. `pre-add`), and optionally +followed by an underscore and a description. +E.g `pre-add_validate_task.v` or `pre-exit_sync.v`. + +They are executed in alphabetical order. +If you want to ensure a specific order, +you can include a number in the description. +E.g. `pre-add_01_x.v`, `pre-add_02_y.v`, …. + +> [!WARNING] +> Multiple `pre-add` hooks are not supported yet, +> but will be in the future. + +To ignore a hook, you can prefix the file name with an underscore (`_`). ## Stages @@ -44,8 +69,7 @@ Following stages are available: Can be used to prevent modification of task. - `post-modify` - After task was modified. - Exit - - `pre-exit` - Pre printing results - - `post-exit` - Last thing before program termination + - `pre-exit` - Last thing before program termination The hooks receive JSON data from TaskLite via stdin. We're using JSON5 here for better readability. @@ -117,7 +141,7 @@ with improved formatting and coloring.
 {
-  taskToAdd: {},
+  task: {},
   message: "…",
   …,
 }
@@ -136,11 +160,7 @@ with improved formatting and coloring.
 }
       
-{
-  taskAdded: {},
-  message: "…",
-  …
-}
+{ message: "…", … }
       
{ message: "…", … }
@@ -152,12 +172,12 @@ with improved formatting and coloring.
 {
   arguments: […],
-  taskOriginal: {}
+  taskToModify: {}
 }
       
 {
-  taskToModify: {},
+  task: {},
   message: "…",
   …
 }
@@ -177,11 +197,7 @@ with improved formatting and coloring.
 }
       
-{
-  taskModified: {},
-  message: "…",
-  …
-}
+{ message: "…", … }
       
{ message: "…", … }
@@ -211,6 +227,57 @@ tl ndjson | grep $ULID_OF_TASK | head -n 1 | jq ``` +## Config + +You can add a `hooks` field to your config file like this: + +```yaml +hooks: + directory: /Users/adrian/Dropbox/TaskLite/hooks + launch: + post: [] + pre: [] + add: + post: [] + pre: + - + interpreter: v + body: | + import json + + struct Task { + mut: + body string + } + + struct TaskData { + mut: + task_to_add Task @[json: 'taskToAdd'] + message string @[omitempty] + warning string @[omitempty] + } + + fn main() { + stdin := os.get_raw_lines_joined() + mut task_data := json.decode(TaskData, stdin)! + task_body := task_data.task_to_add.body + + if task_body.contains('coke') { + task_data.warning = 'Coke? Rather have some milk!' + task_data.task_to_add.body = task_body.replace('coke', 'milk') + } + + println(json.encode(task_data)) + } + modify: + post: [] + pre: [] + exit: + post: [] + pre: [] +``` + + ## Examples ### Shell diff --git a/tasklite-core/source/Cli.hs b/tasklite-core/source/Cli.hs index f48d1f3..ed4fd57 100644 --- a/tasklite-core/source/Cli.hs +++ b/tasklite-core/source/Cli.hs @@ -140,7 +140,8 @@ import Config ( HooksConfig (..), addHookFilesToConfig, ) -import Hooks (HookResult (message), executeHooks) +import Control.Arrow ((>>>)) +import Hooks (executeHooks, formatHookResult) import ImportExport ( backupDatabase, dumpCsv, @@ -228,7 +229,7 @@ import Utils ( TagText, parseUtc, ulidText2utc, - (<$$>), + (), ) @@ -1339,15 +1340,24 @@ printOutput appName argsMb config = do fileContent <- readFile filePath pure (filePath, perm, fileContent) ) + let (configNorm, errors) = + addHookFilesToConfig configNormHookDir hookFilesPermContent + P.when (not $ null errors) $ + ["WARNING:\n"] + <> errors + & P.traverse_ + ( pretty + >>> annotate (color Yellow) + >>> hPutDoc P.stderr + ) - let configNorm = addHookFilesToConfig configNormHookDir hookFilesPermContent - + -- Run pre-launch hooks preLaunchResults <- executeHooks "" configNorm.hooks.launch.pre let preLaunchHookMsg = preLaunchResults <&> \case Left error -> pretty error - Right hookResult -> pretty hookResult.message + Right hookResult -> formatHookResult hookResult & P.fold connection <- setupConnection configNorm @@ -1356,11 +1366,8 @@ printOutput appName argsMb config = do -- SQLite.setTrace connection $ Just P.putStrLn migrationsStatus <- runMigrations configNorm connection - nowElapsed <- timeCurrentP - - let - now = timeFromElapsedP nowElapsed :: DateTime + -- Run post-launch hooks progName <- getProgName args <- case argsMb of Just args -> pure args @@ -1378,20 +1385,32 @@ printOutput appName argsMb config = do postLaunchResults <&> \case Left error -> pretty error - Right hookResult -> pretty hookResult.message + Right hookResult -> formatHookResult hookResult & P.fold + nowElapsed <- timeCurrentP + let now = timeFromElapsedP nowElapsed :: DateTime doc <- executeCLiCommand configNorm now connection progName args -- TODO: Use withConnection instead SQLite.close connection + -- Run pre-exit hooks + preExitResults <- executeHooks "" configNorm.hooks.exit.pre + let preExitHookMsg = + preExitResults + <&> \case + Left error -> pretty error + Right hookResult -> formatHookResult hookResult + & P.fold + -- TODO: Remove color when piping into other command putDoc $ preLaunchHookMsg - <$$> migrationsStatus - <> doc - <$$> postLaunchHookMsg + migrationsStatus + postLaunchHookMsg + doc + preExitHookMsg exampleConfig :: Text diff --git a/tasklite-core/source/Config.hs b/tasklite-core/source/Config.hs index e29f473..0a6844c 100644 --- a/tasklite-core/source/Config.hs +++ b/tasklite-core/source/Config.hs @@ -36,15 +36,8 @@ import Data.Aeson ( (.:?), ) import Data.Hourglass (TimeFormat (toFormat), TimeFormatString) -import Data.Text as T ( - dropEnd, - isInfixOf, - pack, - replace, - split, - strip, - stripPrefix, - ) +import Data.Text (dropEnd, pack, split, stripPrefix) +import Data.Text qualified as T import Data.Yaml (encode) import Prettyprinter.Internal (Pretty (pretty)) import Prettyprinter.Render.Terminal ( @@ -142,8 +135,8 @@ defaultHooksConfig = } -addHookFilesToConfig :: Config -> [(FilePath, b, Text)] -> Config -addHookFilesToConfig = do +addHookFilesToConfig :: Config -> [(FilePath, b, Text)] -> (Config, [Text]) +addHookFilesToConfig config = do let buildHook :: FilePath -> Text -> Hook buildHook filePath content = @@ -190,18 +183,50 @@ addHookFilesToConfig = do } _ -> hConfig - P.foldl $ \conf (filePath, _, fileContent) -> - case split (== '-') $ pack $ takeBaseName filePath of - [stage, event] -> - conf - { hooks = - addToHooksConfig - event - stage - (buildHook filePath fileContent) - conf.hooks - } - _ -> conf + getStageAndEvent :: FilePath -> [Text] + getStageAndEvent filePath = do + let fileName = + filePath + & takeBaseName + & pack + + -- Prefix with "_" to ignore files + if "_" `T.isPrefixOf` fileName + then [] + else + fileName + & split (== '_') + & P.headMay + & fromMaybe "" + & split (== '-') + + P.foldl' + ( \(conf, errors) (filePath, _, fileContent) -> + case getStageAndEvent filePath of + [stage, event] -> + ( conf + { hooks = + addToHooksConfig + event + stage + (buildHook filePath fileContent) + conf.hooks + } + , errors <> [] + ) + [] -> (conf, errors) + filenameParts -> + ( conf + , errors + <> [ ("`" <> (filenameParts & T.intercalate "-") <> "` ") + <> "is not a correct hook name.\n" + <> "Hook file names must be in the format: " + <> "-_.\n" + <> "E.g `post-launch.v`, or `pre-exit.lua`.\n" + ] + ) + ) + (config, []) data Config = Config diff --git a/tasklite-core/source/Hooks.hs b/tasklite-core/source/Hooks.hs index 8933ea1..e1825ce 100644 --- a/tasklite-core/source/Hooks.hs +++ b/tasklite-core/source/Hooks.hs @@ -25,7 +25,10 @@ import Config (Hook (body, filePath, interpreter)) import Control.Arrow ((>>>)) import ImportTask (ImportTask) import Options.Applicative.Arrows (left) +import Prettyprinter (Doc, annotate, pretty) +import Prettyprinter.Render.Terminal (AnsiStyle, Color (Red, Yellow), color) import System.FilePath (takeExtension) +import Utils (()) data HookTiming = PreEvent | PostEvent @@ -44,32 +47,15 @@ data HookEvent = HookEvent HookType HookTiming deriving (Show) +-- | Output of a hook that must be parsed by TaskLite data HookResult = BasicHookResult { message :: Maybe Text , warning :: Maybe Text , error :: Maybe Text } - | PreAddHookResult - { taskToAdd :: Maybe ImportTask - , message :: Maybe Text - , warning :: Maybe Text - , error :: Maybe Text - } - | PostAddHookResult - { taskAdded :: ImportTask - , message :: Maybe Text - , warning :: Maybe Text - , error :: Maybe Text - } - | PreModifyHookResult - { taskToModify :: ImportTask - , message :: Maybe Text - , warning :: Maybe Text - , error :: Maybe Text - } - | PostModifyHookResult - { taskModified :: ImportTask + | TaskHookResult + { task :: Maybe ImportTask , message :: Maybe Text , warning :: Maybe Text , error :: Maybe Text @@ -78,27 +64,15 @@ data HookResult instance Aeson.FromJSON HookResult where - parseJSON = Aeson.withObject "PreAddHookResult" $ \v -> do - message <- v Aeson..:? "message" - warning <- v Aeson..:? "warning" - error <- v Aeson..:? "error" - - taskToAddMb <- v Aeson..:? "taskToAdd" - taskAddedMb <- v Aeson..:? "taskAdded" - taskToModifyMb <- v Aeson..:? "taskToModify" - taskModifiedMb <- v Aeson..:? "taskModified" - - case (taskToAddMb, taskAddedMb, taskToModifyMb, taskModifiedMb) of - (Just taskToAdd, _, _, _) -> do - pure $ PreAddHookResult taskToAdd message warning error - (_, Just taskAdded, _, _) -> do - pure $ PostAddHookResult taskAdded message warning error - (_, _, Just taskToModify, _) -> do - pure $ PreModifyHookResult taskToModify message warning error - (_, _, _, Just taskModified) -> do - pure $ PostModifyHookResult taskModified message warning error - (_, _, _, _) -> do - pure $ BasicHookResult message warning error + parseJSON = Aeson.withObject "HookResult" $ \v -> do + taskMb <- v Aeson..:? "task" + messageMb <- v Aeson..:? "message" + warningMb <- v Aeson..:? "warning" + errorMb <- v Aeson..:? "error" + + case taskMb of + Just task -> pure $ TaskHookResult task messageMb warningMb errorMb + Nothing -> pure $ BasicHookResult messageMb warningMb errorMb data ExecMode = ExecFile | ExecStdin @@ -172,3 +146,12 @@ executeHooks stdinText hooks = do ) pure parsedHookResults + + +formatHookResult :: HookResult -> Doc AnsiStyle +formatHookResult hookResult = + "" + pretty hookResult.message + annotate (color Yellow) (pretty hookResult.warning) + annotate (color Red) (pretty hookResult.error) + "" diff --git a/tasklite-core/source/ImportTask.hs b/tasklite-core/source/ImportTask.hs index 1fd657d..9c10106 100644 --- a/tasklite-core/source/ImportTask.hs +++ b/tasklite-core/source/ImportTask.hs @@ -57,6 +57,7 @@ import Data.Hourglass ( import Data.Text qualified as T import Data.Time.ISO8601.Duration qualified as Iso import Data.ULID (ULID, ulidFromInteger) +import FullTask qualified import Note (Note (..)) import System.Hourglass (dateCurrent) import Task ( @@ -475,3 +476,27 @@ setMissingFields importTaskRec = do else show importTaskRec.task.modified_utc } } + + +importTaskToFullTask :: ImportTask -> FullTask.FullTask +importTaskToFullTask ImportTask{task, notes, tags} = + FullTask.FullTask + { FullTask.ulid = task.ulid + , FullTask.body = task.body + , FullTask.modified_utc = task.modified_utc + , FullTask.awake_utc = task.awake_utc + , FullTask.ready_utc = task.ready_utc + , FullTask.waiting_utc = task.waiting_utc + , FullTask.review_utc = task.review_utc + , FullTask.due_utc = task.due_utc + , FullTask.closed_utc = task.closed_utc + , FullTask.state = task.state + , FullTask.group_ulid = task.group_ulid + , FullTask.repetition_duration = task.repetition_duration + , FullTask.recurrence_duration = task.recurrence_duration + , FullTask.tags = Just tags + , FullTask.notes = Just notes + , FullTask.priority = task.priority_adjustment + , FullTask.user = task.user + , FullTask.metadata = task.metadata + } diff --git a/tasklite-core/source/Lib.hs b/tasklite-core/source/Lib.hs index 040a3e8..fbad955 100644 --- a/tasklite-core/source/Lib.hs +++ b/tasklite-core/source/Lib.hs @@ -222,8 +222,12 @@ import FullTask ( cpTimesAndState, selectQuery, ) -import Hooks (HookResult (error, message, taskToAdd, warning), executeHooks) -import ImportTask (setMissingFields, task) +import Hooks (HookResult (error, message, task, warning), executeHooks) +import ImportTask ( + ImportTask (ImportTask, notes, tags, task), + importTaskToFullTask, + setMissingFields, + ) import Note (Note (body, ulid)) import Prettyprinter.Internal.Type (Doc (Empty)) import SqlUtils (quoteKeyword, quoteText) @@ -489,15 +493,20 @@ addTask conf connection bodyWords = do (ulid, modified_utc, effectiveUserName) <- getTriple conf let (body, tags, dueUtcMb, createdUtcMb) = parseTaskBody bodyWords - taskDraft = - emptyTask - { Task.ulid = T.toLower $ show $ case createdUtcMb of - Nothing -> ulid - Just createdUtc -> setDateTime ulid createdUtc - , Task.body = body - , Task.due_utc = dueUtcMb - , Task.modified_utc = modified_utc - , Task.user = T.pack effectiveUserName + importTaskDraft = + ImportTask + { task = + emptyTask + { Task.ulid = T.toLower $ show $ case createdUtcMb of + Nothing -> ulid + Just createdUtc -> setDateTime ulid createdUtc + , Task.body = body + , Task.due_utc = dueUtcMb + , Task.modified_utc = modified_utc + , Task.user = T.pack effectiveUserName + } + , tags = tags + , notes = [] -- TODO: Add notes to task } args <- getArgs @@ -508,22 +517,21 @@ addTask conf connection bodyWords = do Aeson.encode $ object [ "arguments" .= args - , "taskToAdd" .= taskDraft - -- TODO: Add tags and notes to task + , "taskToAdd" .= importTaskToFullTask importTaskDraft ] ) conf.hooks.add.pre -- Maybe the task was changed by the hook - (task, preAddHookMsg) <- case preAddResults of - [] -> pure (taskDraft, Empty) + (importTask, preAddHookMsg) <- case preAddResults of + [] -> pure (importTaskDraft, Empty) [Left error] -> do _ <- exitFailure - pure (taskDraft, pretty error) + pure (importTaskDraft, pretty error) [Right hookResult] -> do - case hookResult.taskToAdd of - Nothing -> pure (taskDraft, Empty) - Just taskToAdd -> do + case hookResult.task of + Nothing -> pure (importTaskDraft, Empty) + Just task -> do let msg = [ hookResult.message <&> pretty @@ -537,25 +545,25 @@ addTask conf connection bodyWords = do ] & P.filter (\d -> show d /= T.empty) & vsep - fullImportTask <- setMissingFields taskToAdd - pure (fullImportTask.task, msg) + fullImportTask <- setMissingFields task + pure (fullImportTask, msg) _ -> do pure - ( taskDraft + ( importTaskDraft , annotate (color Red) $ "ERROR: Multiple pre-add hooks are not supported yet. " <> "None of the hooks were executed." ) - insertRecord "tasks" connection task - warnings <- insertTags connection Nothing task tags + insertRecord "tasks" connection importTask.task + warnings <- insertTags connection Nothing importTask.task importTask.tags -- TODO: Use RETURNING clause in `insertRecord` instead (insertedTasks :: [FullTask]) <- queryNamed connection "SELECT * FROM tasks_view WHERE ulid == :ulid" - [":ulid" := task.ulid] + [":ulid" := importTask.task.ulid] case insertedTasks of [insertedTask] -> do @@ -575,19 +583,21 @@ addTask conf connection bodyWords = do let postAddHookMsg :: Doc AnsiStyle postAddHookMsg = - postAddResults - <&> \case - Left error -> "ERROR:" <+> pretty error - Right hookResult -> pretty hookResult.message - & P.fold + ( postAddResults + <&> \case + Left error -> "ERROR:" <+> pretty error + Right hookResult -> pretty hookResult.message + & P.fold + ) + <> hardline pure $ [ preAddHookMsg , warnings , "🆕 Added task" - <+> dquotes (pretty task.body) + <+> dquotes (pretty importTask.task.body) <+> "with id" - <+> dquotes (pretty task.ulid) + <+> dquotes (pretty importTask.task.ulid) , postAddHookMsg ] & P.filter (\d -> show d /= T.empty) diff --git a/tasklite-core/source/Utils.hs b/tasklite-core/source/Utils.hs index 5577d98..2cc7874 100644 --- a/tasklite-core/source/Utils.hs +++ b/tasklite-core/source/Utils.hs @@ -76,7 +76,7 @@ import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.ULID (ULID (ULID, random, timeStamp)) import Data.ULID.Random (ULIDRandom, mkULIDRandom) import Data.ULID.TimeStamp (ULIDTimeStamp, mkULIDTimeStamp) -import Prettyprinter (Doc, softline) +import Prettyprinter (Doc, hardline, softline) import Prettyprinter.Render.Terminal ( Color (Black), colorDull, @@ -102,6 +102,12 @@ data ListModifiedFlag = AllItems | ModifiedItemsOnly deriving (Eq, Show) +-- | Always add a hardline after non-empty documents +() :: Doc ann -> Doc ann -> Doc ann +Empty y = y +x y = x <> hardline <> y + + -- | Combine documents with 2 newlines (<++>) :: Doc ann -> Doc ann -> Doc ann x <++> y =