From a20a6d09692f0384865e9741d8e3d1a19b1edc00 Mon Sep 17 00:00:00 2001 From: Adrian Sieber Date: Tue, 23 Apr 2024 17:26:46 +0000 Subject: [PATCH] YAML editing: Include current tags and notes for reference --- tasklite-core/package.yaml | 2 + tasklite-core/source/ImportExport.hs | 19 +-- tasklite-core/source/Lib.hs | 3 +- tasklite-core/source/Task.hs | 81 ++++++++++--- tasklite-core/source/Utils.hs | 4 + tasklite-core/tasklite-core.cabal | 3 + tasklite-core/test/Spec.hs | 2 + tasklite-core/test/TypesSpec.hs | 171 +++++++++++++++++++++++++++ 8 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 tasklite-core/test/TypesSpec.hs diff --git a/tasklite-core/package.yaml b/tasklite-core/package.yaml index 7662d0e..ff9c76b 100644 --- a/tasklite-core/package.yaml +++ b/tasklite-core/package.yaml @@ -107,10 +107,12 @@ tests: - aeson - hourglass - hspec + - neat-interpolation - optparse-applicative - sqlite-simple - tasklite-core - text + - yaml benchmarks: tasklite-bench: diff --git a/tasklite-core/source/ImportExport.hs b/tasklite-core/source/ImportExport.hs index 5e8fe36..0c9d984 100644 --- a/tasklite-core/source/ImportExport.hs +++ b/tasklite-core/source/ImportExport.hs @@ -69,7 +69,7 @@ import Data.Text.Lazy.Encoding qualified as TL import Data.Time.ISO8601.Duration qualified as Iso import Data.ULID (ulidFromInteger) import Data.Vector qualified as V -import Data.Yaml as Yaml (ParseException, decodeEither', encode) +import Data.Yaml as Yaml (ParseException, decodeEither') import Database.SQLite.Simple as Sql (Connection, query_) import FullTask (FullTask) import Lib ( @@ -115,6 +115,7 @@ import Task ( waiting_utc ), setMetadataField, + taskToEditableYaml, textToTaskState, zeroTask, ) @@ -718,8 +719,8 @@ data PreEdit editTaskByTask :: PreEdit -> Connection -> Task -> IO (Doc AnsiStyle) -editTaskByTask preEdit connection taskToEdit = do - let taskYaml = Yaml.encode taskToEdit +editTaskByTask preEdit conn taskToEdit = do + taskYaml <- taskToEditableYaml conn taskToEdit newContent <- case preEdit of ApplyPreEdit editFunc -> pure $ editFunc taskYaml NoPreEdit -> runUserEditorDWIM yamlTemplate taskYaml @@ -764,15 +765,15 @@ editTaskByTask preEdit connection taskToEdit = do else Nothing } - updateTask connection taskFixed + updateTask conn taskFixed warnings <- insertTags - connection + conn Nothing taskFixed (tags importTaskRecord) insertNotes - connection + conn Nothing taskFixed (notes importTaskRecord) @@ -786,6 +787,6 @@ editTaskByTask preEdit connection taskToEdit = do editTask :: Config -> Connection -> IdText -> IO (Doc AnsiStyle) -editTask conf connection idSubstr = do - execWithTask conf connection idSubstr $ \taskToEdit -> do - editTaskByTask NoPreEdit connection taskToEdit +editTask conf conn idSubstr = do + execWithTask conf conn idSubstr $ \taskToEdit -> do + editTaskByTask NoPreEdit conn taskToEdit diff --git a/tasklite-core/source/Lib.hs b/tasklite-core/source/Lib.hs index 44958c1..80d4d8a 100644 --- a/tasklite-core/source/Lib.hs +++ b/tasklite-core/source/Lib.hs @@ -275,6 +275,7 @@ import Utils ( ulidTextToDateTime, utcFormatReadable, utcTimeToDateTime, + vsepCollapse, (<$$>), (<++>), ) @@ -389,7 +390,7 @@ insertTags connection mbCreatedUtc task tags = do (insertRecord "task_to_tag" connection taskToTag P.>> pure "") (handleTagDupError taskToTag.tag) - pure $ vsep insertWarnings + pure $ vsepCollapse insertWarnings insertNotes :: Connection -> Maybe DateTime -> Task -> [Note] -> IO () diff --git a/tasklite-core/source/Task.hs b/tasklite-core/source/Task.hs index a3f02d1..ab24116 100644 --- a/tasklite-core/source/Task.hs +++ b/tasklite-core/source/Task.hs @@ -1,15 +1,16 @@ +{-# LANGUAGE QuasiQuotes #-} + {-| Datatype to represent a task as stored in the `tasks` table -} module Task where -import Protolude as P ( +import Protolude ( Applicative ((<*>)), Either (Left, Right), Enum (toEnum), Eq ((==)), Float, - Foldable (elem), Functor (fmap), Generic, Hashable, @@ -21,6 +22,7 @@ import Protolude as P ( decodeUtf8, fst, otherwise, + pure, show, snd, ($), @@ -30,29 +32,23 @@ import Protolude as P ( (<$>), (<&>), ) +import Protolude qualified as P import Data.Aeson as Aeson ( FromJSON, ToJSON, Value (Object), - encode, eitherDecodeStrictText, + encode, ) import Data.Aeson.Key as Key (fromText) import Data.Aeson.KeyMap as KeyMap (fromList, insert) import Data.ByteString.Lazy qualified as BSL -import Data.Csv as Csv (ToField (..), ToNamedRecord, ToRecord) +import Data.Csv qualified as Csv import Data.Generics (Data) import Data.Hourglass (DateTime, timePrint) -import Data.Text as T ( - Text, - dropEnd, - intercalate, - pack, - replace, - toLower, - unpack, - ) +import Data.Text (Text, pack) +import Data.Text qualified as T import Data.Yaml as Yaml (encode) import Database.SQLite.Simple as Sql ( FromRow (..), @@ -74,6 +70,8 @@ import Test.QuickCheck (Arbitrary (arbitrary)) import Test.QuickCheck.Instances.Text () import Config (defaultConfig, utcFormat) +import Database.SQLite.Simple (Connection, Only (Only), query) +import Database.SQLite.Simple.QQ (sql) data TaskState @@ -107,8 +105,8 @@ instance FromJSON TaskState instance ToJSON TaskState -instance ToRecord TaskState -instance ToNamedRecord TaskState +instance Csv.ToRecord TaskState +instance Csv.ToNamedRecord TaskState instance Csv.ToField TaskState where @@ -395,3 +393,56 @@ setMetadataField fieldNameText value task = Just $ Object $ fromList [(Key.fromText fieldNameText, value)] _ -> metadata task } + + +{-| Convert a task to a YAML string that can be edited +| and then converted back to a task. +| Tags and notes are commented out, so they are not accidentally added again. +-} +taskToEditableYaml :: Connection -> Task -> P.IO P.ByteString +taskToEditableYaml conn task = do + (tags :: [[P.Text]]) <- + query + conn + [sql| + SELECT tag + FROM task_to_tag + WHERE task_ulid == ? + |] + (Only $ ulid task) + + (notes :: [[P.Text]]) <- + query + conn + [sql| + SELECT note + FROM task_to_note + WHERE task_ulid == ? + |] + (Only $ ulid task) + + let indentNoteContent noteContent = + noteContent + & T.strip + & T.lines + <&> T.stripEnd + & T.intercalate "\n# " + + pure $ + ( task + & Yaml.encode + & P.decodeUtf8 + ) + <> "\n# | Existing tags and notes can't be edited here, \ + \but new ones can be added\n\n" + <> (("# tags: " :: Text) <> P.show (P.concat tags) <> "\n") + <> "tags: []\n" + <> ( ("\n# notes:\n" :: Text) + <> ( notes + & P.concat + <&> (\note -> "# - " <> indentNoteContent note) + & T.unlines + ) + ) + <> "notes: []\n" + & P.encodeUtf8 diff --git a/tasklite-core/source/Utils.hs b/tasklite-core/source/Utils.hs index c835311..f4c9692 100644 --- a/tasklite-core/source/Utils.hs +++ b/tasklite-core/source/Utils.hs @@ -126,6 +126,10 @@ x <$$> y = x <++> y infixr 6 <$$> +vsepCollapse :: [Doc ann] -> Doc ann +vsepCollapse = P.foldr (<$$>) Empty + + zeroTime :: DateTime zeroTime = timeFromElapsedP 0 diff --git a/tasklite-core/tasklite-core.cabal b/tasklite-core/tasklite-core.cabal index 720c97a..e41bf44 100644 --- a/tasklite-core/tasklite-core.cabal +++ b/tasklite-core/tasklite-core.cabal @@ -138,6 +138,7 @@ test-suite tasklite-test CliSpec LibSpec TestUtils + TypesSpec Paths_tasklite_core autogen-modules: Paths_tasklite_core @@ -159,11 +160,13 @@ test-suite tasklite-test , base >=4.7 && <5 , hourglass , hspec + , neat-interpolation , optparse-applicative , protolude , sqlite-simple , tasklite-core , text + , yaml default-language: GHC2021 benchmark tasklite-bench diff --git a/tasklite-core/test/Spec.hs b/tasklite-core/test/Spec.hs index 8b4623e..739ccd4 100644 --- a/tasklite-core/test/Spec.hs +++ b/tasklite-core/test/Spec.hs @@ -89,6 +89,7 @@ import TaskToNote qualified import TaskToTag (TaskToTag) import TaskToTag qualified import TestUtils (withMemoryDb) +import TypesSpec qualified import Utils (parseUlidText, parseUlidUtcSection, parseUtc, ulid2utc) @@ -442,6 +443,7 @@ testSuite conf now = do ulid2utc updatedTask.ulid `shouldBe` Just utcFromUlid _ -> P.die "More than one task found" + TypesSpec.spec LibSpec.spec now CliSpec.spec diff --git a/tasklite-core/test/TypesSpec.hs b/tasklite-core/test/TypesSpec.hs new file mode 100644 index 0000000..c242693 --- /dev/null +++ b/tasklite-core/test/TypesSpec.hs @@ -0,0 +1,171 @@ +{-# LANGUAGE QuasiQuotes #-} + +module TypesSpec where + +import Protolude (Maybe (..), Text, ($), (&), (<&>), (<>)) +import Protolude qualified as P + +import Test.Hspec (Spec, describe, it, shouldBe) + +import Config (defaultConfig) +import Data.Text qualified as T +import Data.Yaml qualified +import FullTask (FullTask (body, notes, tags, ulid), emptyFullTask) +import Lib (insertNotes, insertRecord, insertTags) +import NeatInterpolation (trimming) +import Note (Note (Note, body, ulid)) +import Task (Task (body, ulid), taskToEditableYaml, zeroTask) +import TestUtils (withMemoryDb) + + +sampleNotes :: [Note] +sampleNotes = + [ Note + { Note.ulid = "01hw5n9m99papg470w8j9k9vd3" + , Note.body = + "Sample note 1 is quite long \ + \so we can observer how automatic wrapping \ + \can produce unexpected results." + } + , Note + { Note.ulid = "01hw5n9y3q27zys83b139s7e2r" + , Note.body = + "\nSample note 2 is short\n\ + \but has \nsurprising \nline breaks." + } + ] + + +spec :: Spec +spec = do + describe "Types" $ do + describe "Task" $ do + let + sampleTask = + zeroTask + { Task.ulid = "01hs68z7mdg4ktpxbv0yfafznq" + , Task.body = "Sample task" + } + + it "can be converted to YAML" $ do + let + taskYaml = sampleTask & Data.Yaml.encode & P.decodeUtf8 + + expected :: Text + expected = + [trimming| + awake_utc: null + body: Sample task + closed_utc: null + due_utc: null + group_ulid: null + metadata: null + modified_utc: '' + priority_adjustment: null + ready_utc: null + recurrence_duration: null + repetition_duration: null + review_utc: null + state: null + ulid: 01hs68z7mdg4ktpxbv0yfafznq + user: '' + waiting_utc: null + |] + <> "\n" + + taskYaml `shouldBe` expected + + it "can be converted to YAML for editing" $ do + withMemoryDb defaultConfig $ \memConn -> do + Lib.insertRecord "tasks" memConn sampleTask + let tags = [0 .. 9] <&> \(i :: P.Int) -> "tag-" <> P.show i + warnings <- Lib.insertTags memConn Nothing sampleTask tags + P.show warnings `shouldBe` T.empty + Lib.insertNotes memConn Nothing sampleTask sampleNotes + + taskYaml <- taskToEditableYaml memConn sampleTask + let + expected :: P.ByteString + expected = + [trimming| + awake_utc: null + body: Sample task + closed_utc: null + due_utc: null + group_ulid: null + metadata: null + modified_utc: '' + priority_adjustment: null + ready_utc: null + recurrence_duration: null + repetition_duration: null + review_utc: null + state: null + ulid: 01hs68z7mdg4ktpxbv0yfafznq + user: '' + waiting_utc: null + + # | Existing tags and notes can't be edited here, but new ones can be added + + # tags: ["tag-0","tag-1","tag-2","tag-3","tag-4","tag-5","tag-6","tag-7","tag-8","tag-9"] + tags: [] + + # notes: + # - Sample note 1 is quite long so we can observer how automatic wrapping can produce unexpected results. + # - Sample note 2 is short + # but has + # surprising + # line breaks. + notes: [] + |] + <> "\n" + & P.encodeUtf8 + + taskYaml `shouldBe` expected + + describe "FullTask" $ do + let + sampleFullTask = + emptyFullTask + { FullTask.ulid = "01hs68z7mdg4ktpxbv0yfafznq" + , FullTask.body = "Sample task" + , FullTask.tags = Just ["tag1", "tag2"] + , FullTask.notes = Just sampleNotes + } + + it "can be converted to YAML" $ do + let + taskYaml = sampleFullTask & Data.Yaml.encode & P.decodeUtf8 + + expected :: Text + expected = + [trimming| + awake_utc: null + body: Sample task + closed_utc: null + due_utc: null + group_ulid: null + metadata: null + modified_utc: '' + notes: + - body: Sample note 1 is quite long so we can observer how automatic wrapping can + produce unexpected results. + ulid: 01hw5n9m99papg470w8j9k9vd3 + - body: "\nSample note 2 is short\nbut has \nsurprising \nline breaks." + ulid: 01hw5n9y3q27zys83b139s7e2r + priority: null + ready_utc: null + recurrence_duration: null + repetition_duration: null + review_utc: null + state: null + tags: + - tag1 + - tag2 + ulid: 01hs68z7mdg4ktpxbv0yfafznq + user: '' + waiting_utc: null + |] + <> "\n" + + taskYaml `shouldBe` expected