From b183b0d6769b8dbb5841ff4b52376102406fcd48 Mon Sep 17 00:00:00 2001 From: GeraldJansen Date: Wed, 23 Dec 2020 18:45:03 +0100 Subject: [PATCH 1/8] Revert to single comma for description barrier This PR reverts the use of a double comma to indicate the start of the description, a breaking change introduced in Hamster 3.0, back to the previous use of a single comma, as discussed in #657. Likewise, the double comma needed before tags, in the case of descriptions containing the #hash pattern, is also reverted to the use of a single comma. This change requires two restrictions to the parsing rules. Firstly, no comma is allowed in the activity name (#270). Secondly, tags may not be separated by a comma when entered on the commandline (ie. just use `#tag1 #tag2`, not `#tag1, #tag2`). --- help/C/input.page | 18 +++++++++--------- src/hamster/lib/fact.py | 4 ++-- src/hamster/lib/parsing.py | 8 ++++---- tests/test_stuff.py | 32 ++++++++++++++++---------------- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/help/C/input.page b/help/C/input.page index b91e862e7..1c74ccb3c 100644 --- a/help/C/input.page +++ b/help/C/input.page @@ -11,18 +11,18 @@ To start tracking, press the + button, type in the activity name in the entry, and hit the Enter key. - To specify more detail on the fly, use this syntax: - `time_info activity name @category,, some description #tag #other tag with spaces`. + To specify more detail on the fly, use this syntax: + `time_info activity name@category, some description #tag #other tag with spaces`.

Specify specific times as `13:10-13:45`, and "started 5 minutes ago" as `-5`.

Next comes the activity name

Place the category after the activity name, and start it with an at sign `@`, e.g. `@garden`

-

If you want to add a description, add a double comma `,,`.

-

The description is just freeform text immediately after the double comma, and runs until the end of the string or until the beginning of tags.

+

If you want to add a description, add a comma `,`.

+

The description is just freeform text immediately after the comma, and runs until the end of the string or until the beginning of tags.

Place tags at the end, and start each tag with a hash mark `#`.

-

A double comma `,,` can also be placed to indicate the beginning of tags. Otherwise any `#` in the activity, category or description would be interpreted as a starting a tag.

+

A comma `,` can also be placed to indicate the beginning of tags. Otherwise any `#` in the activity, category or description would be interpreted as a starting a tag.

@@ -33,14 +33,14 @@

Forgot to note the important act of watering flowers over lunch.

- tomatoes@garden,, digging holes + tomatoes@garden, digging holes

Need more tomatoes in the garden. Digging holes is purely informational, so added it as a description.

- -7 existentialism,, thinking about the vastness of the universe + -7 existentialism, thinking about the vastness of the universe

Corrected information by informing application that I've been doing something else for the last seven minutes. @@ -52,14 +52,14 @@

Relative times work both for start and end, - provided they are preceded by an explicit sign, + provided they are preceded by an explicit sign, and separated by a space.

-30 -10 means started 30 minutes ago and stopped 10 minutes ago.

-5 +30 means started 5 minutes ago and will stop in 30 minutes (duration of 35 minutes).

-

Duration can be given instead of end, +

Duration can be given instead of end, as 1, 2 or 3 digits without any sign.

-50 30 means started 50 minutes ago and lasted 30 minutes (so it ended 20 minutes ago).

diff --git a/src/hamster/lib/fact.py b/src/hamster/lib/fact.py index d4d67fe1c..c194745f4 100644 --- a/src/hamster/lib/fact.py +++ b/src/hamster/lib/fact.py @@ -186,7 +186,7 @@ def serialized_name(self): res += "@%s" % self.category if self.description: - res += ',, ' + res += ', ' res += self.description if ('#' in self.activity @@ -194,7 +194,7 @@ def serialized_name(self): or '#' in self.description ): # need a tag barrier - res += ",, " + res += ", " if self.tags: # double comma is a left barrier for tags, diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py index 527c574c9..b996f6179 100644 --- a/src/hamster/lib/parsing.py +++ b/src/hamster/lib/parsing.py @@ -37,7 +37,7 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): Returns found fields as a dict. Tentative syntax (not accurate): - start [- end_time] activity[@category][,, description][,,]{ #tag} + start [- end_time] activity[@category][, description][,]{ #tag} According to the legacy tests, # were allowed in the description """ @@ -64,7 +64,7 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): # especially the tags barrier m = re.search(tags_separator, remaining_text) remaining_text = remaining_text[:m.start()] - if m.group(1) == ",,": + if m.group(1) == ",": # tags barrier found break @@ -83,8 +83,8 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): res["tags"] = list(reversed(tags)) # description - # first look for double comma (description hard left boundary) - head, sep, description = remaining_text.partition(",,") + # first look for comma (description hard left boundary) + head, sep, description = remaining_text.partition(",") res["description"] = description.strip() remaining_text = head.strip() diff --git a/tests/test_stuff.py b/tests/test_stuff.py index 218c07b71..87a1e09d0 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -85,7 +85,7 @@ def test_category(self): def test_description(self): # plain activity name - activity = Fact.parse("case,, with added descriptiön") + activity = Fact.parse("case, with added descriptiön") self.assertEqual(activity.activity, "case") self.assertEqual(activity.description, "with added descriptiön") assert not activity.category @@ -95,7 +95,7 @@ def test_description(self): def test_tags(self): # plain activity name - activity = Fact.parse("#case,, description with #hash,, #and, #some #tägs") + activity = Fact.parse("#case, description with #hash, #and #some #tägs") self.assertEqual(activity.activity, "#case") self.assertEqual(activity.description, "description with #hash") self.assertEqual(set(activity.tags), set(["and", "some", "tägs"])) @@ -105,16 +105,17 @@ def test_tags(self): def test_full(self): # plain activity name - activity = Fact.parse("1225-1325 case@cat,, description #ta non-tag,, #tag #bäg") + activity = Fact.parse( + "1225-1325 case@cat, description #hash non-tag, #tag #bäg") self.assertEqual(activity.start_time.strftime("%H:%M"), "12:25") self.assertEqual(activity.end_time.strftime("%H:%M"), "13:25") self.assertEqual(activity.activity, "case") self.assertEqual(activity.category, "cat") - self.assertEqual(activity.description, "description #ta non-tag") + self.assertEqual(activity.description, "description #hash non-tag") self.assertEqual(set(activity.tags), set(["bäg", "tag"])) def test_copy(self): - fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") + fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1.start_time, fact2.start_time) self.assertEqual(fact1.end_time, fact2.end_time) @@ -132,7 +133,7 @@ def test_copy(self): self.assertEqual(fact3.tags, ["changed"]) def test_comparison(self): - fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") + fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1, fact2) fact2 = fact1.copy() @@ -161,12 +162,12 @@ def test_comparison(self): def test_decimal_in_activity(self): # cf. issue #270 - fact = Fact.parse("12:25-13:25 10.0@ABC,, Two Words #tag #bäg") + fact = Fact.parse("12:25-13:25 10.0@ABC, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.0") self.assertEqual(fact.category, "ABC") self.assertEqual(fact.description, "Two Words") # should not pick up a time here - fact = Fact.parse("10.00@ABC,, Two Words #tag #bäg") + fact = Fact.parse("10.00@ABC, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.00") self.assertEqual(fact.category, "ABC") self.assertEqual(fact.description, "Two Words") @@ -186,18 +187,18 @@ def test_spaces(self): self.assertEqual(fact3.serialized(), "") def test_commas(self): - fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma") - self.assertEqual(fact.activity, "activity, with comma") + fact = Fact.parse("11:00 12:00 activity@category, description, with comma") + self.assertEqual(fact.activity, "activity") self.assertEqual(fact.category, "category") self.assertEqual(fact.description, "description, with comma") self.assertEqual(fact.tags, []) - fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma, #tag1, #tag2") - self.assertEqual(fact.activity, "activity, with comma") + fact = Fact.parse("11:00 12:00 activity@category, description, with comma, #tag1 #tag2") + self.assertEqual(fact.activity, "activity") self.assertEqual(fact.category, "category") self.assertEqual(fact.description, "description, with comma") self.assertEqual(fact.tags, ["tag1", "tag2"]) - fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma and #hash,, #tag1, #tag2") - self.assertEqual(fact.activity, "activity, with comma") + fact = Fact.parse("11:00 12:00 activity@category, description, with comma and #hash, #tag1 #tag2") + self.assertEqual(fact.activity, "activity") self.assertEqual(fact.category, "category") self.assertEqual(fact.description, "description, with comma and #hash") self.assertEqual(fact.tags, ["tag1", "tag2"]) @@ -215,7 +216,6 @@ def test_roundtrips(self): for activity in ( "activity", "#123 with two #hash", - "activity, with comma", "17.00 tea", ): for category in ( @@ -453,7 +453,7 @@ def test_timedelta(self): class TestDBus(unittest.TestCase): def test_round_trip(self): - fact = Fact.parse("11:00 12:00 activity, with comma@category,, description, with comma #and #tags") + fact = Fact.parse("11:00 12:00 activity@category, description, with comma #and #tags") dbus_fact = to_dbus_fact_json(fact) return_fact = from_dbus_fact_json(dbus_fact) self.assertEqual(return_fact, fact) From 566020087ed6c9a39090c21b9353e3c649958ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Sat, 26 Dec 2020 17:08:30 +0100 Subject: [PATCH 2/8] Simplify parsing of facts by using a clear tag separator Now we use a comma, followed by one one or more optional spaces, followed by a hash as a clear sign that the tags list has been started. Obviously there are cases where we want to use tags within our description as they are part of it. We will restore this in the next commit. --- src/hamster/lib/fact.py | 11 +--------- src/hamster/lib/parsing.py | 42 +++++++++----------------------------- tests/test_stuff.py | 40 +++++++++++++++++++++++------------- 3 files changed, 37 insertions(+), 56 deletions(-) diff --git a/src/hamster/lib/fact.py b/src/hamster/lib/fact.py index c194745f4..515dc7506 100644 --- a/src/hamster/lib/fact.py +++ b/src/hamster/lib/fact.py @@ -189,17 +189,8 @@ def serialized_name(self): res += ', ' res += self.description - if ('#' in self.activity - or '#' in self.category - or '#' in self.description - ): - # need a tag barrier - res += ", " - if self.tags: - # double comma is a left barrier for tags, - # which is useful only if previous fields contain a hash - res += " %s" % " ".join("#%s" % tag for tag in self.tags) + res += ", %s" % " ".join("#%s" % tag for tag in self.tags) return res def serialized(self, range_pos="head", default_day=None): diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py index b996f6179..0e814221a 100644 --- a/src/hamster/lib/parsing.py +++ b/src/hamster/lib/parsing.py @@ -17,17 +17,12 @@ (?P [^#,]+ # (anything but hash or comma) ) - \s* # maybe spaces - # forbid double comma (tag can not be before the tags barrier): - ,? # single comma (or none) - \s* # maybe space - $ # end of text """, flags=re.VERBOSE) tags_separator = re.compile(r""" - (,{0,2}) # 0, 1 or 2 commas + ,{1,2} # 1 or 2 commas \s* # maybe spaces - $ # end of text + (?=\#) # hash character (start of first tag, doesn't consume it) """, flags=re.VERBOSE) @@ -56,31 +51,14 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): res["end_time"] = end # tags - # Need to start from the end, because - # the description can hold some '#' characters - tags = [] - while True: - # look for tags separators - # especially the tags barrier - m = re.search(tags_separator, remaining_text) - remaining_text = remaining_text[:m.start()] - if m.group(1) == ",": - # tags barrier found - break - - # look for tag - m = re.search(tag_re, remaining_text) - if m: - tag = m.group('tag').strip() - # strip the matched string (including #) - remaining_text = remaining_text[:m.start()] - tags.append(tag) - else: - # no tag - break - - # put tags back in input order - res["tags"] = list(reversed(tags)) + split = re.split(tags_separator, remaining_text, 1) + remaining_text = split[0] + tags_part = split[1] if len(split) > 1 else None + if tags_part: + tags = list(map(lambda x: x.strip(), re.findall(tag_re, tags_part))) + else: + tags = [] + res["tags"] = tags # description # first look for comma (description hard left boundary) diff --git a/tests/test_stuff.py b/tests/test_stuff.py index 87a1e09d0..27bdb1c42 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -103,6 +103,17 @@ def test_tags(self): assert activity.start_time is None assert activity.end_time is None + def test_multiple_tags_separated_with_commas(self): + activity = Fact.parse("devel, fun times, #bugs, #pr, #hamster") + self.assertEqual(set(activity.tags), + set(["bugs", "pr", "hamster"])) + + def test_tags_without_description(self): + activity = Fact.parse("case, #tag1 #tag2") + self.assertEqual(activity.activity, "case") + self.assertEqual(activity.description, "") + self.assertEqual(set(activity.tags), set(["tag1", "tag2"])) + def test_full(self): # plain activity name activity = Fact.parse( @@ -133,7 +144,7 @@ def test_copy(self): self.assertEqual(fact3.tags, ["changed"]) def test_comparison(self): - fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") + fact1 = Fact.parse("12:25-13:25 case@cat, description, #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1, fact2) fact2 = fact1.copy() @@ -165,26 +176,27 @@ def test_decimal_in_activity(self): fact = Fact.parse("12:25-13:25 10.0@ABC, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.0") self.assertEqual(fact.category, "ABC") - self.assertEqual(fact.description, "Two Words") # should not pick up a time here fact = Fact.parse("10.00@ABC, Two Words #tag #bäg") self.assertEqual(fact.activity, "10.00") self.assertEqual(fact.category, "ABC") - self.assertEqual(fact.description, "Two Words") - def test_spaces(self): - # cf. issue #114 - fact = Fact.parse("11:00 12:00 BPC-261 - Task title@Project#code") + def test_activity_with_spaces(self): + fact = Fact.parse("11:00 12:00 BPC-261 - Task title@Project") self.assertEqual(fact.activity, "BPC-261 - Task title") self.assertEqual(fact.category, "Project") self.assertEqual(fact.description, "") - self.assertEqual(fact.tags, ["code"]) - # space between category and tag - fact2 = Fact.parse("11:00 12:00 BPC-261 - Task title@Project #code") - self.assertEqual(fact.serialized(), fact2.serialized()) - # empty fact - fact3 = Fact() - self.assertEqual(fact3.serialized(), "") + self.assertEqual(fact.tags, []) + + def test_activity_and_category_with_hash_and_space(self): + fact = Fact.parse("11:00 12:00 Activity #1@Category #2") + self.assertEqual(fact.activity, "Activity #1") + self.assertEqual(fact.category, "Category #2") + self.assertEqual(fact.description, "") + + def test_serialization_of_an_empty_fact(self): + fact = Fact() + self.assertEqual(fact.serialized(), "") def test_commas(self): fact = Fact.parse("11:00 12:00 activity@category, description, with comma") @@ -255,13 +267,13 @@ def test_roundtrips(self): for range_pos in ("head", "tail"): fact_str = fact.serialized(range_pos=range_pos) parsed = Fact.parse(fact_str, range_pos=range_pos) - self.assertEqual(fact, parsed) self.assertEqual(parsed.range.start, fact.range.start) self.assertEqual(parsed.range.end, fact.range.end) self.assertEqual(parsed.activity, fact.activity) self.assertEqual(parsed.category, fact.category) self.assertEqual(parsed.description, fact.description) self.assertEqual(parsed.tags, fact.tags) + self.assertEqual(fact, parsed) class TestDatetime(unittest.TestCase): From adf852241ac0b4fd4383090d1a18fe54bb5c0431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Sat, 26 Dec 2020 17:15:17 +0100 Subject: [PATCH 3/8] Allow use of tags in description If the tag list is not introduced by a comma, it's supposed to be part of the description... so we copy the embedded tags to the tag list and we drop the leading hash to keep the plain word in the description. Tags embedded in the description are more restricted in their syntax, they can't contain spaces and must not start with a digit (we want to avoid creating tags for things like bug numbers). So this basically converts a fact like "Coding, fix #bugs in #hamster" into "Coding, fix bugs in hamster, #bugs #hamster". Note that I disabled the round trip test for with description containing a hash because even though they are equivalent, the manually created fact is not exactly the same as the parsed one... the parsed one has extracted the tags from the description but not the manually created one. --- src/hamster/lib/fact.py | 10 ++++++++-- src/hamster/lib/parsing.py | 17 ++++++++++++++++- tests/test_stuff.py | 22 ++++++++++++++++++---- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/hamster/lib/fact.py b/src/hamster/lib/fact.py index 515dc7506..02b64b78f 100644 --- a/src/hamster/lib/fact.py +++ b/src/hamster/lib/fact.py @@ -14,7 +14,7 @@ from copy import deepcopy from hamster.lib import datetime as dt -from hamster.lib.parsing import parse_fact +from hamster.lib.parsing import parse_fact, get_tags_from_description class FactError(Exception): @@ -190,7 +190,13 @@ def serialized_name(self): res += self.description if self.tags: - res += ", %s" % " ".join("#%s" % tag for tag in self.tags) + # Don't duplicate tags that are already in the description + seen_tags = get_tags_from_description(self.description) + remaining_tags = [ + tag for tag in self.tags if tag not in seen_tags + ] + if remaining_tags: + res += ", %s" % " ".join("#%s" % tag for tag in remaining_tags) return res def serialized(self, range_pos="head", default_day=None): diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py index 0e814221a..a718bb9c8 100644 --- a/src/hamster/lib/parsing.py +++ b/src/hamster/lib/parsing.py @@ -19,6 +19,14 @@ ) """, flags=re.VERBOSE) +tags_in_description = re.compile(r""" + \# + (?P + [a-zA-Z] # Starts with an alphabetic character (digits excluded) + [^\s]+ # followed by anything except spaces + ) +""", flags=re.VERBOSE) + tags_separator = re.compile(r""" ,{1,2} # 1 or 2 commas \s* # maybe spaces @@ -26,6 +34,10 @@ """, flags=re.VERBOSE) +def get_tags_from_description(description): + return list(re.findall(tags_in_description, description)) + + def parse_fact(text, range_pos="head", default_day=None, ref="now"): """Extract fact fields from the string. @@ -58,14 +70,17 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): tags = list(map(lambda x: x.strip(), re.findall(tag_re, tags_part))) else: tags = [] - res["tags"] = tags # description # first look for comma (description hard left boundary) head, sep, description = remaining_text.partition(",") + # Extract tags from description, put them before other tags + tags = get_tags_from_description(description) + tags res["description"] = description.strip() remaining_text = head.strip() + res["tags"] = tags + # activity split = remaining_text.rsplit('@', maxsplit=1) activity = split[0] diff --git a/tests/test_stuff.py b/tests/test_stuff.py index 27bdb1c42..f8bf4ad22 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -15,6 +15,7 @@ from_dbus_range, ) from hamster.lib.fact import Fact +from hamster.lib.parsing import get_tags_from_description class TestFact(unittest.TestCase): @@ -98,7 +99,8 @@ def test_tags(self): activity = Fact.parse("#case, description with #hash, #and #some #tägs") self.assertEqual(activity.activity, "#case") self.assertEqual(activity.description, "description with #hash") - self.assertEqual(set(activity.tags), set(["and", "some", "tägs"])) + self.assertEqual(set(activity.tags), + set(["and", "hash", "some", "tägs"])) assert not activity.category assert activity.start_time is None assert activity.end_time is None @@ -108,6 +110,19 @@ def test_multiple_tags_separated_with_commas(self): self.assertEqual(set(activity.tags), set(["bugs", "pr", "hamster"])) + def test_tag_in_description_ignores_tag_starting_with_a_number(self): + activity = Fact.parse("case, fix bug #123, #tag1") + self.assertEqual(activity.description, "fix bug #123") + self.assertEqual(set(activity.tags), set(["tag1"])) + + def test_serialization_does_not_duplicate_tag_from_description(self): + fact = Fact(activity="activity", description="review #pr", + tags=["pr", "hamster"]) + self.assertEqual(fact.serialized(), "activity, review #pr, #hamster") + fact = Fact(activity="activity", description="review #pr in #hamster", + tags=["pr", "hamster"]) + self.assertEqual(fact.serialized(), "activity, review #pr in #hamster") + def test_tags_without_description(self): activity = Fact.parse("case, #tag1 #tag2") self.assertEqual(activity.activity, "case") @@ -123,7 +138,7 @@ def test_full(self): self.assertEqual(activity.activity, "case") self.assertEqual(activity.category, "cat") self.assertEqual(activity.description, "description #hash non-tag") - self.assertEqual(set(activity.tags), set(["bäg", "tag"])) + self.assertEqual(set(activity.tags), set(["hash", "bäg", "tag"])) def test_copy(self): fact1 = Fact.parse("12:25-13:25 case@cat, description #tag #bäg") @@ -213,7 +228,7 @@ def test_commas(self): self.assertEqual(fact.activity, "activity") self.assertEqual(fact.category, "category") self.assertEqual(fact.description, "description, with comma and #hash") - self.assertEqual(fact.tags, ["tag1", "tag2"]) + self.assertEqual(fact.tags, ["hash", "tag1", "tag2"]) # ugly. Really need pytest def test_roundtrips(self): @@ -237,7 +252,6 @@ def test_roundtrips(self): for description in ( "", "description", - "with #hash", "with, comma", "with @at", "multiline\ndescription", From 25fec06b655e48a8ad137dda90b75be4a3ac670e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Sun, 27 Dec 2020 11:42:03 +0100 Subject: [PATCH 4/8] Introduce backwards compatibility with double comma syntax v3 with its double comma has been out for a while and apparently GTG is already using that syntax. So we should deal nicely with submission using that syntax. It doesn't cost much to accept multiple commas instead of a single one. Ensure we keep this working with a unit test. --- src/hamster/lib/parsing.py | 9 ++++++++- tests/test_stuff.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py index a718bb9c8..ae17c319e 100644 --- a/src/hamster/lib/parsing.py +++ b/src/hamster/lib/parsing.py @@ -33,6 +33,11 @@ (?=\#) # hash character (start of first tag, doesn't consume it) """, flags=re.VERBOSE) +description_separator = re.compile(r""" + ,+ # 1 or more commas + \s* # maybe spaces +""", flags=re.VERBOSE) + def get_tags_from_description(description): return list(re.findall(tags_in_description, description)) @@ -73,7 +78,9 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): # description # first look for comma (description hard left boundary) - head, sep, description = remaining_text.partition(",") + split = re.split(description_separator, remaining_text, 1) + head = split[0] + description = split[1] if len(split) > 1 else "" # Extract tags from description, put them before other tags tags = get_tags_from_description(description) + tags res["description"] = description.strip() diff --git a/tests/test_stuff.py b/tests/test_stuff.py index f8bf4ad22..bcadcea79 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -230,6 +230,14 @@ def test_commas(self): self.assertEqual(fact.description, "description, with comma and #hash") self.assertEqual(fact.tags, ["hash", "tag1", "tag2"]) + def test_backwards_compat_double_comma(self): + fact = Fact.parse("act@cat,, My description,, #tag1 #tag2") + self.assertEqual(fact.description, "My description") + self.assertEqual(fact.tags, ["tag1", "tag2"]) + fact = Fact.parse("act@cat,, My description, with comma,, #tag1 #tag2") + self.assertEqual(fact.description, "My description, with comma") + self.assertEqual(fact.tags, ["tag1", "tag2"]) + # ugly. Really need pytest def test_roundtrips(self): for start_time in ( From 755816d5843ce11cb7018ec10e9a1b75bbd48b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Sun, 27 Dec 2020 11:45:41 +0100 Subject: [PATCH 5/8] Fix some coding style issues detected by flake8 --- src/hamster/lib/parsing.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/hamster/lib/parsing.py b/src/hamster/lib/parsing.py index ae17c319e..aacd200d8 100644 --- a/src/hamster/lib/parsing.py +++ b/src/hamster/lib/parsing.py @@ -7,8 +7,7 @@ # separator between times and activity -ACTIVITY_SEPARATOR = "\s+" - +activity_separator = r"\s+" # match #tag followed by any space or # that will be ignored # tag must not contain '#' or ',' @@ -62,8 +61,8 @@ def parse_fact(text, range_pos="head", default_day=None, ref="now"): # datetimes # force at least a space to avoid matching 10.00@cat (start, end), remaining_text = dt.Range.parse(text, position=range_pos, - separator=ACTIVITY_SEPARATOR, - default_day=default_day) + separator=activity_separator, + default_day=default_day) res["start_time"] = start res["end_time"] = end From 282124cd9ea1d13b93f96b57586fa76e2dbfda14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Tue, 16 Mar 2021 08:57:44 +0100 Subject: [PATCH 6/8] Add unit test for tags with spaces I saw this documented so we should make sure it actually works. --- tests/test_stuff.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_stuff.py b/tests/test_stuff.py index bcadcea79..900d7ce03 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -129,6 +129,12 @@ def test_tags_without_description(self): self.assertEqual(activity.description, "") self.assertEqual(set(activity.tags), set(["tag1", "tag2"])) + def test_tags_with_spaces(self): + activity = Fact.parse("case, #tag with space #tag2") + self.assertEqual(activity.activity, "case") + self.assertEqual(activity.description, "") + self.assertEqual(set(activity.tags), set(["tag with space", "tag2"])) + def test_full(self): # plain activity name activity = Fact.parse( From f5849ecb169616e391e7e9f39b8ab32b8e9bc18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Tue, 16 Mar 2021 09:20:17 +0100 Subject: [PATCH 7/8] Update documentation for the latest syntax simplifications --- help/C/input.page | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/help/C/input.page b/help/C/input.page index 1c74ccb3c..32e43d530 100644 --- a/help/C/input.page +++ b/help/C/input.page @@ -12,19 +12,24 @@ type in the activity name in the entry, and hit the Enter key. To specify more detail on the fly, use this syntax: - `time_info activity name@category, some description #tag #other tag with spaces`. + `time_info activity name@category, some description, #tag #other tag with spaces`.

Specify specific times as `13:10-13:45`, and "started 5 minutes ago" as `-5`.

Next comes the activity name

Place the category after the activity name, and start it with an at sign `@`, e.g. `@garden`

-

If you want to add a description, add a comma `,`.

+

If you want to add a description and/or tags, add a comma `,`.

The description is just freeform text immediately after the comma, and runs until the end of the string or until the beginning of tags.

-

Place tags at the end, and start each tag with a hash mark `#`.

-

A comma `,` can also be placed to indicate the beginning of tags. Otherwise any `#` in the activity, category or description would be interpreted as a starting a tag.

+

Place tags at the end right after a comma, and start each tag with a hash mark `#`.

+

Note that you can embed single-word tags in the description just by +prepending a hash to any word. Note that sequences of alphanumeric +characters that start with a digit are not considered as words so if you +use Fix bug #123 as your description, the hash will be kept +and there will be no supplementary tag named 123.

+

A few examples:

@@ -46,6 +51,14 @@ doing something else for the last seven minutes.

+ + Hamster@Software, doing some #reviews of pull requests + Hamster@Software, doing some reviews of pull requests, #reviews +

+ Those two syntaxes are equivalent. Single word tags can be embedded in the + description (except on the first word). +

+
Time input From 2b5c05926e5cfbddc59d5847f290cd5650a05d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Tue, 16 Mar 2021 09:31:23 +0100 Subject: [PATCH 8/8] Add a test to cover a description with a comma --- tests/test_stuff.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_stuff.py b/tests/test_stuff.py index 900d7ce03..0e2a5cd4d 100644 --- a/tests/test_stuff.py +++ b/tests/test_stuff.py @@ -94,6 +94,10 @@ def test_description(self): assert activity.end_time is None assert not activity.category + def test_description_with_commas(self): + activity = Fact.parse("case, meet with a, b and c, #holiday") + self.assertEqual(activity.description, "meet with a, b and c") + def test_tags(self): # plain activity name activity = Fact.parse("#case, description with #hash, #and #some #tägs")