From daf7acfde2701dbc700f69ff2a3da22fc0700dbc Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:00:43 +0800 Subject: [PATCH 1/5] feat(task): add description input options --- doc/man/task.1.in | 21 ++++ src/Task.cpp | 7 ++ src/commands/CMakeLists.txt | 1 + src/commands/CmdAdd.cpp | 3 + src/commands/CmdHelp.cpp | 5 + src/commands/CmdModify.cpp | 2 + src/commands/DescriptionInput.cpp | 165 ++++++++++++++++++++++++++++++ src/commands/DescriptionInput.h | 33 ++++++ test/add.test.py | 28 +++++ test/modify.test.py | 34 ++++++ 10 files changed, 299 insertions(+) create mode 100644 src/commands/DescriptionInput.cpp create mode 100644 src/commands/DescriptionInput.h diff --git a/doc/man/task.1.in b/doc/man/task.1.in index bed3c8f3a..80620a65d 100644 --- a/doc/man/task.1.in +++ b/doc/man/task.1.in @@ -360,6 +360,12 @@ Shows all waiting tasks matching the filter. .TP .B task add +.br +.B task add --stdin +.br +.B task add --file +.br +.B task add --description Adds a new pending task to the task list. It is affected by the currently set context. @@ -426,6 +432,12 @@ the currently set context. .TP .B task modify +.br +.B task modify --stdin +.br +.B task modify --file +.br +.B task modify --description Modifies the existing task with provided information. .TP @@ -1509,6 +1521,15 @@ quotes to the description or escaping the special character: $ task add escaped \\' quote .fi +Descriptions for add and modify can also be supplied with named options. These +forms avoid shell interpretation of description text: + +.nf + $ task add --description "literal text" + $ task add --file ./description.txt + $ printf 'literal text' | task 123 modify --stdin +.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..cf81a87dc 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) { + applyDescriptionInputOptions(); + // 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..148af0cbd 100644 --- a/src/commands/CmdHelp.cpp +++ b/src/commands/CmdHelp.cpp @@ -156,6 +156,11 @@ int CmdHelp::execute(std::string& output) { " task add \"quoted ' quote\"\n" " task add escaped \\' quote\n" "\n" + "Descriptions can also be supplied without shell escaping:\n" + " task add --description \"literal text\"\n" + " task add --file ./description.txt\n" + " task 123 modify --stdin\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..8aa150410 100644 --- a/src/commands/CmdModify.cpp +++ b/src/commands/CmdModify.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -58,6 +59,7 @@ CmdModify::CmdModify() { //////////////////////////////////////////////////////////////////////////////// int CmdModify::execute(std::string&) { auto rc = 0; + applyDescriptionInputOptions(); // Apply filter. Filter filter; diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp new file mode 100644 index 000000000..a17ad61ba --- /dev/null +++ b/src/commands/DescriptionInput.cpp @@ -0,0 +1,165 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// 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 + +namespace { + +//////////////////////////////////////////////////////////////////////////////// +bool splitInlineOption(const std::string& raw, const std::string& option, std::string& value) { + if (raw.size() <= option.size()) return false; + if (raw.compare(0, option.size(), option) != 0) return false; + if (raw[option.size()] != ':' && raw[option.size()] != '=') return false; + + value = raw.substr(option.size() + 1); + return true; +} + +//////////////////////////////////////////////////////////////////////////////// +std::string readStdin() { + std::ostringstream buffer; + buffer << std::cin.rdbuf(); + if (std::cin.bad() || buffer.fail()) throw std::string("Failed to read description from stdin."); + + return buffer.str(); +} + +//////////////////////////////////////////////////////////////////////////////// +std::string readFile(const std::string& path) { + std::ifstream in(path.c_str(), std::ios::in | std::ios::binary); + if (!in) throw format("Failed to read description from file '{1}'.", path); + + std::ostringstream buffer; + buffer << in.rdbuf(); + if (in.bad() || buffer.fail()) throw format("Failed to read description from file '{1}'.", path); + + return buffer.str(); +} + +//////////////////////////////////////////////////////////////////////////////// +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 isDescriptionInputOption(const std::string& raw) { + std::string unused; + return raw == "--stdin" || raw == "--file" || raw == "--description" || + splitInlineOption(raw, "--file", unused) || splitInlineOption(raw, "--description", unused); +} + +//////////////////////////////////////////////////////////////////////////////// +bool isPositionalDescriptionWord(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:"; +} + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +void applyDescriptionInputOptions() { + auto& args = Context::getContext().cli2._args; + std::vector reconstructed; + bool found = false; + std::string description; + + for (size_t i = 0; i < args.size(); ++i) { + const auto& arg = args[i]; + const auto raw = arg.attribute("raw"); + std::string value; + + if (raw == "--stdin") { + if (found) throw std::string("Specify only one of --stdin, --file, or --description."); + description = readStdin(); + found = true; + continue; + } + + if (raw == "--file") { + if (found) throw std::string("Specify only one of --stdin, --file, or --description."); + if (i + 1 == args.size()) throw std::string("The --file option requires a path."); + description = readFile(args[++i].attribute("raw")); + found = true; + continue; + } + + if (splitInlineOption(raw, "--file", value)) { + if (found) throw std::string("Specify only one of --stdin, --file, or --description."); + description = readFile(value); + found = true; + continue; + } + + if (raw == "--description") { + if (found) throw std::string("Specify only one of --stdin, --file, or --description."); + if (i + 1 == args.size()) throw std::string("The --description option requires text."); + description = args[++i].attribute("raw"); + found = true; + continue; + } + + if (splitInlineOption(raw, "--description", value)) { + if (found) throw std::string("Specify only one of --stdin, --file, or --description."); + description = value; + found = true; + continue; + } + + reconstructed.push_back(arg); + } + + if (!found) return; + + for (const auto& arg : reconstructed) + if (isDescriptionInputOption(arg.attribute("raw")) || isPositionalDescriptionWord(arg)) + throw std::string( + "Description input options cannot be combined with positional description text."); + + reconstructed.push_back(descriptionModification(description)); + args = reconstructed; +} + +//////////////////////////////////////////////////////////////////////////////// diff --git a/src/commands/DescriptionInput.h b/src/commands/DescriptionInput.h new file mode 100644 index 000000000..9b4d6d406 --- /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 + +void applyDescriptionInputOptions(); + +#endif +//////////////////////////////////////////////////////////////////////////////// diff --git a/test/add.test.py b/test/add.test.py index eb9878feb..2be761b31 100755 --- a/test/add.test.py +++ b/test/add.test.py @@ -122,6 +122,34 @@ 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_stdin(self): + "Testing add command with description read from stdin" + + description = '"Line one" with `code`\nLine two with $HOME and (parens)' + self.t.runSuccess("add --stdin", input=description) + + self.assertEqual(self.t.latest["description"], description) + + def test_add_description_from_file(self): + "Testing add command with description read from a file" + + path = os.path.join(self.t.datadir, "description.txt") + description = '"File line one"\nFile line two with `ticks`' + with open(path, "w", encoding="utf-8") as f: + f.write(description) + + self.t.runSuccess(["add", "--file", path]) + + self.assertEqual(self.t.latest["description"], description) + + def test_add_description_from_named_option(self): + "Testing add command with description provided by named option" + + description = '"Named description with `ticks` and (parens)"' + self.t.runSuccess(["add", "--description", description]) + + self.assertEqual(self.t.latest["description"], description) + class TestBug1359(TestCase): def setUp(self): diff --git a/test/modify.test.py b/test/modify.test.py index c2d49a977..32bdecca6 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -67,6 +67,40 @@ 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_stdin(self): + "Testing modify command with description read from stdin" + + description = '"Line one" with `code`\nLine two with $HOME and (parens)' + self.t.runSuccess("1 modify --stdin", input=description) + + self.assertEqual(self.t.export_one("1")["description"], description) + + def test_modify_description_from_file(self): + "Testing modify command with description read from a file" + + path = os.path.join(self.t.datadir, "description.txt") + description = '"File line one"\nFile line two with `ticks`' + with open(path, "w", encoding="utf-8") as f: + f.write(description) + + self.t.runSuccess(["1", "modify", "--file", path]) + + self.assertEqual(self.t.export_one("1")["description"], description) + + def test_modify_description_from_named_option(self): + "Testing modify command with description provided by named option" + + description = '"Named description with `ticks` and (parens)"' + self.t.runSuccess(["1", "modify", "--description", description]) + + self.assertEqual(self.t.export_one("1")["description"], description) + + if __name__ == "__main__": from simpletap import TAPTestRunner From 87e8811b7af2b53cb53a50f89e9063adf5b2589a Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:22:49 +0800 Subject: [PATCH 2/5] feat(task): read piped descriptions --- doc/man/task.1.in | 19 +----- src/commands/CmdAdd.cpp | 2 +- src/commands/CmdHelp.cpp | 7 +- src/commands/CmdModify.cpp | 2 +- src/commands/DescriptionInput.cpp | 107 +++++++----------------------- src/commands/DescriptionInput.h | 2 +- test/add.test.py | 28 ++++---- test/modify.test.py | 26 +++----- 8 files changed, 57 insertions(+), 136 deletions(-) diff --git a/doc/man/task.1.in b/doc/man/task.1.in index 80620a65d..76ad75d96 100644 --- a/doc/man/task.1.in +++ b/doc/man/task.1.in @@ -360,12 +360,6 @@ Shows all waiting tasks matching the filter. .TP .B task add -.br -.B task add --stdin -.br -.B task add --file -.br -.B task add --description Adds a new pending task to the task list. It is affected by the currently set context. @@ -432,12 +426,6 @@ the currently set context. .TP .B task modify -.br -.B task modify --stdin -.br -.B task modify --file -.br -.B task modify --description Modifies the existing task with provided information. .TP @@ -1521,13 +1509,12 @@ quotes to the description or escaping the special character: $ task add escaped \\' quote .fi -Descriptions for add and modify can also be supplied with named options. These +Descriptions for add and modify can also be supplied from piped stdin. These forms avoid shell interpretation of description text: .nf - $ task add --description "literal text" - $ task add --file ./description.txt - $ printf 'literal text' | task 123 modify --stdin + $ printf 'literal text' | task add + $ printf 'literal text' | task 123 modify .fi The argument \-\- (a double dash) tells Taskwarrior to treat all other args diff --git a/src/commands/CmdAdd.cpp b/src/commands/CmdAdd.cpp index cf81a87dc..aefd514a9 100644 --- a/src/commands/CmdAdd.cpp +++ b/src/commands/CmdAdd.cpp @@ -50,7 +50,7 @@ CmdAdd::CmdAdd() { //////////////////////////////////////////////////////////////////////////////// int CmdAdd::execute(std::string& output) { - applyDescriptionInputOptions(); + 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 148af0cbd..c27494f38 100644 --- a/src/commands/CmdHelp.cpp +++ b/src/commands/CmdHelp.cpp @@ -156,10 +156,9 @@ int CmdHelp::execute(std::string& output) { " task add \"quoted ' quote\"\n" " task add escaped \\' quote\n" "\n" - "Descriptions can also be supplied without shell escaping:\n" - " task add --description \"literal text\"\n" - " task add --file ./description.txt\n" - " task 123 modify --stdin\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" diff --git a/src/commands/CmdModify.cpp b/src/commands/CmdModify.cpp index 8aa150410..a51bd2a86 100644 --- a/src/commands/CmdModify.cpp +++ b/src/commands/CmdModify.cpp @@ -59,7 +59,7 @@ CmdModify::CmdModify() { //////////////////////////////////////////////////////////////////////////////// int CmdModify::execute(std::string&) { auto rc = 0; - applyDescriptionInputOptions(); + applyPipedDescriptionInput(); // Apply filter. Filter filter; diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp index a17ad61ba..714138b04 100644 --- a/src/commands/DescriptionInput.cpp +++ b/src/commands/DescriptionInput.cpp @@ -30,41 +30,19 @@ #include #include #include +#include -#include #include #include #include namespace { -//////////////////////////////////////////////////////////////////////////////// -bool splitInlineOption(const std::string& raw, const std::string& option, std::string& value) { - if (raw.size() <= option.size()) return false; - if (raw.compare(0, option.size(), option) != 0) return false; - if (raw[option.size()] != ':' && raw[option.size()] != '=') return false; - - value = raw.substr(option.size() + 1); - return true; -} - //////////////////////////////////////////////////////////////////////////////// std::string readStdin() { std::ostringstream buffer; buffer << std::cin.rdbuf(); - if (std::cin.bad() || buffer.fail()) throw std::string("Failed to read description from stdin."); - - return buffer.str(); -} - -//////////////////////////////////////////////////////////////////////////////// -std::string readFile(const std::string& path) { - std::ifstream in(path.c_str(), std::ios::in | std::ios::binary); - if (!in) throw format("Failed to read description from file '{1}'.", path); - - std::ostringstream buffer; - buffer << in.rdbuf(); - if (in.bad() || buffer.fail()) throw format("Failed to read description from file '{1}'.", path); + if (std::cin.bad()) throw std::string("Failed to read description from stdin."); return buffer.str(); } @@ -82,14 +60,7 @@ A2 descriptionModification(const std::string& description) { } //////////////////////////////////////////////////////////////////////////////// -bool isDescriptionInputOption(const std::string& raw) { - std::string unused; - return raw == "--stdin" || raw == "--file" || raw == "--description" || - splitInlineOption(raw, "--file", unused) || splitInlineOption(raw, "--description", unused); -} - -//////////////////////////////////////////////////////////////////////////////// -bool isPositionalDescriptionWord(const A2& arg) { +bool isDescriptionWord(const A2& arg) { if (!arg.hasTag("MODIFICATION")) return false; if (arg._lextype != Lexer::Type::word) return false; @@ -97,68 +68,40 @@ bool isPositionalDescriptionWord(const A2& arg) { 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"; +} + } // namespace //////////////////////////////////////////////////////////////////////////////// -void applyDescriptionInputOptions() { +void applyPipedDescriptionInput() { auto& args = Context::getContext().cli2._args; - std::vector reconstructed; - bool found = false; - std::string description; - - for (size_t i = 0; i < args.size(); ++i) { - const auto& arg = args[i]; - const auto raw = arg.attribute("raw"); - std::string value; - - if (raw == "--stdin") { - if (found) throw std::string("Specify only one of --stdin, --file, or --description."); - description = readStdin(); - found = true; - continue; - } + if (isatty(STDIN_FILENO)) return; - if (raw == "--file") { - if (found) throw std::string("Specify only one of --stdin, --file, or --description."); - if (i + 1 == args.size()) throw std::string("The --file option requires a path."); - description = readFile(args[++i].attribute("raw")); - found = true; - continue; - } - - if (splitInlineOption(raw, "--file", value)) { - if (found) throw std::string("Specify only one of --stdin, --file, or --description."); - description = readFile(value); - found = true; - continue; - } + for (const auto& arg : args) + if (isDescriptionWord(arg) || isDescriptionPair(arg)) return; - if (raw == "--description") { - if (found) throw std::string("Specify only one of --stdin, --file, or --description."); - if (i + 1 == args.size()) throw std::string("The --description option requires text."); - description = args[++i].attribute("raw"); - found = true; - continue; - } + auto description = readStdin(); + if (description == "") return; - if (splitInlineOption(raw, "--description", value)) { - if (found) throw std::string("Specify only one of --stdin, --file, or --description."); - description = value; - found = true; - continue; + 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 (!found) return; - - for (const auto& arg : reconstructed) - if (isDescriptionInputOption(arg.attribute("raw")) || isPositionalDescriptionWord(arg)) - throw std::string( - "Description input options cannot be combined with positional description text."); - - reconstructed.push_back(descriptionModification(description)); + if (!inserted) reconstructed.push_back(descriptionArg); args = reconstructed; } diff --git a/src/commands/DescriptionInput.h b/src/commands/DescriptionInput.h index 9b4d6d406..b29c56073 100644 --- a/src/commands/DescriptionInput.h +++ b/src/commands/DescriptionInput.h @@ -27,7 +27,7 @@ #ifndef INCLUDED_DESCRIPTIONINPUT #define INCLUDED_DESCRIPTIONINPUT -void applyDescriptionInputOptions(); +void applyPipedDescriptionInput(); #endif //////////////////////////////////////////////////////////////////////////////// diff --git a/test/add.test.py b/test/add.test.py index 2be761b31..150c69141 100755 --- a/test/add.test.py +++ b/test/add.test.py @@ -122,33 +122,29 @@ 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_stdin(self): - "Testing add command with description read from stdin" + 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 --stdin", input=description) + self.t.runSuccess("add", input=description) self.assertEqual(self.t.latest["description"], description) - def test_add_description_from_file(self): - "Testing add command with description read from a file" + def test_add_piped_description_with_modification(self): + "Testing add command with piped description and other modifications" - path = os.path.join(self.t.datadir, "description.txt") - description = '"File line one"\nFile line two with `ticks`' - with open(path, "w", encoding="utf-8") as f: - f.write(description) - - self.t.runSuccess(["add", "--file", path]) + description = '"Line one"\nLine two with priority' + self.t.runSuccess("add priority:H", input=description) self.assertEqual(self.t.latest["description"], description) + self.assertEqual(self.t.latest["priority"], "H") - def test_add_description_from_named_option(self): - "Testing add command with description provided by named option" + def test_add_positional_description_ignores_piped_stdin(self): + "Testing add command keeps positional description when stdin is piped" - description = '"Named description with `ticks` and (parens)"' - self.t.runSuccess(["add", "--description", description]) + self.t.runSuccess("add positional description", input="piped description") - self.assertEqual(self.t.latest["description"], description) + self.assertEqual(self.t.latest["description"], "positional description") class TestBug1359(TestCase): diff --git a/test/modify.test.py b/test/modify.test.py index 32bdecca6..40e62ace7 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -72,33 +72,29 @@ def setUp(self): self.t = Task() self.t("add original") - def test_modify_description_from_stdin(self): - "Testing modify command with description read from stdin" + 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 --stdin", input=description) + self.t.runSuccess("1 modify", input=description) self.assertEqual(self.t.export_one("1")["description"], description) - def test_modify_description_from_file(self): - "Testing modify command with description read from a file" + def test_modify_piped_description_with_modification(self): + "Testing modify command with piped description and other modifications" - path = os.path.join(self.t.datadir, "description.txt") description = '"File line one"\nFile line two with `ticks`' - with open(path, "w", encoding="utf-8") as f: - f.write(description) - - self.t.runSuccess(["1", "modify", "--file", path]) + self.t.runSuccess("1 modify priority:H", input=description) self.assertEqual(self.t.export_one("1")["description"], description) + self.assertEqual(self.t.export_one("1")["priority"], "H") - def test_modify_description_from_named_option(self): - "Testing modify command with description provided by named option" + def test_modify_positional_description_ignores_piped_stdin(self): + "Testing modify command keeps positional description when stdin is piped" - description = '"Named description with `ticks` and (parens)"' - self.t.runSuccess(["1", "modify", "--description", description]) + self.t.runSuccess("1 modify positional description", input="piped description") - self.assertEqual(self.t.export_one("1")["description"], description) + self.assertEqual(self.t.export_one("1")["description"], "positional description") if __name__ == "__main__": From 275581b3c5d07db10160bbbf2048b56952e74850 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:28:58 +0800 Subject: [PATCH 3/5] fix(task): preserve modify confirmation input --- src/commands/DescriptionInput.cpp | 7 +++++++ test/modify.test.py | 22 +++++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp index 714138b04..df7727478 100644 --- a/src/commands/DescriptionInput.cpp +++ b/src/commands/DescriptionInput.cpp @@ -76,6 +76,9 @@ bool isDescriptionPair(const A2& arg) { return arg.attribute("canonical") == "description" || arg.attribute("name") == "description"; } +//////////////////////////////////////////////////////////////////////////////// +bool isModification(const A2& arg) { return arg.hasTag("MODIFICATION"); } + } // namespace //////////////////////////////////////////////////////////////////////////////// @@ -86,6 +89,10 @@ void applyPipedDescriptionInput() { for (const auto& arg : args) if (isDescriptionWord(arg) || isDescriptionPair(arg)) return; + if (Context::getContext().cli2.getCommand() == "modify") + for (const auto& arg : args) + if (isModification(arg)) return; + auto description = readStdin(); if (description == "") return; diff --git a/test/modify.test.py b/test/modify.test.py index 40e62ace7..e189d047c 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -80,14 +80,26 @@ def test_modify_description_from_pipe(self): self.assertEqual(self.t.export_one("1")["description"], description) - def test_modify_piped_description_with_modification(self): - "Testing modify command with piped description and other modifications" + def test_modify_with_modification_ignores_piped_stdin(self): + "Testing modify command keeps stdin available when modifications are present" - description = '"File line one"\nFile line two with `ticks`' - self.t.runSuccess("1 modify priority:H", input=description) + self.t.runSuccess("1 modify priority:H", input="not a description") - self.assertEqual(self.t.export_one("1")["description"], 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_positional_description_ignores_piped_stdin(self): "Testing modify command keeps positional description when stdin is piped" From 2dc80033d8b035c63c2c311919ffd93744e20359 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:39:23 +0800 Subject: [PATCH 4/5] fix(task): trim piped description endings --- src/commands/DescriptionInput.cpp | 6 +++++- test/add.test.py | 4 ++-- test/modify.test.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp index df7727478..0f7975b57 100644 --- a/src/commands/DescriptionInput.cpp +++ b/src/commands/DescriptionInput.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -44,7 +45,10 @@ std::string readStdin() { buffer << std::cin.rdbuf(); if (std::cin.bad()) throw std::string("Failed to read description from stdin."); - return buffer.str(); + auto description = buffer.str(); + while (!description.empty() && std::isspace(static_cast(description.back()))) + description.pop_back(); + return description; } //////////////////////////////////////////////////////////////////////////////// diff --git a/test/add.test.py b/test/add.test.py index 150c69141..782c482a0 100755 --- a/test/add.test.py +++ b/test/add.test.py @@ -126,7 +126,7 @@ 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) + self.t.runSuccess("add", input=description + "\n") self.assertEqual(self.t.latest["description"], description) @@ -134,7 +134,7 @@ 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) + self.t.runSuccess("add priority:H", input=description + "\n") self.assertEqual(self.t.latest["description"], description) self.assertEqual(self.t.latest["priority"], "H") diff --git a/test/modify.test.py b/test/modify.test.py index e189d047c..d52437f44 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -76,7 +76,7 @@ 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) + self.t.runSuccess("1 modify", input=description + "\n") self.assertEqual(self.t.export_one("1")["description"], description) From 7cbb3a995cdcf59090a5aef51d3a8f25d72bba5d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 17:23:52 +0800 Subject: [PATCH 5/5] fix(task): auto-confirm piped bulk modify --- src/commands/CmdModify.cpp | 3 ++- src/commands/DescriptionInput.cpp | 11 ++++++----- src/commands/DescriptionInput.h | 2 +- test/modify.test.py | 12 ++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/commands/CmdModify.cpp b/src/commands/CmdModify.cpp index a51bd2a86..8560c687e 100644 --- a/src/commands/CmdModify.cpp +++ b/src/commands/CmdModify.cpp @@ -59,7 +59,8 @@ CmdModify::CmdModify() { //////////////////////////////////////////////////////////////////////////////// int CmdModify::execute(std::string&) { auto rc = 0; - applyPipedDescriptionInput(); + bool pipedDescription = applyPipedDescriptionInput(); + if (pipedDescription) _permission_all = true; // Apply filter. Filter filter; diff --git a/src/commands/DescriptionInput.cpp b/src/commands/DescriptionInput.cpp index 0f7975b57..15809297c 100644 --- a/src/commands/DescriptionInput.cpp +++ b/src/commands/DescriptionInput.cpp @@ -86,19 +86,19 @@ bool isModification(const A2& arg) { return arg.hasTag("MODIFICATION"); } } // namespace //////////////////////////////////////////////////////////////////////////////// -void applyPipedDescriptionInput() { +bool applyPipedDescriptionInput() { auto& args = Context::getContext().cli2._args; - if (isatty(STDIN_FILENO)) return; + if (isatty(STDIN_FILENO)) return false; for (const auto& arg : args) - if (isDescriptionWord(arg) || isDescriptionPair(arg)) return; + if (isDescriptionWord(arg) || isDescriptionPair(arg)) return false; if (Context::getContext().cli2.getCommand() == "modify") for (const auto& arg : args) - if (isModification(arg)) return; + if (isModification(arg)) return false; auto description = readStdin(); - if (description == "") return; + if (description == "") return false; auto descriptionArg = descriptionModification(description); std::vector reconstructed; @@ -114,6 +114,7 @@ void applyPipedDescriptionInput() { if (!inserted) reconstructed.push_back(descriptionArg); args = reconstructed; + return true; } //////////////////////////////////////////////////////////////////////////////// diff --git a/src/commands/DescriptionInput.h b/src/commands/DescriptionInput.h index b29c56073..1c06c0605 100644 --- a/src/commands/DescriptionInput.h +++ b/src/commands/DescriptionInput.h @@ -27,7 +27,7 @@ #ifndef INCLUDED_DESCRIPTIONINPUT #define INCLUDED_DESCRIPTIONINPUT -void applyPipedDescriptionInput(); +bool applyPipedDescriptionInput(); #endif //////////////////////////////////////////////////////////////////////////////// diff --git a/test/modify.test.py b/test/modify.test.py index d52437f44..174d9f6d3 100755 --- a/test/modify.test.py +++ b/test/modify.test.py @@ -101,6 +101,18 @@ def test_modify_with_bulk_confirmation_keeps_stdin_for_prompt(self): 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"