Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/man/task.1.in
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions src/Task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/commands/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/commands/CmdAdd.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

#include <CmdAdd.h>
#include <Context.h>
#include <DescriptionInput.h>
#include <feedback.h>
#include <format.h>
#include <taskchampion-cpp/lib.h>
Expand All @@ -49,6 +50,8 @@ CmdAdd::CmdAdd() {

////////////////////////////////////////////////////////////////////////////////
int CmdAdd::execute(std::string& output) {
applyPipedDescriptionInput();

// Apply the command line modifications to the new task.
Task task;

Expand Down
4 changes: 4 additions & 0 deletions src/commands/CmdHelp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions src/commands/CmdModify.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

#include <CmdModify.h>
#include <Context.h>
#include <DescriptionInput.h>
#include <Filter.h>
#include <feedback.h>
#include <format.h>
Expand Down Expand Up @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions src/commands/DescriptionInput.cpp
Original file line number Diff line number Diff line change
@@ -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>
// cmake.h include header must come first

#include <DescriptionInput.h>
#include <Context.h>
#include <format.h>
#include <unistd.h>

#include <cctype>
#include <iostream>
#include <sstream>
#include <vector>

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<unsigned char>(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<A2> 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;
}

////////////////////////////////////////////////////////////////////////////////
33 changes: 33 additions & 0 deletions src/commands/DescriptionInput.h
Original file line number Diff line number Diff line change
@@ -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
////////////////////////////////////////////////////////////////////////////////
24 changes: 24 additions & 0 deletions test/add.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions test/modify.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down