diff --git a/ChangeLog b/ChangeLog index c43c91078..6454e8626 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,6 +10,11 @@ + Added feature #341 that makes explicit references to the task and taskrc man pages, both in the auto-generated .taskrc file and the version command output (thanks to Cory Donnelly). + + Added new attribute modifiers 'word' and 'noword' which find the existence + of whole words, or prove the non-existence of whole words. If a task has + the description "Pay the bill", then "description.word:the" will match, but + "description.word:th" will not. For partial word matches, there is still + "description.contains:th". + Fixed bug that showed a calendar for the year 2037 when 'task calendar due' was run, and there are no tasks with due dates. + Fixed bug #316 which caused the timesheet report to display an oddly sorted diff --git a/doc/man/task.1 b/doc/man/task.1 index b109a0c0b..7e64c4aab 100644 --- a/doc/man/task.1 +++ b/doc/man/task.1 @@ -266,6 +266,10 @@ Attribute modifiers improve filters. Supported modifiers are: .B startswith (synonym left) .br .B endswith (synonym right) +.br +.B word +.br +.B noword .RE For example: diff --git a/src/Att.cpp b/src/Att.cpp index bfacfcd3a..9f55b1e19 100644 --- a/src/Att.cpp +++ b/src/Att.cpp @@ -77,6 +77,8 @@ static const char* modifierNames[] = "hasnt", "startswith", "left", "endswith", "right", + "word", + "noword" }; #define NUM_INTERNAL_NAMES (sizeof (internalNames) / sizeof (internalNames[0])) @@ -375,7 +377,7 @@ bool Att::validNameValue ( } //////////////////////////////////////////////////////////////////////////////// -// TODO Obsolete +// TODO Deprecated - remove. bool Att::validMod (const std::string& mod) { for (unsigned int i = 0; i < NUM_MODIFIER_NAMES; ++i) @@ -412,7 +414,8 @@ std::string Att::type (const std::string& name) const std::string Att::modType (const std::string& name) const { if (name == "hasnt" || - name == "isnt") + name == "isnt" || + name == "noword") return "negative"; return "positive"; @@ -615,6 +618,35 @@ bool Att::match (const Att& other) const } } + // word = contains as a substring, with word boundaries. + else if (mMod == "word") // TODO i18n + { + // Fail if the substring is not found. + std::string::size_type sub = other.mValue.find (mValue); + if (sub == std::string::npos) + return false; + + // Also fail if there is no word boundary at beginning and end. + if (!isWordStart (other.mValue, sub)) + return false; + + if (!isWordEnd (other.mValue, sub + mValue.length () - 1)) + return false; + } + + // noword = does not contain as a substring, with word boundaries. + else if (mMod == "noword") // TODO i18n + { + // Fail if the substring is not found. + std::string::size_type sub = other.mValue.find (mValue); + if (sub != std::string::npos && + isWordStart (other.mValue, sub) && + isWordEnd (other.mValue, sub + mValue.length () - 1)) + { + return false; + } + } + return true; } diff --git a/src/Context.cpp b/src/Context.cpp index 911d98203..7f9c2cbcb 100644 --- a/src/Context.cpp +++ b/src/Context.cpp @@ -690,6 +690,9 @@ void Context::parse ( } //////////////////////////////////////////////////////////////////////////////// +// Note: The reason some of these are commented out is because the ::clear +// method is not really "clear" but "clear_some". Some members do not need to +// be initialized. That makes this method something of a misnomer. So be it. void Context::clear () { // Config config; @@ -759,6 +762,7 @@ void Context::autoFilter (Task& t, Filter& f) } // The mechanism for filtering on tags is +/-. + // Do not handle here - see below. else if (att->second.name () == "tags") { } @@ -776,17 +780,22 @@ void Context::autoFilter (Task& t, Filter& f) } } + // This is now a correct implementation of a filter on the presence or absence + // of a tag. The prior code provided the illusion of leftmost partial tag + // matches, but was really using the 'contains' and 'nocontains' attribute + // modifiers. See bug #293. + // Include tagAdditions. foreach (tag, tagAdditions) { - f.push_back (Att ("tags", "has", *tag)); + f.push_back (Att ("tags", "word", *tag)); debug ("auto filter: +" + *tag); } // Include tagRemovals. foreach (tag, tagRemovals) { - f.push_back (Att ("tags", "hasnt", *tag)); + f.push_back (Att ("tags", "noword", *tag)); debug ("auto filter: -" + *tag); } } diff --git a/src/Filter.cpp b/src/Filter.cpp index c955a815a..23f034294 100644 --- a/src/Filter.cpp +++ b/src/Filter.cpp @@ -125,7 +125,7 @@ void Filter::applySequence (std::vector& all, Sequence& sequence) std::vector right; listDiff (filteredSequence, (std::vector &)sequence, left, right); if (left.size ()) - throw std::string ("Sequence filtering error - please report this error"); + throw std::string ("Sequence filtering error - please report this error to support@taskwarrior.org"); if (right.size ()) { diff --git a/src/report.cpp b/src/report.cpp index 3fe91d6bd..25bd79e37 100644 --- a/src/report.cpp +++ b/src/report.cpp @@ -260,6 +260,8 @@ int longUsage (std::string &outs) << " hasnt" << "\n" << " startswith (synonym left)" << "\n" << " endswith (synonym right)" << "\n" + << " word" << "\n" + << " noword" << "\n" << "\n" << " For example:" << "\n" << " task list due.before:eom priority.not:L" << "\n" diff --git a/src/tests/att.t.cpp b/src/tests/att.t.cpp index 11f17ef78..f60b7a093 100644 --- a/src/tests/att.t.cpp +++ b/src/tests/att.t.cpp @@ -33,7 +33,7 @@ Context context; //////////////////////////////////////////////////////////////////////////////// int main (int argc, char** argv) { - UnitTest t (95); + UnitTest t (97); Att a; t.notok (a.valid ("name"), "Att::valid name -> fail"); @@ -146,6 +146,14 @@ int main (int argc, char** argv) try {a6.mod ("endswith");} catch (...) {good = false;} t.ok (good, "Att::mod (endswith)"); + good = true; + try {a6.mod ("word");} catch (...) {good = false;} + t.ok (good, "Att::mod (word)"); + + good = true; + try {a6.mod ("noword");} catch (...) {good = false;} + t.ok (good, "Att::mod (noword)"); + good = true; try {a6.mod ("fartwizzle");} catch (...) {good = false;} t.notok (good, "Att::mod (fartwizzle)"); diff --git a/src/tests/filt.t.cpp b/src/tests/filt.t.cpp index 7e4044772..31521f788 100644 --- a/src/tests/filt.t.cpp +++ b/src/tests/filt.t.cpp @@ -34,7 +34,7 @@ Context context; //////////////////////////////////////////////////////////////////////////////// int main (int argc, char** argv) { - UnitTest test (14); + UnitTest test (20); // Create a filter consisting of two Att criteria. Filter f; @@ -66,6 +66,7 @@ int main (int argc, char** argv) // Modifiers. Task mods; mods.set ("name", "value"); + mods.set ("description", "hello, world."); Att a ("name", "is", "value"); f.clear (); @@ -124,6 +125,42 @@ int main (int argc, char** argv) "below" */ + a = Att ("description", "word", "hello"); + f.clear (); + f.push_back (a); + test.ok (f.pass (mods), "description:hello, world. -> description.word:hello = match"); + // TODO test inverse. + + a = Att ("description", "word", "world"); + f.clear (); + f.push_back (a); + test.ok (f.pass (mods), "description:hello, world. -> description.word:world = match"); + // TODO test inverse. + + a = Att ("description", "word", "pig"); + f.clear (); + f.push_back (a); + test.notok (f.pass (mods), "description:hello, world. -> description.word:pig = no match"); + // TODO test inverse. + + a = Att ("description", "noword", "hello"); + f.clear (); + f.push_back (a); + test.notok (f.pass (mods), "description:hello, world. -> description.noword:hello = no match"); + // TODO test inverse. + + a = Att ("description", "noword", "world"); + f.clear (); + f.push_back (a); + test.notok (f.pass (mods), "description:hello, world. -> description.noword:world = no match"); + // TODO test inverse. + + a = Att ("description", "noword", "pig"); + f.clear (); + f.push_back (a); + test.ok (f.pass (mods), "description:hello, world. -> description.noword:pig = match"); + // TODO test inverse. + return 0; } diff --git a/src/tests/text.t.cpp b/src/tests/text.t.cpp index b0437c1d6..080c79d93 100644 --- a/src/tests/text.t.cpp +++ b/src/tests/text.t.cpp @@ -34,7 +34,7 @@ Context context; //////////////////////////////////////////////////////////////////////////////// int main (int argc, char** argv) { - UnitTest t (117); + UnitTest t (147); // void wrapText (std::vector & lines, const std::string& text, const int width) std::string text = "This is a test of the line wrapping code."; @@ -267,6 +267,44 @@ int main (int argc, char** argv) t.notok (noVerticalSpace ("a\rb"), "noVerticalSpace 'a\\rb' -> false"); t.notok (noVerticalSpace ("a\fb"), "noVerticalSpace 'a\\fb' -> false"); + text = "Hello, world."; + // 0123456789012 + // s e s e + + // bool isWordStart (const std::string&, std::string::size_type); + t.notok (isWordStart ("", 0), "isWordStart (\"\", 0) -> false"); + t.ok (isWordStart ("foo", 0), "isWordStart (\"foo\", 0) -> true"); + t.ok (isWordStart (text, 0), "isWordStart (\"Hello, world.\", 0) -> true"); + t.notok (isWordStart (text, 1), "isWordStart (\"Hello, world.\", 1) -> false"); + t.notok (isWordStart (text, 2), "isWordStart (\"Hello, world.\", 2) -> false"); + t.notok (isWordStart (text, 3), "isWordStart (\"Hello, world.\", 3) -> false"); + t.notok (isWordStart (text, 4), "isWordStart (\"Hello, world.\", 4) -> false"); + t.notok (isWordStart (text, 5), "isWordStart (\"Hello, world.\", 5) -> false"); + t.notok (isWordStart (text, 6), "isWordStart (\"Hello, world.\", 6) -> false"); + t.ok (isWordStart (text, 7), "isWordStart (\"Hello, world.\", 7) -> true"); + t.notok (isWordStart (text, 8), "isWordStart (\"Hello, world.\", 8) -> false"); + t.notok (isWordStart (text, 9), "isWordStart (\"Hello, world.\", 9) -> false"); + t.notok (isWordStart (text, 10), "isWordStart (\"Hello, world.\", 10) -> false"); + t.notok (isWordStart (text, 11), "isWordStart (\"Hello, world.\", 11) -> false"); + t.notok (isWordStart (text, 12), "isWordStart (\"Hello, world.\", 12) -> false"); + + // bool isWordEnd (const std::string&, std::string::size_type); + t.notok (isWordEnd ("", 0), "isWordEnd (\"\", 0) -> false"); + t.ok (isWordEnd ("foo", 2), "isWordEnd (\"foo\", 2) -> true"); + t.notok (isWordEnd (text, 0), "isWordEnd (\"Hello, world.\", 0) -> false"); + t.notok (isWordEnd (text, 1), "isWordEnd (\"Hello, world.\", 1) -> false"); + t.notok (isWordEnd (text, 2), "isWordEnd (\"Hello, world.\", 2) -> false"); + t.notok (isWordEnd (text, 3), "isWordEnd (\"Hello, world.\", 3) -> false"); + t.ok (isWordEnd (text, 4), "isWordEnd (\"Hello, world.\", 4) -> true"); + t.notok (isWordEnd (text, 5), "isWordEnd (\"Hello, world.\", 5) -> false"); + t.notok (isWordEnd (text, 6), "isWordEnd (\"Hello, world.\", 6) -> false"); + t.notok (isWordEnd (text, 7), "isWordEnd (\"Hello, world.\", 7) -> false"); + t.notok (isWordEnd (text, 8), "isWordEnd (\"Hello, world.\", 8) -> false"); + t.notok (isWordEnd (text, 9), "isWordEnd (\"Hello, world.\", 9) -> false"); + t.notok (isWordEnd (text, 10), "isWordEnd (\"Hello, world.\", 10) -> false"); + t.ok (isWordEnd (text, 11), "isWordEnd (\"Hello, world.\", 11) -> true"); + t.notok (isWordEnd (text, 12), "isWordEnd (\"Hello, world.\", 12) -> false"); + return 0; } diff --git a/src/tests/utf8.t b/src/tests/utf8.t index 1fbfd91e5..5660a22a6 100755 --- a/src/tests/utf8.t +++ b/src/tests/utf8.t @@ -65,8 +65,8 @@ qx{../task rc:utf8.rc add project:Çirçös utf8 in project}; $output = qx{../task rc:utf8.rc ls project:Çirçös}; like ($output, qr/Çirçös.+utf8 in project/, 'utf8 in project works'); -qx{../task rc:utf8.rc add utf8 in tag +☺}; -$output = qx{../task rc:utf8.rc ls +☺}; +qx{../task rc:utf8.rc add utf8 in tag +Zwölf}; +$output = qx{../task rc:utf8.rc ls +Zwölf}; like ($output, qr/utf8 in tag/, 'utf8 in tag works'); # Cleanup. diff --git a/src/text.cpp b/src/text.cpp index 727bfd811..faebda956 100644 --- a/src/text.cpp +++ b/src/text.cpp @@ -420,3 +420,45 @@ bool noVerticalSpace (const std::string& input) } //////////////////////////////////////////////////////////////////////////////// +// Input: hello, world +// Result for pos: y......y.... +bool isWordStart (const std::string& input, std::string::size_type pos) +{ + // Short circuit: no input means no word start. + if (input.length () == 0) + return false; + + // If pos is the first alphanumeric character of the string. + if (pos == 0 && isalnum (input[pos])) + return true; + + // If pos is not the first alphanumeric character, but there is a preceding + // non-alphanumeric character. + if (pos > 0 && isalnum (input[pos]) && !isalnum (input[pos - 1])) + return true; + + return false; +} + +//////////////////////////////////////////////////////////////////////////////// +// Input: hello, world +// Result for pos: ....y......y +bool isWordEnd (const std::string& input, std::string::size_type pos) +{ + // Short circuit: no input means no word start. + if (input.length () == 0) + return false; + + // If pos is the last alphanumeric character of the string. + if (pos == input.length () - 1 && isalnum (input[pos])) + return true; + + // If pos is not the last alphanumeric character, but there is a following + // non-alphanumeric character. + if (pos < input.length () - 1 && isalnum (input[pos]) && !isalnum (input[pos + 1])) + return true; + + return false; +} + +//////////////////////////////////////////////////////////////////////////////// diff --git a/src/text.h b/src/text.h index e23864cd8..678b70df2 100644 --- a/src/text.h +++ b/src/text.h @@ -52,6 +52,8 @@ void guess (const std::string&, std::vector&, std::string&); bool digitsOnly (const std::string&); bool noSpaces (const std::string&); bool noVerticalSpace (const std::string&); +bool isWordStart (const std::string&, std::string::size_type); +bool isWordEnd (const std::string&, std::string::size_type); #endif ////////////////////////////////////////////////////////////////////////////////