diff --git a/doc/man/task.1.in b/doc/man/task.1.in index bed3c8f3a..76ad75d96 100644 --- a/doc/man/task.1.in +++ b/doc/man/task.1.in @@ -1509,6 +1509,14 @@ quotes to the description or escaping the special character: $ task add escaped \\' quote .fi +Descriptions for add and modify can also be supplied from piped stdin. These +forms avoid shell interpretation of description text: + +.nf + $ printf 'literal text' | task add + $ printf 'literal text' | task 123 modify +.fi + The argument \-\- (a double dash) tells Taskwarrior to treat all other args as description: diff --git a/src/Task.cpp b/src/Task.cpp index a9d47c5d1..daca2eda0 100644 --- a/src/Task.cpp +++ b/src/Task.cpp @@ -1946,6 +1946,13 @@ void Task::modify(modType type, bool text_required /* = false */) { // 'value' requires eval. std::string name = a.attribute("canonical"); std::string value = a.attribute("value"); + if (name == "description" && a.hasTag("DESCRIPTION_INPUT")) { + Context::getContext().debug(label + "description <-- '" + value + '\''); + set("description", value); + mods = true; + continue; + } + if (value == "" || value == "''" || value == "\"\"") { // Special case: Handle bulk removal of 'tags' and 'depends" virtual // attributes diff --git a/src/commands/CMakeLists.txt b/src/commands/CMakeLists.txt index 320a09308..bbcf900a3 100644 --- a/src/commands/CMakeLists.txt +++ b/src/commands/CMakeLists.txt @@ -7,6 +7,7 @@ include_directories (${CMAKE_SOURCE_DIR} ${TASK_INCLUDE_DIRS}) set (commands_SRCS Command.cpp Command.h + DescriptionInput.cpp DescriptionInput.h CmdAdd.cpp CmdAdd.h CmdAliases.cpp CmdAliases.h CmdAnnotate.cpp CmdAnnotate.h diff --git a/src/commands/CmdAdd.cpp b/src/commands/CmdAdd.cpp index 2857b5f44..aefd514a9 100644 --- a/src/commands/CmdAdd.cpp +++ b/src/commands/CmdAdd.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -49,6 +50,8 @@ CmdAdd::CmdAdd() { //////////////////////////////////////////////////////////////////////////////// int CmdAdd::execute(std::string& output) { + applyPipedDescriptionInput(); + // Apply the command line modifications to the new task. Task task; diff --git a/src/commands/CmdHelp.cpp b/src/commands/CmdHelp.cpp index cf2215cdf..c27494f38 100644 --- a/src/commands/CmdHelp.cpp +++ b/src/commands/CmdHelp.cpp @@ -156,6 +156,10 @@ int CmdHelp::execute(std::string& output) { " task add \"quoted ' quote\"\n" " task add escaped \\' quote\n" "\n" + "Descriptions can also be supplied from piped stdin:\n" + " printf 'literal text' | task add\n" + " printf 'literal text' | task 123 modify\n" + "\n" "The argument -- tells Taskwarrior to treat all other args as description, even " "if they would otherwise be attributes or tags:\n" " task add -- project:Home needs scheduling\n" diff --git a/src/commands/CmdModify.cpp b/src/commands/CmdModify.cpp index f289a9401..8560c687e 100644 --- a/src/commands/CmdModify.cpp +++ b/src/commands/CmdModify.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -58,6 +59,8 @@ CmdModify::CmdModify() { //////////////////////////////////////////////////////////////////////////////// int CmdModify::execute(std::string&) { auto rc = 0; + bool pipedDescription = applyPipedDescriptionInput(); + if (pipedDescription) _permission_all = true; // Apply filter. Filter filter; diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp new file mode 100644 index 000000000..15809297c --- /dev/null +++ b/src/commands/DescriptionInput.cpp @@ -0,0 +1,120 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez. +// +// 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. +// +// https://www.opensource.org/licenses/mit-license.php +// +//////////////////////////////////////////////////////////////////////////////// + +#include +// cmake.h include header must come first + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +//////////////////////////////////////////////////////////////////////////////// +std::string readStdin() { + std::ostringstream buffer; + buffer << std::cin.rdbuf(); + if (std::cin.bad()) throw std::string("Failed to read description from stdin."); + + auto description = buffer.str(); + while (!description.empty() && std::isspace(static_cast(description.back()))) + description.pop_back(); + return description; +} + +//////////////////////////////////////////////////////////////////////////////// +A2 descriptionModification(const std::string& description) { + A2 arg("description:", Lexer::Type::pair); + arg.attribute("name", "description"); + arg.attribute("separator", ":"); + arg.attribute("canonical", "description"); + arg.attribute("value", description); + arg.tag("MODIFICATION"); + arg.tag("DESCRIPTION_INPUT"); + return arg; +} + +//////////////////////////////////////////////////////////////////////////////// +bool isDescriptionWord(const A2& arg) { + if (!arg.hasTag("MODIFICATION")) return false; + if (arg._lextype != Lexer::Type::word) return false; + + std::string raw = arg.attribute("raw"); + return raw.substr(0, 7) != "before:" && raw.substr(0, 6) != "after:"; +} + +//////////////////////////////////////////////////////////////////////////////// +bool isDescriptionPair(const A2& arg) { + if (!arg.hasTag("MODIFICATION")) return false; + if (arg._lextype != Lexer::Type::pair) return false; + + return arg.attribute("canonical") == "description" || arg.attribute("name") == "description"; +} + +//////////////////////////////////////////////////////////////////////////////// +bool isModification(const A2& arg) { return arg.hasTag("MODIFICATION"); } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +bool applyPipedDescriptionInput() { + auto& args = Context::getContext().cli2._args; + if (isatty(STDIN_FILENO)) return false; + + for (const auto& arg : args) + if (isDescriptionWord(arg) || isDescriptionPair(arg)) return false; + + if (Context::getContext().cli2.getCommand() == "modify") + for (const auto& arg : args) + if (isModification(arg)) return false; + + auto description = readStdin(); + if (description == "") return false; + + auto descriptionArg = descriptionModification(description); + std::vector reconstructed; + bool inserted = false; + for (const auto& arg : args) { + if (!inserted && arg.hasTag("MODIFICATION")) { + reconstructed.push_back(descriptionArg); + inserted = true; + } + + reconstructed.push_back(arg); + } + + if (!inserted) reconstructed.push_back(descriptionArg); + args = reconstructed; + return true; +} + +//////////////////////////////////////////////////////////////////////////////// diff --git a/src/commands/DescriptionInput.h b/src/commands/DescriptionInput.h new file mode 100644 index 000000000..1c06c0605 --- /dev/null +++ b/src/commands/DescriptionInput.h @@ -0,0 +1,33 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez. +// +// 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. +// +// https://www.opensource.org/licenses/mit-license.php +// +//////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDED_DESCRIPTIONINPUT +#define INCLUDED_DESCRIPTIONINPUT + +bool applyPipedDescriptionInput(); + +#endif +//////////////////////////////////////////////////////////////////////////////// diff --git a/test/add.test.py b/test/add.test.py index eb9878feb..782c482a0 100755 --- a/test/add.test.py +++ b/test/add.test.py @@ -122,6 +122,30 @@ def test_single_quote_preserved(self): code, out, err = self.t("_get 1.description") self.assertIn("Return Randy's stuff\n", out) + def test_add_description_from_pipe(self): + "Testing add command with description read from piped stdin" + + description = '"Line one" with `code`\nLine two with $HOME and (parens)' + self.t.runSuccess("add", input=description + "\n") + + self.assertEqual(self.t.latest["description"], description) + + def test_add_piped_description_with_modification(self): + "Testing add command with piped description and other modifications" + + description = '"Line one"\nLine two with priority' + self.t.runSuccess("add priority:H", input=description + "\n") + + self.assertEqual(self.t.latest["description"], description) + self.assertEqual(self.t.latest["priority"], "H") + + def test_add_positional_description_ignores_piped_stdin(self): + "Testing add command keeps positional description when stdin is piped" + + self.t.runSuccess("add positional description", input="piped description") + + self.assertEqual(self.t.latest["description"], "positional description") + class TestBug1359(TestCase): def setUp(self): diff --git a/test/modify.test.py b/test/modify.test.py index c2d49a977..174d9f6d3 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -67,6 +67,60 @@ def test_mod_pending_task_end_date(self): self.assertIn("You cannot set an end date on a pending task.", err) +class TestModifyDescriptionInput(TestCase): + def setUp(self): + self.t = Task() + self.t("add original") + + def test_modify_description_from_pipe(self): + "Testing modify command with description read from piped stdin" + + description = '"Line one" with `code`\nLine two with $HOME and (parens)' + self.t.runSuccess("1 modify", input=description + "\n") + + self.assertEqual(self.t.export_one("1")["description"], description) + + def test_modify_with_modification_ignores_piped_stdin(self): + "Testing modify command keeps stdin available when modifications are present" + + self.t.runSuccess("1 modify priority:H", input="not a description") + + self.assertEqual(self.t.export_one("1")["description"], "original") + self.assertEqual(self.t.export_one("1")["priority"], "H") + + def test_modify_with_bulk_confirmation_keeps_stdin_for_prompt(self): + "Testing bulk modify still reads confirmation from stdin" + + self.t("add second") + self.t.config("bulk", "2") + + self.t.runSuccess("1 2 modify priority:H", input="All\n") + + self.assertEqual(self.t.export_one("1")["description"], "original") + self.assertEqual(self.t.export_one("2")["description"], "second") + self.assertEqual(self.t.export_one("1")["priority"], "H") + self.assertEqual(self.t.export_one("2")["priority"], "H") + + def test_modify_bulk_description_from_pipe_auto_confirms(self): + "Testing bulk modify with piped description does not prompt from exhausted stdin" + + self.t("add second") + self.t.config("bulk", "2") + description = "bulk description" + + self.t.runSuccess("1 2 modify", input=description + "\n") + + self.assertEqual(self.t.export_one("1")["description"], description) + self.assertEqual(self.t.export_one("2")["description"], description) + + def test_modify_positional_description_ignores_piped_stdin(self): + "Testing modify command keeps positional description when stdin is piped" + + self.t.runSuccess("1 modify positional description", input="piped description") + + self.assertEqual(self.t.export_one("1")["description"], "positional description") + + if __name__ == "__main__": from simpletap import TAPTestRunner