def test_roundtrips(self): for start_time in ( None, dt.time(12, 33), ): for end_time in ( None, dt.time(13, 34), ): for activity in ( "activity", "#123 with two #hash", "activity, with comma", ): for category in ( "", "category", ): for description in ( "", "description", "with #hash", "with, comma", "with @at", ): for tags in ( [], ["single"], ["with space"], ["two", "tags"], ["with @at"], ): start = hamsterday_time_to_datetime( hamster_today(), start_time) if start_time else None end = hamsterday_time_to_datetime( hamster_today(), end_time) if end_time else None if end and not start: # end without start is not parseable continue fact = Fact(start_time=start, end_time=end, activity=activity, category=category, description=description, tags=tags) 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.activity, fact.activity) self.assertEqual(parsed.category, fact.category) self.assertEqual(parsed.description, fact.description) self.assertEqual(parsed.tags, fact.tags)
def test_round_trip(self): fact = Fact.parse( "11:00 12:00 activity, with comma@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) dbus_fact = to_dbus_fact(fact) return_fact = from_dbus_fact(dbus_fact) self.assertEqual(return_fact, fact) fact = Fact.parse("11:00 activity") dbus_fact = to_dbus_fact_json(fact) return_fact = from_dbus_fact_json(dbus_fact) self.assertEqual(return_fact, fact) dbus_fact = to_dbus_fact(fact) return_fact = from_dbus_fact(dbus_fact) self.assertEqual(return_fact, fact) range, __ = dt.Range.parse("2020-01-19 11:00 - 2020-01-19 12:00") dbus_range = to_dbus_range(range) return_range = from_dbus_range(dbus_range) self.assertEqual(return_range, range)
def test_decimal_in_activity(self): # cf. issue #270 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") 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(), "")
def test_range(self): t1 = dt.datetime(2020, 1, 15, 13, 30) t2 = dt.datetime(2020, 1, 15, 15, 30) range = dt.Range(t1, t2) fact = Fact(range=range) self.assertEqual(fact.range.start, t1) self.assertEqual(fact.range.end, t2) fact = Fact(start=t1, end=t2) self.assertEqual(fact.range.start, t1) self.assertEqual(fact.range.end, t2) # backward compatibility (before v3.0) fact = Fact(start_time=t1, end_time=t2) self.assertEqual(fact.range.start, t1) self.assertEqual(fact.range.end, t2)
def test_comparison(self): fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1, fact2) fact2 = fact1.copy() fact2.activity = "abcd" self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.category = "abcd" self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.description = "abcd" self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.range.start = fact1.range.start + dt.timedelta(minutes=1) self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.range.end = fact1.range.end + dt.timedelta(minutes=1) self.assertNotEqual(fact1, fact2) # wrong order fact2 = fact1.copy() fact2.tags = ["bäg", "tag"] self.assertNotEqual(fact1, fact2) # correct order fact2 = fact1.copy() fact2.tags = ["tag", "bäg"] self.assertEqual(fact1, fact2)
def AddFact(self, fact_str, start_time, end_time, temporary=False): """Add fact specified by a string. Args: fact_str (str): string to be parsed. start_time (int): Start datetime timestamp. For backward compatibility with the gnome shell extension, 0 is special and means hamster_now(). Otherwise, overrides the parsed value. -1 means None. end_time (int): Start datetime timestamp. If different from 0, overrides the parsed value. -1 means None. Returns: fact id (int), or 0 in case of failure. Note: see datetime.utcfromtimestamp documentation for the precise meaning of timestamps. """ fact = Fact.parse(fact_str) if start_time == -1: fact.start_time = None elif start_time == 0: fact.start_time = stuff.hamster_now() else: fact.start_time = dt.datetime.utcfromtimestamp(start_time) if end_time == -1: fact.end_time = None elif end_time != 0: fact.end_time = dt.datetime.utcfromtimestamp(end_time) return self.add_fact(fact) or 0
def add_fact(self, fact, start_time=None, end_time=None, temporary=False): """Add fact. fact: either a Fact instance or a string that can be parsed through Fact.parse. note: start_time and end_time are used only when fact is a string, for backward compatibility. Passing fact as a string is deprecated and will be removed in a future version. Parsing should be done in the caller. """ if isinstance(fact, str): logger.info("Passing fact as a string is deprecated") fact = Fact.parse(fact) fact.start_time = start_time fact.end_time = end_time # better fail before opening the transaction self.check_fact(fact) self.start_transaction() result = self.__add_fact(fact, temporary) self.end_transaction() if result: self.facts_changed() return result
def validate_fields(self): """Check fields information. Update gui status about entry and description validity. Try to merge date, activity and description informations. Return the consolidated fact if successful, or None. """ fact = self.fact now = dt.datetime.now() self.get_widget("button-next-day").set_sensitive(self.date < now.date()) if self.date == now.date(): default_dt = now else: default_dt = dt.datetime.combine(self.date, now.time()) self.draw_preview(fact.start_time or default_dt, fact.end_time or default_dt) try: runtime.storage.check_fact(fact, default_day=self.date) except FactError as error: self.update_status(status="wrong", markup=str(error)) return None roundtrip_fact = Fact.parse(fact.serialized(), default_day=self.date) if roundtrip_fact != fact: self.update_status(status="wrong", markup="Fact could not be parsed back") return None # nothing unusual self.update_status(status="looks good", markup="") return fact
def test_comparison(self): fact1 = Fact.parse("12:25-13:25 case@cat,, description #tag #bäg") fact2 = fact1.copy() self.assertEqual(fact1, fact2) fact2 = fact1.copy() fact2.activity = "abcd" self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.category = "abcd" self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.description = "abcd" self.assertNotEqual(fact1, fact2) import datetime as dt fact2 = fact1.copy() fact2.start_time = hamster_now() self.assertNotEqual(fact1, fact2) fact2 = fact1.copy() fact2.end_time = hamster_now() self.assertNotEqual(fact1, fact2) # wrong order fact2 = fact1.copy() fact2.tags = ["bäg", "tag"] self.assertNotEqual(fact1, fact2) # correct order fact2 = fact1.copy() fact2.tags = ["tag", "bäg"] self.assertEqual(fact1, fact2)
def extract_search(text): fact = Fact.parse(text) search = fact.activity if fact.category: search += "@%s" % fact.category if fact.tags: search += " #%s" % (" #".join(fact.tags)) return search
def test_plain_name(self): # plain activity name activity = Fact.parse("just a simple case with ütf-8") self.assertEqual(activity.activity, "just a simple case with ütf-8") assert activity.start_time is None assert activity.end_time is None assert not activity.category assert not activity.description
def test_category(self): # plain activity name activity = Fact.parse("just a simple case@hämster") self.assertEqual(activity.activity, "just a simple case") self.assertEqual(activity.category, "hämster") assert activity.start_time is None assert activity.end_time is None assert not activity.description
def test_description(self): # plain activity name 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 assert activity.start_time is None assert activity.end_time is None assert not activity.category
def test_tags(self): # plain activity name 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"])) assert not activity.category assert activity.start_time is None assert activity.end_time is None
def test_with_start_and_end_time(self): # with time activity = Fact.parse("12:35-14:25 with start-end time") self.assertEqual(activity.activity, "with start-end time") self.assertEqual(activity.start_time.strftime("%H:%M"), "12:35") self.assertEqual(activity.end_time.strftime("%H:%M"), "14:25") #rest must be empty assert not activity.category assert not activity.description
def test_full(self): # plain activity name activity = Fact.parse( "1225-1325 case@cat,, description #ta 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(set(activity.tags), set(["bäg", "tag"]))
def _dbfact_to_libfact(self, db_fact): """Convert a db fact (coming from __group_facts) to Fact.""" return Fact(activity=db_fact["name"], category=db_fact["category"], description=db_fact["description"], tags=db_fact["tags"], start_time=db_fact["start_time"], end_time=db_fact["end_time"], id=db_fact["id"], activity_id=db_fact["activity_id"])
def from_dbus_fact(dbus_fact): """unpack the struct into a proper dict""" return Fact(activity=dbus_fact[4], start_time=dt.datetime.utcfromtimestamp(dbus_fact[1]), end_time=dt.datetime.utcfromtimestamp(dbus_fact[2]) if dbus_fact[2] else None, description=dbus_fact[3], activity_id=dbus_fact[5], category=dbus_fact[6], tags=dbus_fact[7], id=dbus_fact[0])
def start(self, *args): '''Start a new activity.''' if not args: print("Error: please specify activity") return fact = Fact.parse(" ".join(args), range_pos="tail") if fact.start_time is None: fact.start_time = dt.datetime.now() self.storage.check_fact(fact, default_day=dt.hday.today()) self.storage.add_fact(fact)
def complete_first(self): text = self.get_text() fact = Fact.parse(text) search = extract_search(text) if not self.complete_tree.rows or not fact.activity: return text, None label = self.complete_tree.rows[0].data if label.startswith(search): return text, label[len(search):] return text, None
def from_dbus_fact_json(dbus_fact): """Convert D-Bus JSON to Fact.""" d = loads(dbus_fact) range_d = d['range'] # should use pdt.datetime.fromisoformat, # but that appears only in python3.7, nevermind start_s = range_d['start'] end_s = range_d['end'] range = dt.Range(start=dt.datetime.parse(start_s) if start_s else None, end=dt.datetime.parse(end_s) if end_s else None) d['range'] = range return Fact(**d)
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") 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") 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") self.assertEqual(fact.category, "category") self.assertEqual(fact.description, "description, with comma and #hash") self.assertEqual(fact.tags, ["tag1", "tag2"])
def on_cmdline_changed(self, widget): if self.master_is_cmdline: fact = Fact.parse(self.cmdline.get_text(), default_day=self.date) previous_cmdline_fact = self.cmdline_fact # copy the entered fact before any modification self.cmdline_fact = fact.copy() if fact.start_time is None: fact.start_time = dt.datetime.now() if fact.description == previous_cmdline_fact.description: # no change to description here, keep the main one fact.description = self.fact.description self.fact = fact self.update_fields()
def from_dbus_fact(dbus_fact): """Unpack the struct into a proper dict. Legacy: to besuperceded by from_dbus_fact_json at some point. """ return Fact(activity=dbus_fact[4], start_time=dt.datetime.utcfromtimestamp(dbus_fact[1]), end_time=dt.datetime.utcfromtimestamp(dbus_fact[2]) if dbus_fact[2] else None, description=dbus_fact[3], activity_id=dbus_fact[5], category=dbus_fact[6], tags=dbus_fact[7], id=dbus_fact[0])
def check_fact(cls, fact, default_day=None): """Check Fact validity for inclusion in the storage. Raise FactError(message) on failure. """ if fact.start_time is None: raise FactError("Missing start time") if fact.end_time and (fact.delta < dt.timedelta(0)): fixed_fact = Fact(start_time=fact.start_time, end_time=fact.end_time + dt.timedelta(days=1)) suggested_range_str = fixed_fact.serialized_range( default_day=default_day) # work around cyclic imports from hamster.lib.configuration import conf raise FactError( dedent("""\ Duration would be negative. Working late ? This happens when the activity crosses the hamster day start time ({:%H:%M} from tracking settings). Suggestion: move the end to the next day; the range would become: {} (in civil local time) """.format(conf.day_start, suggested_range_str))) if not fact.activity: raise FactError("Missing activity") if ',' in fact.category: raise FactError( dedent("""\ Forbidden comma in category: '{}' Note: The description separator changed from single comma to double comma ',,' (cf. PR #482). """.format(fact.category)))
def update_fact(self, fact_id, fact, start_time=None, end_time=None, temporary=False): # better fail before opening the transaction self.check_fact(fact) self.start_transaction() self.__remove_fact(fact_id) # to be removed once update facts use Fact directly. if isinstance(fact, str): fact = Fact.parse(fact) fact = fact.copy(start_time=start_time, end_time=end_time) result = self.__add_fact(fact, temporary) if not result: logger.warning("failed to update fact {} ({})".format(fact_id, fact)) self.end_transaction() if result: self.facts_changed() return result
def test_copy(self): 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) self.assertEqual(fact1.activity, fact2.activity) self.assertEqual(fact1.category, fact2.category) self.assertEqual(fact1.description, fact2.description) self.assertEqual(fact1.tags, fact2.tags) fact3 = fact1.copy(activity="changed") self.assertEqual(fact3.activity, "changed") fact3 = fact1.copy(category="changed") self.assertEqual(fact3.category, "changed") fact3 = fact1.copy(description="changed") self.assertEqual(fact3.description, "changed") fact3 = fact1.copy(tags=["changed"]) self.assertEqual(fact3.tags, ["changed"])
def AddFact(self, fact_str, start_time, end_time, temporary): """Add fact specified by a string. If the parsed fact has no start, then now is used. To fully use the hamster fact parser, as on the cmdline, just pass 0 for start_time and end_time. Args: fact_str (str): string to be parsed. start_time (int): Start datetime ovveride timestamp (ignored if 0). -1 means None. end_time (int): datetime ovveride timestamp (ignored if 0). -1 means None. #temporary (boolean): historical mystery, ignored, but needed to keep the method signature stable. Do not forget to pass something (e.g. False)! Returns: fact id (int), 0 means failure. Note: see datetime.utcfromtimestamp documentation for the precise meaning of timestamps. """ fact = Fact.parse(fact_str) # default value if none found if not fact.start_time: fact.start_time = dt.datetime.now() if start_time == -1: fact.start_time = None elif start_time != 0: fact.start_time = dt.datetime.utcfromtimestamp(start_time) if end_time == -1: fact.end_time = None elif end_time != 0: fact.end_time = dt.datetime.utcfromtimestamp(end_time) return self.add_fact(fact)
def __init__(self, action, fact_id=None): Controller.__init__(self) self._date = None # for the date property self._gui = load_ui_file("edit_activity.ui") self.window = self.get_widget('custom_fact_window') self.window.set_size_request(600, 200) self.action = action self.fact_id = fact_id self.category_entry = widgets.CategoryEntry( widget=self.get_widget('category')) self.activity_entry = widgets.ActivityEntry( widget=self.get_widget('activity'), category_widget=self.category_entry) self.cmdline = widgets.CmdLineEntry( parent=self.get_widget("cmdline box")) self.cmdline.connect("focus_in_event", self.on_cmdline_focus_in_event) self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event) self.dayline = widgets.DayLine() self._gui.get_object("day_preview").add(self.dayline) self.description_box = self.get_widget('description') self.description_buffer = self.description_box.get_buffer() self.end_date = widgets.Calendar( widget=self.get_widget("end date"), expander=self.get_widget("end date expander")) self.end_time = widgets.TimeInput( parent=self.get_widget("end time box")) self.start_date = widgets.Calendar( widget=self.get_widget("start date"), expander=self.get_widget("start date expander")) self.start_time = widgets.TimeInput( parent=self.get_widget("start time box")) self.tags_entry = widgets.TagsEntry() self.get_widget("tags box").add(self.tags_entry) self.save_button = self.get_widget("save_button") # this will set self.master_is_cmdline self.cmdline.grab_focus() title = _("Update activity") if action == "edit" else _("Add activity") self.window.set_title(title) self.get_widget("delete_button").set_sensitive(action == "edit") if action == "edit": self.fact = runtime.storage.get_fact(fact_id) elif action == "clone": base_fact = runtime.storage.get_fact(fact_id) self.fact = base_fact.copy(start_time=dt.datetime.now(), end_time=None) else: self.fact = Fact(start_time=dt.datetime.now()) original_fact = self.fact # TODO: should use hday, not date. self.date = self.fact.date self.update_fields() self.update_cmdline(select=True) self.cmdline.original_fact = original_fact # This signal should be emitted only after a manual modification, # not at init time when cmdline might not always be fully parsable. self.cmdline.connect("changed", self.on_cmdline_changed) self.description_buffer.connect("changed", self.on_description_changed) self.start_time.connect("changed", self.on_start_time_changed) self.start_date.connect("day-selected", self.on_start_date_changed) self.start_date.expander.connect("activate", self.on_start_date_expander_activated) self.end_time.connect("changed", self.on_end_time_changed) self.end_date.connect("day-selected", self.on_end_date_changed) self.end_date.expander.connect("activate", self.on_end_date_expander_activated) self.activity_entry.connect("changed", self.on_activity_changed) self.category_entry.connect("changed", self.on_category_changed) self.tags_entry.connect("changed", self.on_tags_changed) self._gui.connect_signals(self) self.validate_fields() self.window.show_all()