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 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 fact_dict(fact_data, with_date): fact = {} if with_date: fmt = '%Y-%m-%d %H:%M' else: fmt = '%H:%M' fact['start'] = fact_data.start_time.strftime(fmt) if fact_data.end_time: fact['end'] = fact_data.end_time.strftime(fmt) else: end_date = stuff.hamster_now() fact['end'] = '' fact['duration'] = stuff.format_duration(fact_data.delta) fact['activity'] = fact_data.activity fact['category'] = fact_data.category if fact_data.tags: fact['tags'] = ' '.join('#%s' % tag for tag in fact_data.tags) else: fact['tags'] = '' fact['description'] = fact_data.description return fact
def load_suggestions(self): self.todays_facts = self.storage.get_todays_facts() # list of facts of last month now = stuff.hamster_now() last_month = self.storage.get_facts(now - dt.timedelta(days=30), now) # naive recency and frequency rank # score is as simple as you get 30-days_ago points for each occurence suggestions = defaultdict(int) for fact in last_month: days = 30 - (now - dt.datetime.combine( fact.date, dt.time())).total_seconds() / 60 / 60 / 24 label = fact.activity if fact.category: label += "@%s" % fact.category suggestions[label] += days if fact.tags: label += " #%s" % (" #".join(fact.tags)) suggestions[label] += days for rec in self.storage.get_activities(): label = rec["name"] if rec["category"]: label += "@%s" % rec["category"] suggestions[label] += 0 # list of (label, score), higher scores first self.suggestions = sorted(suggestions.items(), key=lambda x: x[1], reverse=True)
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 = hamster_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 __init__(self, start_time=None): graphics.Scene.__init__(self) self.set_can_focus(False) # no interaction self.day_start = conf.day_start start_time = start_time or stuff.hamster_now() self.view_time = start_time or dt.datetime.combine( start_time.date(), self.day_start) self.scope_hours = 24 self.fact_bars = [] self.categories = [] self.connect("on-enter-frame", self.on_enter_frame) self.plot_area = graphics.Sprite(y=15) self.chosen_selection = Selection() self.plot_area.add_child(self.chosen_selection) self.drag_start = None self.current_x = None self.date_label = graphics.Label(color=self._style.get_color( gtk.StateFlags.NORMAL), x=5, y=16) self.add_child(self.plot_area, self.date_label)
def localized_fact(self): """Make sure fact has the correct start_time.""" fact = Fact.parse(self.activity.get_text()) if fact.start_time: fact.date = self.date else: fact.start_time = hamster_now() return fact
def __init__(self, parent=None, fact_id=None, base_fact=None): gobject.GObject.__init__(self) self._gui = load_ui_file("edit_activity.ui") self.window = self.get_widget('custom_fact_window') self.window.set_size_request(600, 200) self.parent = parent # None if creating a new fact, instead of editing one self.fact_id = fact_id self.activity = widgets.ActivityEntry() self.activity.connect("changed", self.on_activity_changed) self.get_widget("activity_box").add(self.activity) self.day_start = conf.day_start 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.description_buffer.connect("changed", self.on_description_changed) self.save_button = self.get_widget("save_button") self.activity.grab_focus() if fact_id: # editing fact = runtime.storage.get_fact(fact_id) self.date = fact.date original_fact = fact self.window.set_title(_("Update activity")) else: self.date = hamster_today() self.get_widget("delete_button").set_sensitive(False) if base_fact: # start a clone now. original_fact = base_fact.copy(start_time=hamster_now(), end_time=None) else: original_fact = None if original_fact: stripped_fact = original_fact.copy() stripped_fact.description = None label = stripped_fact.serialized(prepend_date=False) with self.activity.handler_block(self.activity.checker): self.activity.set_text(label) time_len = len(label) - len(stripped_fact.serialized_name()) self.activity.select_region(0, time_len - 1) self.description_buffer.set_text(original_fact.description) self.activity.original_fact = original_fact self._gui.connect_signals(self) self.validate_fields() self.window.show_all()
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 = stuff.hamster_now() self.storage.check_fact(fact, default_day=stuff.hamster_today()) self.storage.add_fact(fact)
def add_fact(self, fact, temporary_activity=False): """Add fact (Fact).""" assert fact.activity, "missing activity" if not fact.start_time: logger.info("Adding fact without any start_time is deprecated") fact.start_time = hamster_now() dbus_fact = to_dbus_fact(fact) new_id = self.conn.AddFactVerbatim(dbus_fact) return new_id
def __touch_fact(self, fact, end_time=None): end_time = end_time or hamster_now() # tasks under one minute do not count if end_time - fact.start_time < dt.timedelta(minutes=1): self.__remove_fact(fact.id) else: query = """ UPDATE facts SET end_time = ? WHERE id = ? """ self.execute(query, (end_time, fact.id))
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 = hamster_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) if fact.start_time is None: self.update_status(status="wrong", markup="Missing start time") return None if not fact.activity: self.update_status(status="wrong", markup="Missing activity") return None if (fact.delta < dt.timedelta(0)) and fact.end_time: fact.end_time += dt.timedelta(days=1) markup = dedent("""\ <b>Working late ?</b> Duration would be negative. This happens when the activity crosses the hamster day start time ({:%H:%M} from tracking settings). Changing the end time date to the next day. Pressing the button would save an actvity going from {} to {} (in civil local time) """.format(conf.day_start, fact.start_time, fact.end_time)) self.update_status(status="warning", markup=markup) return fact # nothing unusual self.update_status(status="looks good", markup="") return fact
def on_cmdline_changed(self, widget): if self.master_is_cmdline: fact = Fact.parse(self.cmdline.get_text(), date=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 = hamster_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 add_fact(self, fact, start_time, end_time, temporary=False): """Add fact. fact: either a Fact instance or a string that can be parsed through Fact.parse. """ if isinstance(fact, Fact): fact_str = fact.serialized_name() else: fact_str = fact start_time = start_time or hamster_now() self.start_transaction() result = self.__add_fact(fact_str, start_time, end_time, temporary) self.end_transaction() if result: self.facts_changed() return result
def figure_time(str_time): if not str_time or not str_time.strip(): return None # strip everything non-numeric and consider hours to be first number # and minutes - second number numbers = re.split("\D", str_time) numbers = [x for x in numbers if x!=""] hours, minutes = None, None if len(numbers) == 1 and len(numbers[0]) == 4: hours, minutes = int(numbers[0][:2]), int(numbers[0][2:]) else: if len(numbers) >= 1: hours = int(numbers[0]) if len(numbers) >= 2: minutes = int(numbers[1]) if (hours is None or minutes is None) or hours > 24 or minutes > 60: return None #no can do return hamster_now()
def update_suggestions(self, text=""): """ * from previous activity | set time | minutes ago | start now * to ongoing | set time * activity * [@category] * #tags, #tags, #tags * we will leave description for later all our magic is space separated, strictly, start-end can be just dash phases: [start_time] | [-end_time] | activity | [@category] | [#tag] """ res = [] fact = Fact.parse(text) now = stuff.hamster_now() # figure out what we are looking for # time -> activity[@category] -> tags -> description # presence of an attribute means that we are not looking for the previous one # we still might be looking for the current one though looking_for = "start_time" fields = [ "start_time", "end_time", "activity", "category", "tags", "description", "done" ] for field in reversed(fields): if getattr(fact, field, None): looking_for = field if text[-1] == " ": looking_for = fields[fields.index(field) + 1] break fragments = [f for f in re.split("[\s|#]", text)] current_fragment = fragments[-1] if fragments else "" search = extract_search(text) matches = [] for match, score in self.suggestions: if search in match: if match.startswith(search): score += 10**8 # boost beginnings matches.append((match, score)) # need to limit these guys, sorry matches = sorted(matches, key=lambda x: x[1], reverse=True)[:7] for match, score in matches: label = (fact.start_time or now).strftime("%H:%M") if fact.end_time: label += fact.end_time.strftime("-%H:%M") markup_label = label + " " + (stuff.escape_pango(match).replace( search, "<b>%s</b>" % search) if search else match) label += " " + match res.append(DataRow(markup_label, match, label)) # list of tuples (description, variant) variants = [] if self.original_fact: # editing an existing fact variant_fact = None if self.original_fact.end_time is None: description = "stop now" variant_fact = self.original_fact.copy() variant_fact.end_time = now elif self.todays_facts and self.original_fact == self.todays_facts[ -1]: # that one is too dangerous, except for the last entry description = "keep up" # Do not use Fact(..., end_time=None): it would be a no-op variant_fact = self.original_fact.copy() variant_fact.end_time = None if variant_fact: variant_fact.description = None variant = variant_fact.serialized(default_day=self.default_day) variants.append((description, variant)) else: # brand new fact description = "start now" variant = now.strftime("%H:%M ") variants.append((description, variant)) prev_fact = self.todays_facts[-1] if self.todays_facts else None if prev_fact and prev_fact.end_time: since = stuff.format_duration(now - prev_fact.end_time) description = "from previous activity, %s ago" % since variant = prev_fact.end_time.strftime("%H:%M ") variants.append((description, variant)) description = "start activity -n minutes ago (1 or 3 digits allowed)" variant = "-" variants.append((description, variant)) text = text.strip() if text: description = "clear" variant = "" variants.append((description, variant)) for (description, variant) in variants: res.append(DataRow(variant, description=description)) self.complete_tree.set_rows(res)
def __add_fact(self, serialized_fact, start_time, end_time=None, temporary=False): fact = Fact.parse(serialized_fact) fact.start_time = start_time fact.end_time = end_time logger.info("adding fact {}".format(fact)) start_time = start_time or fact.start_time end_time = end_time or fact.end_time if not fact.activity or start_time is None: # sanity check return 0 # get tags from database - this will create any missing tags too tags = [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.get_tag_ids(fact.tags)] # now check if maybe there is also a category category_id = None if fact.category: category_id = self.__get_category_id(fact.category) if not category_id: category_id = self.__add_category(fact.category) # try to find activity, resurrect if not temporary activity_id = self.__get_activity_by_name(fact.activity, category_id, resurrect=not temporary) if not activity_id: activity_id = self.__add_activity(fact.activity, category_id, temporary) else: activity_id = activity_id['id'] # if we are working on +/- current day - check the last_activity if (dt.timedelta(days=-1) <= hamster_now() - start_time <= dt.timedelta(days=1)): # pull in previous facts facts = self.__get_todays_facts() previous = None if facts and facts[-1]["end_time"] == None: previous = facts[-1] if previous and previous['start_time'] <= start_time: # check if maybe that is the same one, in that case no need to restart if previous["activity_id"] == activity_id \ and set(previous["tags"]) == set([tag[1] for tag in tags]) \ and (previous["description"] or "") == (fact.description or ""): return None # if no description is added # see if maybe previous was too short to qualify as an activity if not previous["description"] \ and 60 >= (start_time - previous['start_time']).seconds >= 0: self.__remove_fact(previous['id']) # now that we removed the previous one, see if maybe the one # before that is actually same as the one we want to start # (glueing) if len(facts) > 1 and 60 >= ( start_time - facts[-2]['end_time']).seconds >= 0: before = facts[-2] if before["activity_id"] == activity_id \ and set(before["tags"]) == set([tag[1] for tag in tags]): # resume and return update = """ UPDATE facts SET end_time = null WHERE id = ? """ self.execute(update, (before["id"], )) return before["id"] else: # otherwise stop update = """ UPDATE facts SET end_time = ? WHERE id = ? """ self.execute(update, (start_time, previous["id"])) # done with the current activity, now we can solve overlaps if not end_time: end_time = self.__squeeze_in(start_time) else: self.__solve_overlaps(start_time, end_time) # finally add the new entry insert = """ INSERT INTO facts (activity_id, start_time, end_time, description) VALUES (?, ?, ?, ?) """ self.execute(insert, (activity_id, start_time, end_time, fact.description)) fact_id = self.__last_insert_rowid() #now link tags insert = ["insert into fact_tags(fact_id, tag_id) values(?, ?)" ] * len(tags) params = [(fact_id, tag[0]) for tag in tags] self.execute(insert, params) self.__remove_index([fact_id]) logger.info("fact successfully added, with id #{}".format(fact_id)) return fact_id
def parse_fact(text, phase=None, res=None, date=None): """tries to extract fact fields from the string the optional arguments in the syntax makes us actually try parsing values and fallback to next phase start -> [end] -> activity[@category] -> tags Returns dict for the fact and achieved phase TODO - While we are now bit cooler and going recursively, this code still looks rather awfully spaghetterian. What is the real solution? Tentative syntax: [date] start_time[-end_time] activity[@category][, description]{[,] { })#tag} According to the legacy tests, # were allowed in the description """ now = hamster_now() # determine what we can look for phases = [ "date", # hamster day "start_time", "end_time", "tags", "activity", "category", ] phase = phase or phases[0] phases = phases[phases.index(phase):] if res is None: res = {} text = text.strip() if not text: return res fragment = re.split("[\s|#]", text, 1)[0].strip() # remove a fragment assumed to be at the beginning of text remove_fragment = lambda text, fragment: text[len(fragment):] if "date" in phases: # if there is any date given, it must be at the front try: date = dt.datetime.strptime(fragment, DATE_FMT).date() remaining_text = remove_fragment(text, fragment) except ValueError: date = datetime_to_hamsterday(now) remaining_text = text return parse_fact(remaining_text, "start_time", res, date) if "start_time" in phases or "end_time" in phases: # -delta ? delta_re = re.compile("^-[0-9]{1,3}$") if delta_re.match(fragment): # TODO untested # delta_re was probably thought to be used # alone or together with a start_time # but using "now" prevents the latter res[phase] = now + dt.timedelta(minutes=int(fragment)) remaining_text = remove_fragment(text, fragment) return parse_fact(remaining_text, phases[phases.index(phase)+1], res, date) # only starting time ? m = re.search(time_re, fragment) if m: time = extract_time(m) res[phase] = hamsterday_time_to_datetime(date, time) remaining_text = remove_fragment(text, fragment) return parse_fact(remaining_text, phases[phases.index(phase)+1], res, date) # start-end ? start, __, end = fragment.partition("-") m_start = re.search(time_re, start) m_end = re.search(time_re, end) if m_start and m_end: start_time = extract_time(m_start) end_time = extract_time(m_end) res["start_time"] = hamsterday_time_to_datetime(date, start_time) res["end_time"] = hamsterday_time_to_datetime(date, end_time) remaining_text = remove_fragment(text, fragment) return parse_fact(remaining_text, "tags", res, date) if "tags" in phases: # Need to start from the end, because # the description can hold some '#' characters tags = [] remaining_text = text while True: m = re.search(tag_re, remaining_text) if not m: break tag = m.group(1) tags.append(tag) # strip the matched string (including #) remaining_text = remaining_text[:m.start()] # put tags back in input order res["tags"] = list(reversed(tags)) return parse_fact(remaining_text, "activity", res, date) if "activity" in phases: activity = re.split("[@|#|,]", text, 1)[0] if looks_like_time(activity): # want meaningful activities return res res["activity"] = activity remaining_text = remove_fragment(text, activity) return parse_fact(remaining_text, "category", res, date) if "category" in phases: category, _, description = text.partition(",") res["category"] = category.lstrip("@").strip() or None res["description"] = description.strip() or None return res return {}
def delta(self): """Duration (datetime.timedelta).""" end_time = self.end_time if self.end_time else hamster_now() return end_time - self.start_time
def on_enter_frame(self, scene, context): g = graphics.Graphics(context) self.plot_area.height = self.height - 30 vertical = min(self.plot_area.height / 5, 7) minute_pixel = (self.scope_hours * 60.0 - 15) / self.width g.set_line_style(width=1) g.translate(0.5, 0.5) colors = { "normal": self._style.get_color(gtk.StateFlags.NORMAL), "normal_bg": self._style.get_background_color(gtk.StateFlags.NORMAL), "selected": self._style.get_color(gtk.StateFlags.SELECTED), "selected_bg": self._style.get_background_color(gtk.StateFlags.SELECTED), } bottom = self.plot_area.y + self.plot_area.height for bar in self.fact_bars: bar.y = vertical * bar.category + 5 bar.height = vertical bar_start_time = bar.fact.start_time - self.view_time minutes = bar_start_time.seconds / 60 + bar_start_time.days * self.scope_hours * 60 bar.x = round(minutes / minute_pixel) + 0.5 bar.width = round((bar.fact.delta).seconds / 60 / minute_pixel) if self.chosen_selection.start_time and self.chosen_selection.width is None: # we have time but no pixels minutes = round( (self.chosen_selection.start_time - self.view_time).seconds / 60 / minute_pixel) + 0.5 self.chosen_selection.x = minutes if self.chosen_selection.end_time: self.chosen_selection.width = round( (self.chosen_selection.end_time - self.chosen_selection.start_time).seconds / 60 / minute_pixel) else: self.chosen_selection.width = 0 self.chosen_selection.height = self.chosen_selection.parent.height # use the oportunity to set proper colors too self.chosen_selection.fill = colors['selected_bg'] self.chosen_selection.duration_label.color = colors['selected'] #time scale g.set_color("#000") background = colors["normal_bg"] text = colors["normal"] tick_color = g.colors.contrast(background, 80) layout = g.create_layout(size=10) for i in range(self.scope_hours * 60): time = (self.view_time + dt.timedelta(minutes=i)) g.set_color(tick_color) if time.minute == 0: g.move_to(round(i / minute_pixel), bottom - 15) g.line_to(round(i / minute_pixel), bottom) g.stroke() elif time.minute % 15 == 0: g.move_to(round(i / minute_pixel), bottom - 5) g.line_to(round(i / minute_pixel), bottom) g.stroke() if time.minute == 0 and time.hour % 4 == 0: if time.hour == 0: g.move_to(round(i / minute_pixel), self.plot_area.y) g.line_to(round(i / minute_pixel), bottom) label_minutes = time.strftime("%b %d") else: label_minutes = time.strftime( "%H<small><sup>%M</sup></small>") g.set_color(text) layout.set_markup(label_minutes) g.move_to(round(i / minute_pixel) + 2, 0) pangocairo.show_layout(context, layout) #current time if self.view_time < stuff.hamster_now( ) < self.view_time + dt.timedelta(hours=self.scope_hours): minutes = round((stuff.hamster_now() - self.view_time).seconds / 60 / minute_pixel) g.rectangle(minutes, 0, self.width, self.height) g.fill(colors['normal_bg'], 0.7) g.move_to(minutes, self.plot_area.y) g.line_to(minutes, bottom) g.stroke("#f00", 0.4)
def __init__(self, parent=None, fact_id=None, base_fact=None): gobject.GObject.__init__(self) self._gui = load_ui_file("edit_activity.ui") self.window = self.get_widget('custom_fact_window') self.window.set_size_request(600, 200) self.parent = parent # None if creating a new fact, instead of editing one 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() self.get_widget("command line box").add(self.cmdline) 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() self.get_widget("end time box").add(self.end_time) self.start_date = widgets.Calendar( widget=self.get_widget("start date"), expander=self.get_widget("start date expander")) self.start_time = widgets.TimeInput() self.get_widget("start time box").add(self.start_time) 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() if fact_id: # editing self.fact = runtime.storage.get_fact(fact_id) self.date = self.fact.date self.window.set_title(_("Update activity")) else: self.window.set_title(_("Add activity")) self.date = hamster_today() self.get_widget("delete_button").set_sensitive(False) if base_fact: # start a clone now. self.fact = base_fact.copy(start_time=hamster_now(), end_time=None) else: self.fact = Fact(start_time=hamster_now()) original_fact = self.fact 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()
def __get_facts(self, date, end_date=None, search_terms=""): split_time = conf.day_start datetime_from = dt.datetime.combine(date, split_time) end_date = end_date or date datetime_to = dt.datetime.combine(end_date, split_time) + dt.timedelta( days=1, seconds=-1) logger.info("searching for facts from {} to {}".format( datetime_from, datetime_to)) query = """ SELECT a.id AS id, a.start_time AS start_time, a.end_time AS end_time, a.description as description, b.name AS name, b.id as activity_id, coalesce(c.name, ?) as category, e.name as tag FROM facts a LEFT JOIN activities b ON a.activity_id = b.id LEFT JOIN categories c ON b.category_id = c.id LEFT JOIN fact_tags d ON d.fact_id = a.id LEFT JOIN tags e ON e.id = d.tag_id WHERE (a.end_time >= ? OR a.end_time IS NULL) AND a.start_time <= ? """ if search_terms: # check if we need changes to the index self.__check_index(datetime_from, datetime_to) # flip the query around when it starts with "not " reverse_search_terms = search_terms.lower().startswith("not ") if reverse_search_terms: search_terms = search_terms[4:] search_terms = search_terms.replace('\\', '\\\\').replace( '%', '\\%').replace('_', '\\_').replace("'", "''") query += """ AND a.id %s IN (SELECT id FROM fact_index WHERE fact_index MATCH '%s')""" % ( 'NOT' if reverse_search_terms else '', search_terms) query += " ORDER BY a.start_time, e.name" facts = self.fetchall( query, (self._unsorted_localized, datetime_from, datetime_to)) #first let's put all tags in an array facts = self.__group_tags(facts) res = [] for fact in facts: # heuristics to assign tasks to proper days # if fact has no end time, set the last minute of the day, # or current time if fact has happened in last 12 hours if fact["end_time"]: fact_end_time = fact["end_time"] elif (hamster_today() == fact["start_time"].date()) or \ (hamster_now() - fact["start_time"]) <= dt.timedelta(hours=12): fact_end_time = hamster_now() else: fact_end_time = fact["start_time"] fact_start_date = fact["start_time"].date() \ - dt.timedelta(1 if fact["start_time"].time() < split_time else 0) fact_end_date = fact_end_time.date() \ - dt.timedelta(1 if fact_end_time.time() < split_time else 0) fact_date_span = fact_end_date - fact_start_date # check if the task spans across two dates if fact_date_span.days == 1: datetime_split = dt.datetime.combine(fact_end_date, split_time) start_date_duration = datetime_split - fact["start_time"] end_date_duration = fact_end_time - datetime_split if start_date_duration > end_date_duration: # most of the task was done during the previous day fact_date = fact_start_date else: fact_date = fact_end_date else: # either doesn't span or more than 24 hrs tracked # (in which case we give up) fact_date = fact_start_date if fact["start_time"] < datetime_from - dt.timedelta(days=30): # ignoring old on-going facts continue fact["date"] = fact_date fact["delta"] = fact_end_time - fact["start_time"] res.append(fact) return res
def stop_tracking(self, end_time=None): """Stop tracking current activity. end_time can be passed in if the activity should have other end time than the current moment""" end_time = timegm((end_time or hamster_now()).timetuple()) return self.conn.StopTracking(end_time)
def __solve_overlaps(self, start_time, end_time): """finds facts that happen in given interval and shifts them to make room for new fact """ if end_time is None or start_time is None: return # possible combinations and the OR clauses that catch them # (the side of the number marks if it catches the end or start time) # |----------------- NEW -----------------| # |--- old --- 1| |2 --- old --- 1| |2 --- old ---| # |3 ----------------------- big old ------------------------ 3| query = """ SELECT a.*, b.name, c.name as category FROM facts a LEFT JOIN activities b on b.id = a.activity_id LEFT JOIN categories c on b.category_id = c.id WHERE (end_time > ? and end_time < ?) OR (start_time > ? and start_time < ?) OR (start_time < ? and end_time > ?) ORDER BY start_time """ conflicts = self.fetchall( query, (start_time, end_time, start_time, end_time, start_time, end_time)) for fact in conflicts: # fact is a sqlite.Row, indexable by column name fact_end_time = fact["end_time"] or hamster_now() # won't eliminate as it is better to have overlapping entries than loosing data if start_time < fact["start_time"] and end_time > fact_end_time: continue # split - truncate until beginning of new entry and create new activity for end if fact["start_time"] < start_time < fact_end_time and \ fact["start_time"] < end_time < fact_end_time: logger.info("splitting %s" % fact["name"]) # truncate until beginning of the new entry self.execute( """UPDATE facts SET end_time = ? WHERE id = ?""", (start_time, fact["id"])) fact_name = fact["name"] # create new fact for the end new_fact = Fact(activity=fact["name"], category=fact["category"], description=fact["description"], start_time=end_time, end_time=fact_end_time) storage.Storage.check_fact(new_fact) new_fact_id = self.__add_fact(new_fact) # copy tags tag_update = """INSERT INTO fact_tags(fact_id, tag_id) SELECT ?, tag_id FROM fact_tags WHERE fact_id = ?""" self.execute(tag_update, (new_fact_id, fact["id"])) #clone tags # overlap start elif start_time < fact["start_time"] < end_time: logger.info("Overlapping start of %s" % fact["name"]) self.execute("UPDATE facts SET start_time=? WHERE id=?", (end_time, fact["id"])) # overlap end elif start_time < fact_end_time < end_time: logger.info("Overlapping end of %s" % fact["name"]) self.execute("UPDATE facts SET end_time=? WHERE id=?", (start_time, fact["id"]))
def __add_fact(self, fact, temporary=False): """Add fact to database. Args: fact (Fact) Returns: int, the new fact id in the database (> 0) on success, 0 if nothing needed to be done (e.g. if the same fact was already on-going), note: a sanity check on the given fact is performed first, that would raise an AssertionError. Other errors would also be handled through exceptions. """ logger.info("adding fact {}".format(fact)) start_time = fact.start_time end_time = fact.end_time # get tags from database - this will create any missing tags too tags = [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.get_tag_ids(fact.tags)] # now check if maybe there is also a category category_id = self.__get_category_id(fact.category) if not category_id: category_id = self.__add_category(fact.category) # try to find activity, resurrect if not temporary activity_id = self.__get_activity_by_name(fact.activity, category_id, resurrect=not temporary) if not activity_id: activity_id = self.__add_activity(fact.activity, category_id, temporary) else: activity_id = activity_id['id'] # if we are working on +/- current day - check the last_activity if (dt.timedelta(days=-1) <= hamster_now() - start_time <= dt.timedelta(days=1)): # pull in previous facts facts = self.__get_todays_facts() previous = None if facts and facts[-1].end_time is None: previous = facts[-1] if previous and previous.start_time <= start_time: # check if maybe that is the same one, in that case no need to restart if (previous.activity_id == activity_id and set(previous.tags) == set([tag[1] for tag in tags]) and (previous.description or "") == (fact.description or "")): logger.info("same fact, already on-going, nothing to do") return 0 # if no description is added # see if maybe previous was too short to qualify as an activity if (not previous.description and 60 >= (start_time - previous.start_time).seconds >= 0): self.__remove_fact(previous.id) # now that we removed the previous one, see if maybe the one # before that is actually same as the one we want to start # (glueing) if len(facts) > 1 and 60 >= ( start_time - facts[-2].end_time).seconds >= 0: before = facts[-2] if (before.activity_id == activity_id and set( before.tags) == set([tag[1] for tag in tags])): # resume and return update = """ UPDATE facts SET end_time = null WHERE id = ? """ self.execute(update, (before.id, )) return before.id else: # otherwise stop update = """ UPDATE facts SET end_time = ? WHERE id = ? """ self.execute(update, (start_time, previous.id)) # done with the current activity, now we can solve overlaps if not end_time: end_time = self.__squeeze_in(start_time) else: self.__solve_overlaps(start_time, end_time) # finally add the new entry insert = """ INSERT INTO facts (activity_id, start_time, end_time, description) VALUES (?, ?, ?, ?) """ self.execute(insert, (activity_id, start_time, end_time, fact.description)) fact_id = self.__last_insert_rowid() #now link tags insert = ["insert into fact_tags(fact_id, tag_id) values(?, ?)" ] * len(tags) params = [(fact_id, tag[0]) for tag in tags] self.execute(insert, params) self.__remove_index([fact_id]) logger.info("fact successfully added, with id #{}".format(fact_id)) return fact_id
def parse_datetime_range(text, position="exact", separator="\s+", default_day=None, ref="now"): """Parse a start-end range from text. position (str): "exact" to match exactly the full text "head" to search only at the beginning of text, and "tail" to search only at the end. separator (str): regexp pattern (e.g. '\s+') meant to separate the datetime from the rest. Discarded for "exact" position. default_day (dt.date): If start is given without any date (e.g. just hh:mm), put the corresponding datetime in default_day. Defaults to hamster_today. Note: the default end day is always the start day, so "2019-11-27 23:50 - 00:20" lasts 30 minutes. ref (dt.datetime): reference for relative times (e.g. -15: quarter hour before ref). For testing purposes only (note: this will be removed later on, and replaced with hamster_now mocking in pytest). For users, it should be "now". Return: (start, end, rest) """ if ref == "now": ref = hamster_now() if default_day is None: default_day = hamster_today() assert position in ("exact", "head", "tail"), "position unknown: '{}'".format(position) if position == "exact": p = "^{}$".format(range_pattern) elif position == "head": # require at least a space after, to avoid matching 10.00@cat # .*? so rest is as little as possible p = "^{}{}(?P<rest>.*?)$".format(range_pattern, separator) elif position == "tail": # require at least a space after, to avoid matching #10.00 # .*? so rest is as little as possible p = "^(?P<rest>.*?){}{}$".format(separator, range_pattern) # no need to compile, recent patterns are cached by re m = re.search(p, text, flags=re.VERBOSE) if not m: return None, None, text elif position == "exact": rest = "" else: rest = m.group("rest") if m.group('firstday'): # only day given for start firstday = parse_date(m.group('firstday')) start = hamsterday_start(firstday) else: firstday = None start = _extract_datetime(m, d="date1", h="hour1", m="minute1", r="relative1", default_day=default_day) if isinstance(start, dt.timedelta): # relative to ref, actually delta1 = start start = ref + delta1 if m.group('lastday'): lastday = parse_date(m.group('lastday')) end = hamsterday_end(lastday) elif firstday: end = hamsterday_end(firstday) else: end = _extract_datetime(m, d="date2", h="hour2", m="minute2", r="relative2", default_day=datetime_to_hamsterday(start)) if isinstance(end, dt.timedelta): # relative to start, actually delta2 = end if delta2 > dt.timedelta(0): # wip: currently not reachable (would need [-\+]\d{1,3} in the parser). end = start + delta2 elif ref and delta2 < dt.timedelta(0): end = ref + delta2 else: end = None return start, end, rest