From 2508d1879bcf65352997bb2583c40e3041b64d29 Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Wed, 3 Jul 2024 15:35:51 +0200 Subject: [PATCH 01/11] dnd implementation with the shortcut --- src/vince.py | 52 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/vince.py b/src/vince.py index f1a0f32..720557e 100644 --- a/src/vince.py +++ b/src/vince.py @@ -57,7 +57,7 @@ def _load(self,_): if self.creds: return if not self._has_internet(): - print(f"Waiting for internet ... ") + logging.debug(f"Waiting for internet ... ") self.title = "Waiting for internet ... " quit_btn = rumps.MenuItem("Quit") quit_btn.set_callback(self.quit) @@ -132,7 +132,7 @@ def load_events(self): events = events_result.get('items', []) except Exception as e: - print(e) + logging.debug(e) events = None if events: for event in events: @@ -174,7 +174,7 @@ def load_events(self): d_events = sorted(d_events, key=lambda d: d['start']) self.menu_items = d_events except HttpError as err: - print(err) + logging.debug(err) def extract_urls(self, text): # # Regular expression pattern to match URLs @@ -227,18 +227,21 @@ def build_menu(self): quit_btn.set_callback(self.quit) self.menu.add(quit_btn) + def _open_browser(self, urls): + for url in urls: + if app_meet:= self.settings.get('app_meet',""): + if url.startswith("https://meet.google.com"): + cmd = fr"open -a {app_meet} " + logging.debug(cmd) + os.system(cmd) + return + + webbrowser.open(url) + def open_browser(self, sender): - print(self.settings.get('app_meet',"")) + logging.debug(self.settings.get('app_meet',"")) if self.settings['link_opening_enabled']: - for url in sender.urls: - if app_meet:= self.settings.get('app_meet',""): - if url.startswith("https://meet.google.com"): - cmd = fr"open -a {app_meet} " - print(cmd) - os.system(cmd) - return - - webbrowser.open(url) + self._open_browser(sender.urls) @rumps.clicked("Refresh Menu") def refresh_menu(self, _): @@ -338,11 +341,13 @@ def update_bar_str(self, _): title += ", " if current_events != self.current_events: if not current_events: + self.dnd(None) self.slack_meeting(None) else: event = sorted( current_events, key=lambda x: x["end"])[0] self.slack_meeting(event) + self.dnd(event) self.current_events = current_events # get the shortest one and update with taht data @@ -446,12 +451,11 @@ def send_and_open_link(self, _): ) if self.settings['link_opening_enabled']: if event['urls']: - for url in event['urls']: - webbrowser.open(url) + self._open_browser(event['urls']) @rumps.clicked("Quit") def quit(self, _): - print('over') + logging.debug('over') rumps.quit_application() def _convert_minutes_to_epoch(self, mins): @@ -459,6 +463,22 @@ def _convert_minutes_to_epoch(self, mins): epoch = calendar.timegm(future.timetuple()) return epoch + + def dnd(self, event, reset=False): + current_datetime = datetime.now(pytz.utc) + if not event: + # reset DND + pars = "off" + else: + minutes = (event['end']-current_datetime).seconds // 60 + # set DND for minutes, note that this is already 1 min before. + pars = minutes + try: + logging.info(f'shortcuts run "Calm Notifications" -i {pars}') + os.system(f'shortcuts run "Calm Notifications" -i {pars}') + except Exception: + logging.exception("Problemi with running shorcut.") + def slack_meeting(self, event, reset=False): slack_token = self.settings.get('slack_oauth_token',"") if not slack_token: From bb5cd23e3d61a926bd64c9790eefad14255d8d2b Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Wed, 3 Jul 2024 16:05:17 +0200 Subject: [PATCH 02/11] showing all events but adding icon to show past present and future --- src/vince.py | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/vince.py b/src/vince.py index 720557e..dc4fba6 100644 --- a/src/vince.py +++ b/src/vince.py @@ -205,15 +205,22 @@ def build_menu(self): hours, minutes = self._time_left( item['start'], current_datetime) menu_item = rumps.MenuItem( - title=f"[{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d}) {item['summary']}") + title=f"⏰ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d}) {item['summary']}") + elif item['end'] < current_datetime: + hours, minutes = self._time_left( + current_datetime,item['end'], end_time=True) + + menu_item = rumps.MenuItem( + title=f"☑️[{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d} ago) {item['summary']}") else: # if it's current, does not print time menu_item = rumps.MenuItem( - title=f"[{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}](now) {item['summary']}") + title=f"⭐️ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}](now) {item['summary']}") if item['url']: # if there's a meet link it adds the link and the "clicking option" # otherwise the item cannot be clicked. and it look disable. menu_item.urls = item['urls'] + menu_item.set_callback(self.open_browser) self.menu.add(menu_item) # add the quit button @@ -250,20 +257,20 @@ def refresh_menu(self, _): self.update_exiting_events(None) - @rumps.timer(61) - def update_exiting_events(self, _): - if not self.creds: - return - # every 60 seconds remove the events that are past. - current_datetime = datetime.now(pytz.utc) - res = [] - for el in self.menu_items: - if el['end'] >= current_datetime: - res.append(el) - self.menu_items = res - self.build_menu() - - def _time_left(self, event_time, current_datetime, show_seconds=False): + # @rumps.timer(61) + # def update_exiting_events(self, _): + # if not self.creds: + # return + # # every 60 seconds remove the events that are past. + # current_datetime = datetime.now(pytz.utc) + # res = [] + # for el in self.menu_items: + # if el['end'] >= current_datetime: + # res.append(el) + # self.menu_items = res + # self.build_menu() + + def _time_left(self, event_time, current_datetime, show_seconds=False, end_time=False): # calcualtes time left between two datetimes, retunrs horus,minutes and optinaly seconds time_left = event_time - current_datetime @@ -274,10 +281,11 @@ def _time_left(self, event_time, current_datetime, show_seconds=False): minutes, seconds = divmod(remainder, 60) time_left_str = f"{hours:02d}:{minutes:02d}" # :{seconds:02d} if not show_seconds: - minutes += 1 - if minutes == 60: - hours += 1 - minutes = 0 + if not end_time: + minutes += 1 + if minutes == 60: + hours += 1 + minutes = 0 return hours, minutes else: return hours, minutes, seconds From c2d4514f991a22fbbe7c3b5d9e9aa8d611d2b363 Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Wed, 3 Jul 2024 17:28:56 +0200 Subject: [PATCH 03/11] fix bug --- src/vince.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/vince.py b/src/vince.py index dc4fba6..a3f75a4 100644 --- a/src/vince.py +++ b/src/vince.py @@ -257,18 +257,18 @@ def refresh_menu(self, _): self.update_exiting_events(None) - # @rumps.timer(61) - # def update_exiting_events(self, _): - # if not self.creds: - # return - # # every 60 seconds remove the events that are past. - # current_datetime = datetime.now(pytz.utc) - # res = [] - # for el in self.menu_items: - # if el['end'] >= current_datetime: - # res.append(el) - # self.menu_items = res - # self.build_menu() + @rumps.timer(61) + def update_exiting_events(self, _): + if not self.creds: + return + # every 60 seconds remove the events that are past. + current_datetime = datetime.now(pytz.utc) + # res = [] + # for el in self.menu_items: + # if el['end'] >= current_datetime: + # res.append(el) + self.menu_items = self.menu_items + self.build_menu() def _time_left(self, event_time, current_datetime, show_seconds=False, end_time=False): # calcualtes time left between two datetimes, retunrs horus,minutes and optinaly seconds @@ -449,7 +449,7 @@ def send_and_open_link(self, _): next_events = self._get_next_events() for event in next_events: hours, minutes, seconds = self._time_left( - event['start'], current_datetime, True) + event['start'], current_datetime, show_seconds=True) if hours == 0 and minutes == 1 and seconds == 0: rumps.notification( title="It's meeting time", From 5e36fca551d060ff667d167bdf6c670b08a6465d Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Tue, 5 Nov 2024 16:37:39 +0100 Subject: [PATCH 04/11] DND if the event is with other people --- src/vince.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/vince.py b/src/vince.py index a3f75a4..2e8f883 100644 --- a/src/vince.py +++ b/src/vince.py @@ -169,6 +169,7 @@ def load_events(self): logging.debug( f"{event['summary']} | {event.get('description','')} | {event_url}") d_event = dict(id=id, start=start, end=end, summary=event["summary"], url=event_url, + attendees=attendees, urls=urls, eventType=event['eventType'], visibility=event.get('visibility', 'default')) d_events.append(d_event) d_events = sorted(d_events, key=lambda d: d['start']) @@ -199,9 +200,13 @@ def build_menu(self): # Create a menu item for each item in the list current_datetime = datetime.now(pytz.utc) + current_is_now = False + previous_is_now = False for item in self.menu_items: + previous_is_now = current_is_now + current_is_now = False # if it's coming it tells how much time left - if item['start'] > current_datetime: + if item['start'] > current_datetime+timedelta(minutes=1): hours, minutes = self._time_left( item['start'], current_datetime) menu_item = rumps.MenuItem( @@ -211,9 +216,10 @@ def build_menu(self): current_datetime,item['end'], end_time=True) menu_item = rumps.MenuItem( - title=f"☑️[{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d} ago) {item['summary']}") + title=f"☑️ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d} ago) {item['summary']}") else: # if it's current, does not print time + current_is_now = True menu_item = rumps.MenuItem( title=f"⭐️ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}](now) {item['summary']}") if item['url']: @@ -222,8 +228,19 @@ def build_menu(self): menu_item.urls = item['urls'] menu_item.set_callback(self.open_browser) + else: + menu_item.title += " ❌" + menu_item.set_callback(self.show_alert) + if previous_is_now and not current_is_now: + # last item of now + self.menu.add(rumps.separator) + if not previous_is_now and current_is_now: + # first item of now + self.menu.add(rumps.separator) + self.menu.add(menu_item) # add the quit button + self.menu.add(rumps.separator) settings_item = rumps.MenuItem( "Settings", callback=self.open_settings_window) self.menu.add(settings_item) @@ -245,6 +262,11 @@ def _open_browser(self, urls): webbrowser.open(url) + def show_alert(self, sender): + rumps.alert("no link for this event") + + + def open_browser(self, sender): logging.debug(self.settings.get('app_meet',"")) if self.settings['link_opening_enabled']: @@ -474,7 +496,9 @@ def _convert_minutes_to_epoch(self, mins): def dnd(self, event, reset=False): current_datetime = datetime.now(pytz.utc) - if not event: + # no DND if there's no attenedees (means the event is mine?) + if not event or len(event['attendees'])<=1: + logging.info(f"No DND for event with no attendees {event['attendees']}") # reset DND pars = "off" else: From 492c9bc15005758b16f8cfd1a94ec50b3ce1b06c Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Tue, 5 Nov 2024 17:30:49 +0100 Subject: [PATCH 05/11] DND if the event is with other people --- src/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/requirements.txt b/src/requirements.txt index 5f721db..733a80a 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -10,4 +10,5 @@ requests chardet appdirs requests -bs4 \ No newline at end of file +bs4 +setuptools<71 \ No newline at end of file From 8d1aca66509da45badea4540c12a9c621f42065a Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Thu, 21 Nov 2024 09:52:11 +0100 Subject: [PATCH 06/11] i can't recall but works :D --- changelog.md | 9 ++ src/vince.py | 374 +++++++++++++++++++++++++++------------------------ 2 files changed, 209 insertions(+), 174 deletions(-) create mode 100644 changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..18e4c0e --- /dev/null +++ b/changelog.md @@ -0,0 +1,9 @@ +[1.2.0] 2024-06-05 +- Added support for chrome meet app to open the meet app link (pedro request). + To enable this feature, follow these steps: + 1. Locate the Google Meet app on your system. It's typically found at this path: `/Users/YOUR_USER/Applications/Chrome\ Apps.localized/Google\ Meet.app`. + 2. Copy this path and replace any single backslash `\` with a double backslash `\\`. The modified path should look like this: `/Users/YOUR_USER/Applications/Chrome\\ Apps.localized/Google\\ Meet.app`. + 3. Paste this modified path into the settings under the `app_meet` field. + 4. Once done, any Google Meet link will trigger the app to open. + + Please note a known limitation: The app does not automatically open the meeting link. You will need to manually enter the meeting ID or select the meeting from your event list on the right side of the app. diff --git a/src/vince.py b/src/vince.py index 2e8f883..fd09a38 100644 --- a/src/vince.py +++ b/src/vince.py @@ -22,40 +22,39 @@ import urllib.request import time -logging.basicConfig(level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) def str_truncate(string, width): if len(string) > width: - string = string[:width-3] + '...' + string = string[: width - 3] + "..." return string class Vince(rumps.App): def __init__(self): - super(Vince, self).__init__( - "Vince", icon="menu-icon.png", template=True) - self.scopes = ['https://www.googleapis.com/auth/calendar.readonly'] + super(Vince, self).__init__("Vince", icon="menu-icon.png", template=True) + self.scopes = ["https://www.googleapis.com/auth/calendar.readonly"] self.flow = None # this is to get the library folder self.app_name = "Vince" self.settings = self.load_settings() self.current_events = [] self.creds = None - - - def _has_internet(self): + def _has_internet(self): try: urllib.request.urlopen("https://www.google.com") return True except urllib.error.URLError: return False + @rumps.timer(5) - def _load(self,_): + def _load(self, _): if self.creds: - return + return if not self._has_internet(): logging.debug(f"Waiting for internet ... ") self.title = "Waiting for internet ... " @@ -63,9 +62,7 @@ def _load(self,_): quit_btn.set_callback(self.quit) self.menu.clear() self.menu.add(quit_btn) - return - - + return data_dir = user_data_dir(self.app_name) file_path = os.path.join(data_dir, "token.json") @@ -73,28 +70,29 @@ def _load(self,_): # handles google login at startup. not the best, but works token_path = str(file_path) if os.path.exists(token_path): - creds = Credentials.from_authorized_user_file( - token_path, self.scopes) + creds = Credentials.from_authorized_user_file(token_path, self.scopes) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: try: creds.refresh(Request()) except: flow = InstalledAppFlow.from_client_secrets_file( - 'credentials.json', self.scopes) + "credentials.json", self.scopes + ) creds = flow.run_local_server(port=0) else: flow = InstalledAppFlow.from_client_secrets_file( - 'credentials.json', self.scopes) + "credentials.json", self.scopes + ) creds = flow.run_local_server(port=0) # Save the credentials for the next run - with open(token_path, 'w') as token: + with open(token_path, "w") as token: token.write(creds.to_json()) self.creds = creds self.load_events() self.build_menu() - @rumps.timer(90*5) + @rumps.timer(90 * 5) def timely_load_events(self, _): if not self.creds: return @@ -112,38 +110,45 @@ def load_events(self): # self.menu_items = d_events # gets all todays' event from calendar try: - service = build('calendar', 'v3', credentials=self.creds) + service = build("calendar", "v3", credentials=self.creds) # Get today's date and format it today = datetime.combine(date.today(), datetime.min.time()) # Retrieve events for today d_events = [] - for calendar in self.settings.get('calendars', ['primary']): + for calendar in self.settings.get("calendars", ["primary"]): try: - events_result = service.events().list( - calendarId=calendar, - timeMin=today.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), - timeMax=(datetime.combine(date.today(), datetime.min.time( - )) + timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), - singleEvents=True, - orderBy='startTime', - showDeleted=False, - ).execute() - - events = events_result.get('items', []) + events_result = ( + service.events() + .list( + calendarId=calendar, + timeMin=today.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + timeMax=( + datetime.combine(date.today(), datetime.min.time()) + + timedelta(days=1) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + singleEvents=True, + orderBy="startTime", + showDeleted=False, + ) + .execute() + ) + + events = events_result.get("items", []) except Exception as e: logging.debug(e) events = None if events: for event in events: try: - id = event['id'] - start = event['start'].get( - 'dateTime', event['start'].get('date')) - end = event['end'].get( - 'dateTime', event['start'].get('date')) - start = datetime.strptime( - start, "%Y-%m-%dT%H:%M:%S%z") + id = event["id"] + start = event["start"].get( + "dateTime", event["start"].get("date") + ) + end = event["end"].get( + "dateTime", event["start"].get("date") + ) + start = datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") end = datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") except: # most probably a daily event @@ -151,14 +156,13 @@ def load_events(self): add_event = True # skip declined events. - if attendees := event.get('attendees', []): + if attendees := event.get("attendees", []): for attendee in attendees: - if attendee.get('self', False): - if attendee['responseStatus'] == 'declined': + if attendee.get("self", False): + if attendee["responseStatus"] == "declined": add_event = False if add_event: - event_url = event.get( - 'hangoutLink', '') + event_url = event.get("hangoutLink", "") description = event.get("description", "") urls = self.extract_urls(description) if not event_url: @@ -167,12 +171,21 @@ def load_events(self): else: urls.append(event_url) logging.debug( - f"{event['summary']} | {event.get('description','')} | {event_url}") - d_event = dict(id=id, start=start, end=end, summary=event["summary"], url=event_url, - attendees=attendees, - urls=urls, eventType=event['eventType'], visibility=event.get('visibility', 'default')) + f"{event['summary']} | {event.get('description','')} | {event_url}" + ) + d_event = dict( + id=id, + start=start, + end=end, + summary=event["summary"], + url=event_url, + attendees=attendees, + urls=urls, + eventType=event["eventType"], + visibility=event.get("visibility", "default"), + ) d_events.append(d_event) - d_events = sorted(d_events, key=lambda d: d['start']) + d_events = sorted(d_events, key=lambda d: d["start"]) self.menu_items = d_events except HttpError as err: logging.debug(err) @@ -183,11 +196,11 @@ def extract_urls(self, text): # # Find all occurrences of the pattern in the text # urls = re.findall(url_pattern, text) - soup = BeautifulSoup(text, 'html.parser') + soup = BeautifulSoup(text, "html.parser") urls = [] - for link in soup.find_all('a'): - urls.append(link.get('href')) + for link in soup.find_all("a"): + urls.append(link.get("href")) if not urls: for link in re.findall(r"(?Phttps?://[^\s]+)", text): urls.append(link) @@ -205,32 +218,40 @@ def build_menu(self): for item in self.menu_items: previous_is_now = current_is_now current_is_now = False + extra = "" + if not item["attendees"] or len(item["attendees"]) <= 1: + extra = "👤" + if not item["url"]: + extra += " ⛓️‍💥" + # if there's a meet link it adds the link and the "clicking option" + # otherwise the item cannot be clicked. and it look disable. + # if it's coming it tells how much time left - if item['start'] > current_datetime+timedelta(minutes=1): - hours, minutes = self._time_left( - item['start'], current_datetime) + if item["start"] > current_datetime + timedelta(minutes=1): + hours, minutes = self._time_left(item["start"], current_datetime) menu_item = rumps.MenuItem( - title=f"⏰ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d}) {item['summary']}") - elif item['end'] < current_datetime: + title=f"⏰ {extra} [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d}) {item['summary']}" + ) + elif item["end"] < current_datetime: hours, minutes = self._time_left( - current_datetime,item['end'], end_time=True) - + current_datetime, item["end"], end_time=True + ) + menu_item = rumps.MenuItem( - title=f"☑️ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d} ago) {item['summary']}") + title=f"☑️ {extra} [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}]({hours:02d}:{minutes:02d} ago) {item['summary']}" + ) else: # if it's current, does not print time current_is_now = True menu_item = rumps.MenuItem( - title=f"⭐️ [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}](now) {item['summary']}") - if item['url']: - # if there's a meet link it adds the link and the "clicking option" - # otherwise the item cannot be clicked. and it look disable. - menu_item.urls = item['urls'] - + title=f"⭐️ {extra} [{item['start'].strftime('%H:%M')}-{item['end'].strftime('%H:%M')}](now) {item['summary']}" + ) + if item["url"]: + menu_item.urls = item["urls"] menu_item.set_callback(self.open_browser) else: - menu_item.title += " ❌" menu_item.set_callback(self.show_alert) + if previous_is_now and not current_is_now: # last item of now self.menu.add(rumps.separator) @@ -241,8 +262,7 @@ def build_menu(self): self.menu.add(menu_item) # add the quit button self.menu.add(rumps.separator) - settings_item = rumps.MenuItem( - "Settings", callback=self.open_settings_window) + settings_item = rumps.MenuItem("Settings", callback=self.open_settings_window) self.menu.add(settings_item) refresh_btn = rumps.MenuItem("Refresh") refresh_btn.set_callback(self.refresh_menu) @@ -253,23 +273,21 @@ def build_menu(self): def _open_browser(self, urls): for url in urls: - if app_meet:= self.settings.get('app_meet',""): + if app_meet := self.settings.get("app_meet", ""): if url.startswith("https://meet.google.com"): - cmd = fr"open -a {app_meet} " + cmd = rf"open -a {app_meet} " logging.debug(cmd) os.system(cmd) return - + webbrowser.open(url) def show_alert(self, sender): rumps.alert("no link for this event") - - def open_browser(self, sender): - logging.debug(self.settings.get('app_meet',"")) - if self.settings['link_opening_enabled']: + logging.debug(self.settings.get("app_meet", "")) + if self.settings["link_opening_enabled"]: self._open_browser(sender.urls) @rumps.clicked("Refresh Menu") @@ -278,7 +296,6 @@ def refresh_menu(self, _): self.build_menu() self.update_exiting_events(None) - @rumps.timer(61) def update_exiting_events(self, _): if not self.creds: @@ -292,13 +309,14 @@ def update_exiting_events(self, _): self.menu_items = self.menu_items self.build_menu() - def _time_left(self, event_time, current_datetime, show_seconds=False, end_time=False): + def _time_left( + self, event_time, current_datetime, show_seconds=False, end_time=False + ): # calcualtes time left between two datetimes, retunrs horus,minutes and optinaly seconds time_left = event_time - current_datetime time_left_str = str(time_left).split(".")[0] - time_left_str = time_left_str.split( - ",")[0] # Remove microseconds if present + time_left_str = time_left_str.split(",")[0] # Remove microseconds if present hours, remainder = divmod(time_left.seconds, 3600) minutes, seconds = divmod(remainder, 60) time_left_str = f"{hours:02d}:{minutes:02d}" # :{seconds:02d} @@ -319,7 +337,10 @@ def _get_current_events(self): res = [] if self.menu_items: for item in self.menu_items: - if item['start'] <= current_datetime and item['end'] >= current_datetime: + if ( + item["start"] <= current_datetime + and item["end"] >= current_datetime + ): res.append(item) return res @@ -333,12 +354,12 @@ def _get_next_events(self): start_time = None if self.menu_items: for item in self.menu_items: - if item['start'] >= current_datetime: + if item["start"] >= current_datetime: if not start_time: - start_time = item['start'] + start_time = item["start"] res.append(item) else: - if start_time == item['start']: + if start_time == item["start"]: res.append(item) else: return res @@ -348,7 +369,7 @@ def _get_next_events(self): def update_bar_str(self, _): if not self.creds: return - if self.settings['show_menu_bar']: + if self.settings["show_menu_bar"]: # updates the bar if self.menu_items: current_datetime = datetime.now(pytz.utc) @@ -360,11 +381,17 @@ def update_bar_str(self, _): # first all the current, with time left for event in current_events: hours, minutes, seconds = self._time_left( - event['end'], current_datetime, True) + event["end"], current_datetime, True + ) + summary = event["summary"] + if not event or len(event["attendees"]) <= 1: + summary = "👤" if hours > 0 or minutes > 15: - title += f" {str_truncate(event['summary'],20)}: {hours:02d}:{minutes:02d}" + title += ( + f" {str_truncate(summary,20)}: {hours:02d}:{minutes:02d}" + ) else: - title += f" {str_truncate(event['summary'],20)}: {hours:02d}:{minutes:02d}:{seconds:02d}" + title += f" {str_truncate(summary,20)}: {hours:02d}:{minutes:02d}:{seconds:02d}" i_current_events += 1 # separated with comma if more than one if i_current_events < len_current_events: @@ -374,8 +401,7 @@ def update_bar_str(self, _): self.dnd(None) self.slack_meeting(None) else: - event = sorted( - current_events, key=lambda x: x["end"])[0] + event = sorted(current_events, key=lambda x: x["end"])[0] self.slack_meeting(event) self.dnd(event) @@ -387,8 +413,9 @@ def update_bar_str(self, _): if len_next_events: title += " [" for event in next_events: - hours, minutes = self._time_left( - event['start'], current_datetime) + if not event or len(event["attendees"]) <= 1: + title += "👤 " + hours, minutes = self._time_left(event["start"], current_datetime) title += f"{str_truncate(event['summary'],20)}: in {hours:02d}:{minutes:02d}" i_next_events += 1 if i_next_events < len_next_events: @@ -404,14 +431,12 @@ def update_bar_str(self, _): def _str_event_menu_current(self, element): # create the items in the menu. util function current_datetime = datetime.now(pytz.utc) - if element['start'] > current_datetime: - hours, minutes = self._time_left( - element['start'], current_datetime) + if element["start"] > current_datetime: + hours, minutes = self._time_left(element["start"], current_datetime) time_left_str = f" in {hours:02d}:{minutes:02d}" else: - if element['start'] < current_datetime: - hours, minutes = self._time_left( - element['end'], current_datetime) + if element["start"] < current_datetime: + hours, minutes = self._time_left(element["end"], current_datetime) time_left_str = f" {hours:02d}:{minutes:02d} left" return f"{element['summary'][:20]} {time_left_str}" @@ -419,8 +444,7 @@ def _str_event_menu_next(self, element): # same but for upcoming eents. current_datetime = datetime.now(pytz.utc) if element: - hours, minutes = self._time_left( - element['start'], current_datetime) + hours, minutes = self._time_left(element["start"], current_datetime) title = f" [{element['summary'][:20]} in {hours:02d}:{minutes:02d}]" return title else: @@ -429,25 +453,29 @@ def _str_event_menu_next(self, element): @rumps.timer(1) def send_notification_(self, _): if not self.creds: - return - if self.settings['notifications']: - + return + if self.settings["notifications"]: if self.menu_items: current_datetime = datetime.now(pytz.utc) current_events = self._get_current_events() for event in current_events: hours, minutes, seconds = self._time_left( - event['end'], current_datetime, True) + event["end"], current_datetime, True + ) # send a notification 5 min before the end that event it's almost over - notifications = self.settings['notifications'] + notifications = self.settings["notifications"] for notification in notifications: - minute_notification = notification['time_left'] - if hours == 0 and minutes == minute_notification and seconds == 0: + minute_notification = notification["time_left"] + if ( + hours == 0 + and minutes == minute_notification + and seconds == 0 + ): rumps.notification( title=f"{minute_notification} minutes left", subtitle=f"Just {minute_notification}", message=f"I said {minute_notification} mins left", - sound=notification['sound'] + sound=notification["sound"], ) # and when it's over @@ -456,14 +484,14 @@ def send_notification_(self, _): title=f"{event['summary']}", subtitle="It's over", message="It's over", - sound=True + sound=True, ) @rumps.timer(1) def send_and_open_link(self, _): if not self.creds: - return - if self.settings['link_opening_enabled']: + return + if self.settings["link_opening_enabled"]: # 1 min beofre the meeting it opens the browser with the link # you can't miss it. if self.menu_items: @@ -471,40 +499,39 @@ def send_and_open_link(self, _): next_events = self._get_next_events() for event in next_events: hours, minutes, seconds = self._time_left( - event['start'], current_datetime, show_seconds=True) + event["start"], current_datetime, show_seconds=True + ) if hours == 0 and minutes == 1 and seconds == 0: rumps.notification( title="It's meeting time", subtitle=f"For {event['summary']}", message=f"For {event['summary']}", - sound=True + sound=True, ) - if self.settings['link_opening_enabled']: - if event['urls']: - self._open_browser(event['urls']) + if self.settings["link_opening_enabled"]: + if event["urls"]: + self._open_browser(event["urls"]) @rumps.clicked("Quit") def quit(self, _): - logging.debug('over') + logging.debug("over") rumps.quit_application() def _convert_minutes_to_epoch(self, mins): - future = datetime.utcnow() + timedelta(minutes=mins+1) + future = datetime.utcnow() + timedelta(minutes=mins + 1) epoch = calendar.timegm(future.timetuple()) return epoch - def dnd(self, event, reset=False): current_datetime = datetime.now(pytz.utc) # no DND if there's no attenedees (means the event is mine?) - if not event or len(event['attendees'])<=1: - logging.info(f"No DND for event with no attendees {event['attendees']}") + if not event or len(event["attendees"]) <= 1: # reset DND pars = "off" else: - minutes = (event['end']-current_datetime).seconds // 60 + minutes = (event["end"] - current_datetime).seconds // 60 # set DND for minutes, note that this is already 1 min before. - pars = minutes + pars = minutes try: logging.info(f'shortcuts run "Calm Notifications" -i {pars}') os.system(f'shortcuts run "Calm Notifications" -i {pars}') @@ -512,11 +539,10 @@ def dnd(self, event, reset=False): logging.exception("Problemi with running shorcut.") def slack_meeting(self, event, reset=False): - slack_token = self.settings.get('slack_oauth_token',"") + slack_token = self.settings.get("slack_oauth_token", "") if not slack_token: return - auth = { - 'Authorization': 'Bearer %s' % slack_token} + auth = {"Authorization": "Bearer %s" % slack_token} # if reset: # data = {"num_minutes": 0} # res = requests.get('https://slack.com/api/dnd.setSnooze', params=data, @@ -524,47 +550,43 @@ def slack_meeting(self, event, reset=False): # else: current_datetime = datetime.now(pytz.utc) if not event: - data = { - "profile": { - "status_text": "", - "status_emoji": "" - } - } + data = {"profile": {"status_text": "", "status_emoji": ""}} epoch = self._convert_minutes_to_epoch(0) - data['profile']["status_expiration"] = epoch - res = requests.post('https://slack.com/api/users.profile.set', json=data, - headers=auth) + data["profile"]["status_expiration"] = epoch + res = requests.post( + "https://slack.com/api/users.profile.set", json=data, headers=auth + ) data = {"num_minutes": 0} - res = requests.get('https://slack.com/api/dnd.setSnooze', params=data, - headers=auth) + res = requests.get( + "https://slack.com/api/dnd.setSnooze", params=data, headers=auth + ) else: - minutes = (event['end']-current_datetime).seconds // 60 - if event['eventType'] == 'outOfOffice': + minutes = (event["end"] - current_datetime).seconds // 60 + if event["eventType"] == "outOfOffice": status_emoji = ":no_entry_sign:" - elif event['eventType'] == 'focusTime': + elif event["eventType"] == "focusTime": status_emoji = ":person_in_lotus_position:" - elif event['summary'].lower() in ['lunch']: + elif event["summary"].lower() in ["lunch"]: status_emoji = ":chef-brb:" else: status_emoji = ":date:" - if event['visibility'] in ['default', 'public']: + if event["visibility"] in ["default", "public"]: status_text = f"{event['summary']} [{event['start'].strftime('%H:%M')}-{event['end'].strftime('%H:%M')}]" else: status_text = f"Meeting [{event['start'].strftime('%H:%M')}-{event['end'].strftime('%H:%M')}]" data = { - "profile": { - "status_text": status_text, - "status_emoji": status_emoji - } + "profile": {"status_text": status_text, "status_emoji": status_emoji} } epoch = self._convert_minutes_to_epoch(minutes) - data['profile']["status_expiration"] = epoch - res = requests.post('https://slack.com/api/users.profile.set', json=data, - headers=auth) + data["profile"]["status_expiration"] = epoch + res = requests.post( + "https://slack.com/api/users.profile.set", json=data, headers=auth + ) data = {"num_minutes": minutes} - res = requests.get('https://slack.com/api/dnd.setSnooze', params=data, - headers=auth) + res = requests.get( + "https://slack.com/api/dnd.setSnooze", params=data, headers=auth + ) def load_settings(self): data_dir = user_data_dir(self.app_name) @@ -573,18 +595,12 @@ def load_settings(self): "calendars": ["primary"], "link_opening_enabled": True, "show_menu_bar": True, - "app_meet":"", + "app_meet": "", "notifications": [ - { - "time_left": 5, - "sound": False - }, { - "time_left": 3, - "sound": False - }, { - "time_left": 1, - "sound": False - }], + {"time_left": 5, "sound": False}, + {"time_left": 3, "sound": False}, + {"time_left": 1, "sound": False}, + ], } try: with open(settings_path, "r") as settings_file: @@ -602,23 +618,33 @@ def save_settings(self): json.dump(self.settings, settings_file, indent=4) def slack_oauth(self): - client_id = '3091729876.2525836761175' + client_id = "3091729876.2525836761175" scopes = "user_scope=dnd:write,users.profile:write,users:write" - state = ''.join(random.choices( - string.ascii_uppercase + string.digits, k=15)) - url = "https://slack.com/oauth/v2/authorize?client_id=" + \ - client_id+"&scope=&"+scopes+"&state="+state + state = "".join(random.choices(string.ascii_uppercase + string.digits, k=15)) + url = ( + "https://slack.com/oauth/v2/authorize?client_id=" + + client_id + + "&scope=&" + + scopes + + "&state=" + + state + ) rumps.alert( - "Proceed in the browers and copy the string `xoxo--..` and past them in the settings at `slack_oauth_token`") + "Proceed in the browers and copy the string `xoxo--..` and past them in the settings at `slack_oauth_token`" + ) webbrowser.open(url) self.open_settings_window(None) def open_settings_window(self, _): - window = rumps.Window(title="Vince Settings", dimensions=( - 300, 200), ok='Save settings', cancel=True) + window = rumps.Window( + title="Vince Settings", + dimensions=(300, 200), + ok="Save settings", + cancel=True, + ) window.message = "Configure your settings:" window.default_text = json.dumps(self.settings, indent=2) - window.add_button('Slack Setup') + window.add_button("Slack Setup") res = window.run() if res.clicked == 2: self.slack_oauth() @@ -629,14 +655,14 @@ def open_settings_window(self, _): title="Saved settings", subtitle="", message="Saved settings", - sound=True + sound=True, ) except: rumps.notification( title="Cannot save the settings", subtitle="", message="There was an error", - sound=True + sound=True, ) From a4d1f47de834156c6b82d4a6f44d5c7cbc8120ac Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Mon, 10 Feb 2025 16:47:12 +0100 Subject: [PATCH 07/11] adding the popup window with teh counter --- changelog.md | 16 +-- src/_setup.py | 32 ++++++ src/build.fish | 2 +- src/countdown_window.py | 212 ++++++++++++++++++++++++++++++++++++++++ src/requirements.txt | 6 +- src/setup.py | 32 +++--- src/vince.py | 186 +++++++++++++++++++++++++++++++---- 7 files changed, 444 insertions(+), 42 deletions(-) create mode 100644 src/_setup.py create mode 100644 src/countdown_window.py diff --git a/changelog.md b/changelog.md index 18e4c0e..3efe1f5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,9 +1,13 @@ +https://github.com/jaredks/rumps/issues/200#issuecomment-1475380684 + [1.2.0] 2024-06-05 + - Added support for chrome meet app to open the meet app link (pedro request). - To enable this feature, follow these steps: - 1. Locate the Google Meet app on your system. It's typically found at this path: `/Users/YOUR_USER/Applications/Chrome\ Apps.localized/Google\ Meet.app`. - 2. Copy this path and replace any single backslash `\` with a double backslash `\\`. The modified path should look like this: `/Users/YOUR_USER/Applications/Chrome\\ Apps.localized/Google\\ Meet.app`. - 3. Paste this modified path into the settings under the `app_meet` field. - 4. Once done, any Google Meet link will trigger the app to open. + To enable this feature, follow these steps: + + 1. Locate the Google Meet app on your system. It's typically found at this path: `/Users/YOUR_USER/Applications/Chrome\ Apps.localized/Google\ Meet.app`. + 2. Copy this path and replace any single backslash `\` with a double backslash `\\`. The modified path should look like this: `/Users/YOUR_USER/Applications/Chrome\\ Apps.localized/Google\\ Meet.app`. + 3. Paste this modified path into the settings under the `app_meet` field. + 4. Once done, any Google Meet link will trigger the app to open. - Please note a known limitation: The app does not automatically open the meeting link. You will need to manually enter the meeting ID or select the meeting from your event list on the right side of the app. + Please note a known limitation: The app does not automatically open the meeting link. You will need to manually enter the meeting ID or select the meeting from your event list on the right side of the app. diff --git a/src/_setup.py b/src/_setup.py new file mode 100644 index 0000000..fe430d2 --- /dev/null +++ b/src/_setup.py @@ -0,0 +1,32 @@ +""" +This is a setup.py script generated by py2applet + +Usage: + python setup.py py2app +""" + +from setuptools import setup + +APP = ['vince.py'] +DATA_FILES = [('', ['credentials.json','icon.png','menu-icon.png'])] + +OPTIONS = { + 'argv_emulation': False, + 'plist': {'LSUIElement': True, + 'CFBundleName': 'Vince2', + 'CFBundleShortVersionString': '1.2.0', + }, + 'iconfile':'icon.png', + } + + +setup( + app=APP, + data_files=DATA_FILES, + options={'py2app': OPTIONS}, + setup_requires=['py2app'], + author='Stefano Tranquillini', # Set the author name here + author_email='stefano.tranquillini@gmail.com', # Set the author email here + url='https://www.stefanotranquillini.com', # Set the project URL here + license='GNU GPL 3', # Set the project license here +) diff --git a/src/build.fish b/src/build.fish index bbf09da..14a6067 100755 --- a/src/build.fish +++ b/src/build.fish @@ -1,4 +1,4 @@ #!/bin/bash rm -rf dist/ rm -rf build/ -python setup.py py2app +python setup.py py2app --includes imp diff --git a/src/countdown_window.py b/src/countdown_window.py new file mode 100644 index 0000000..1f8311f --- /dev/null +++ b/src/countdown_window.py @@ -0,0 +1,212 @@ +import AppKit +import time +from datetime import datetime, timedelta +from Foundation import NSMakeRect, NSMakePoint +import pytz + +import logging + + +class CountdownWindowDelegate(AppKit.NSObject): + def windowWillClose_(self, notification): + window = notification.object() + if hasattr(window, "owner"): + window.owner.handle_window_closed() + + +class CountdownWindow: + def __init__(self, event, parent=None): + self.parent = parent + self.event = event + # Create window delegate + self.delegate = CountdownWindowDelegate.alloc().init() + + self.window = AppKit.NSWindow.alloc() + screen = AppKit.NSScreen.mainScreen() + screen_frame = screen.frame() + window_width = 200.0 + window_height = 50.0 + frame = ( + ( + screen_frame.size.width / 2 - window_width / 2, + screen_frame.size.height - window_height, + ), + (window_width, window_height), + ) + self.window.initWithContentRect_styleMask_backing_defer_( + frame, + AppKit.NSWindowStyleMaskBorderless, # Remove title bar + AppKit.NSBackingStoreBuffered, + False, + ) + # Make window movable by dragging anywhere + self.window.setMovableByWindowBackground_(True) + + self.paretn = parent + + # Set window properties for transparency and rounded corners + self.window.setBackgroundColor_(AppKit.NSColor.clearColor()) + self.window.setOpaque_(False) + + # Create a visual effect view for the background + visual_effect = AppKit.NSVisualEffectView.alloc().initWithFrame_( + self.window.contentView().bounds() + ) + visual_effect.setAutoresizingMask_( + AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable + ) + visual_effect.setWantsLayer_(True) + visual_effect.layer().setCornerRadius_(10.0) # Set corner radius + visual_effect.layer().setMasksToBounds_(True) + + # Set the visual effect to be light and transparent + visual_effect.setMaterial_(AppKit.NSVisualEffectMaterialLight) + visual_effect.setBlendingMode_(AppKit.NSVisualEffectBlendingModeWithinWindow) + visual_effect.setState_(AppKit.NSVisualEffectStateActive) + + # Replace the content view with our visual effect view + self.window.contentView().addSubview_(visual_effect) + self.visual_effect = visual_effect + + # Create and configure the countdown label + content_view = self.visual_effect + # Make the label height bigger to accommodate the font size + label_height = 30 + label_width = window_width - 40 # Leave space for close button + self.label = AppKit.NSTextField.alloc().initWithFrame_( + NSMakeRect( + 10, (window_height - label_height) / 2, label_width, label_height + ) + ) + self.label.setBezeled_(False) + self.label.setDrawsBackground_(False) + self.label.setEditable_(False) + self.label.setSelectable_(False) + self.label.setAlignment_(AppKit.NSTextAlignmentCenter) + self.label.setFont_(AppKit.NSFont.boldSystemFontOfSize_(24)) + self.label.setTextColor_(AppKit.NSColor.blackColor()) + content_view.addSubview_(self.label) + + # Create and configure the event name label + event_name_label_height = 20 + event_name_label = AppKit.NSTextField.alloc().initWithFrame_( + NSMakeRect(10, -5, label_width, event_name_label_height) + ) + event_name_label.setBezeled_(False) + event_name_label.setDrawsBackground_(False) + event_name_label.setEditable_(False) + event_name_label.setSelectable_(False) + event_name_label.setAlignment_(AppKit.NSTextAlignmentCenter) + event_name_label.setFont_(AppKit.NSFont.systemFontOfSize_(10)) + event_name_label.setTextColor_(AppKit.NSColor.blackColor()) + event_name_label.setStringValue_(self.event["summary"]) + content_view.addSubview_(event_name_label) + + # Add close button + button_size = 20 + close_button = AppKit.NSButton.alloc().initWithFrame_( + NSMakeRect( + window_width - button_size - 10, + (window_height - button_size) / 2, + button_size, + button_size, + ) + ) + close_button.setBezelStyle_(AppKit.NSBezelStyleCircular) + close_button.setTitle_("×") + # close_button.setFontColor_(AppKit.NSColor.blackColor()) + close_button.setTarget_(self) + close_button.setAction_("close") + attributes = { + AppKit.NSForegroundColorAttributeName: AppKit.NSColor.blackColor(), + AppKit.NSFontAttributeName: AppKit.NSFont.boldSystemFontOfSize_(16), + } + attributed_title = AppKit.NSAttributedString.alloc().initWithString_attributes_( + "×", attributes + ) + + # Apply the styled title + close_button.setAttributedTitle_(attributed_title) + + content_view.addSubview_(close_button) + + # Set initial background color (yellow) + initial_color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 0.0, 0.0, 0.0, 0.95 + ) # Bright yellow + self.visual_effect.layer().setBackgroundColor_(initial_color.CGColor()) + + self.window.makeKeyAndOrderFront_(None) + self.window.setLevel_(AppKit.NSFloatingWindowLevel) + self.timer = None + + def start_countdown(self): + self.timer = AppKit.NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 1.0, + self, + "timerCallback:", + { + "end_time": self.event["end"], + "start_time": self.event["start"], + }, + True, + ) + AppKit.NSRunLoop.currentRunLoop().addTimer_forMode_( + self.timer, AppKit.NSRunLoopCommonModes + ) + + def timerCallback_(self, timer): + start_time = timer.userInfo()["start_time"] + end_time = timer.userInfo()["end_time"] + time_to_use = start_time if start_time > datetime.now(pytz.utc) else end_time + sign = None + if time_to_use == start_time: + sign = "+" + now = datetime.now(pytz.utc) + time_diff = time_to_use - now + + logging.debug( + f"Time diff: {time_diff}, sign: {sign} time_to_use: {time_to_use}, now: {now}" + ) + + total_seconds = int(time_diff.total_seconds()) + minutes = abs(total_seconds) // 60 + seconds = abs(total_seconds) % 60 + + # Update background color based on time + if sign == "+": + color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 0.5, 0.0, 0.5, 0.95 + ) # Green + elif total_seconds < 0: + # Red for expired + color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 0.9, 0.2, 0.2, 0.95 + ) # Bright red + elif total_seconds <= 300 and total_seconds > 60: # Between 1 and 5 minutes + color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 241 / 255, 196 / 255, 15 / 255, 0.95 + ) # Yellow + else: # More than 5 minutes + color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 46 / 255, 204 / 255, 113 / 255, 0.95 + ) + + if not sign: + sign = "-" if total_seconds < 0 else "" + countdown_text = f"{sign}{minutes:01d}:{seconds:02d}" + self.label.setStringValue_(countdown_text) + + self.visual_effect.layer().setBackgroundColor_(color.CGColor()) + + def show(self): + # Show the window + self.window.makeKeyAndOrderFront_(self.window) + + def close(self): + if self.timer: + self.timer.invalidate() + self.window.close() + if self.parent and self.event and "id" in self.event: + if self.event["id"] in self.parent.countdown_windows: + del self.parent.countdown_windows[self.event["id"]] diff --git a/src/requirements.txt b/src/requirements.txt index 733a80a..db16943 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,10 +5,12 @@ google-auth-httplib2 google-auth-oauthlib pytz py2app -py2app[setuptools] +# py2app[setuptools] requests chardet appdirs requests bs4 -setuptools<71 \ No newline at end of file +setuptools<71 +#AppKit +PyObjC-core \ No newline at end of file diff --git a/src/setup.py b/src/setup.py index 749520f..38b034b 100644 --- a/src/setup.py +++ b/src/setup.py @@ -7,26 +7,26 @@ from setuptools import setup -APP = ['vince.py'] -DATA_FILES = [('', ['credentials.json','icon.png','menu-icon.png'])] +APP = ["vince.py"] +DATA_FILES = [("", ["credentials.json", "icon.png", "menu-icon.png"])] OPTIONS = { - 'argv_emulation': False, - 'plist': {'LSUIElement': True, - 'CFBundleName': 'Vince', - 'CFBundleShortVersionString': '1.2.0', - }, - 'iconfile':'icon.png', - } - + "argv_emulation": False, + "plist": { + "LSUIElement": True, + "CFBundleName": "Vince", + "CFBundleShortVersionString": "2.0", + }, + "iconfile": "icon.png", +} setup( app=APP, data_files=DATA_FILES, - options={'py2app': OPTIONS}, - setup_requires=['py2app'], - author='Stefano Tranquillini', # Set the author name here - author_email='stefano.tranquillini@gmail.com', # Set the author email here - url='https://www.stefanotranquillini.com', # Set the project URL here - license='GNU GPL 3', # Set the project license here + options={"py2app": OPTIONS}, + setup_requires=["py2app"], + author="Stefano Tranquillini", # Set the author name here + author_email="stefano.tranquillini@gmail.com", # Set the author email here + url="https://www.stefanotranquillini.com", # Set the project URL here + license="GNU GPL 3", # Set the project license here ) diff --git a/src/vince.py b/src/vince.py index fd09a38..1c7cd3e 100644 --- a/src/vince.py +++ b/src/vince.py @@ -11,6 +11,7 @@ import random import string import re +from countdown_window import CountdownWindow from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow @@ -20,7 +21,7 @@ from bs4 import BeautifulSoup import logging import urllib.request -import time +import AppKit logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" @@ -43,6 +44,7 @@ def __init__(self): self.settings = self.load_settings() self.current_events = [] self.creds = None + self.countdown_windows = {} def _has_internet(self): try: @@ -161,6 +163,8 @@ def load_events(self): if attendee.get("self", False): if attendee["responseStatus"] == "declined": add_event = False + if "#NOVINCE" in event.get("description", ""): + add_event = False if add_event: event_url = event.get("hangoutLink", "") description = event.get("description", "") @@ -171,8 +175,9 @@ def load_events(self): else: urls.append(event_url) logging.debug( - f"{event['summary']} | {event.get('description','')} | {event_url}" + f"{event['summary']} | {event.get('description', '')} | {event_url}" ) + d_event = dict( id=id, start=start, @@ -185,7 +190,63 @@ def load_events(self): visibility=event.get("visibility", "default"), ) d_events.append(d_event) + # Add an event that ends in 5 minutes and 5 seconds + + if logging.getLogger().getEffectiveLevel() == logging.DEBUG: + now = datetime.now(pytz.utc) + end_time = now + timedelta(minutes=0, seconds=10) + d_events.append( + { + "id": "upcoming_end_event_1", + "start": now, + "end": end_time, + "summary": "Event ending soon", + "url": "", + "attendees": [], + "urls": [], + "eventType": "default", + "visibility": "default", + } + ) + start_time = end_time + timedelta(seconds=60) + end_time = start_time + timedelta(seconds=60) + d_events.append( + { + "id": "upcoming_end_event_2", + "start": start_time, + "end": end_time, + "summary": "Event ending soon 2", + "url": "", + "attendees": [], + "urls": [], + "eventType": "default", + "visibility": "default", + } + ) + start_time = end_time + timedelta(seconds=60) + end_time = start_time + timedelta(seconds=60) + d_events.append( + { + "id": "upcoming_end_event_3", + "start": start_time, + "end": end_time, + "summary": "Event ending soon3", + "url": "", + "attendees": [], + "urls": [], + "eventType": "default", + "visibility": "default", + } + ) + + # Update settings for notifications + self.settings["notifications"] = [ + {"time_left": 1, "sound": True}, + {"time_left": 0, "sound": True}, + ] + d_events = sorted(d_events, key=lambda d: d["start"]) + self.menu_items = d_events except HttpError as err: logging.debug(err) @@ -267,6 +328,9 @@ def build_menu(self): refresh_btn = rumps.MenuItem("Refresh") refresh_btn.set_callback(self.refresh_menu) self.menu.add(refresh_btn) + force_btn = rumps.MenuItem("Force popup") + force_btn.set_callback(self.force_popup) + self.menu.add(force_btn) quit_btn = rumps.MenuItem("Quit") quit_btn.set_callback(self.quit) self.menu.add(quit_btn) @@ -276,19 +340,36 @@ def _open_browser(self, urls): if app_meet := self.settings.get("app_meet", ""): if url.startswith("https://meet.google.com"): cmd = rf"open -a {app_meet} " + self._copy_link([url]) logging.debug(cmd) os.system(cmd) - return + continue webbrowser.open(url) + def _copy_link(self, urls): + for url in urls: + if url.startswith("https://meet.google.com"): + # copy the url to clipboard + pb = AppKit.NSPasteboard.generalPasteboard() + pb.clearContents() + pb.setString_forType_(url, AppKit.NSPasteboardTypeString) + return + def show_alert(self, sender): rumps.alert("no link for this event") def open_browser(self, sender): - logging.debug(self.settings.get("app_meet", "")) - if self.settings["link_opening_enabled"]: - self._open_browser(sender.urls) + event = AppKit.NSApplication.sharedApplication().currentEvent() + logging.info(event.type()) + if event.type() == 1: + logging.info("left click") + if self.settings["link_opening_enabled"]: + self._open_browser(sender.urls) + + elif event.type() == 3: + logging.info("right click") + self._copy_link(sender.urls) @rumps.clicked("Refresh Menu") def refresh_menu(self, _): @@ -301,7 +382,7 @@ def update_exiting_events(self, _): if not self.creds: return # every 60 seconds remove the events that are past. - current_datetime = datetime.now(pytz.utc) + # res = [] # for el in self.menu_items: # if el['end'] >= current_datetime: @@ -367,6 +448,7 @@ def _get_next_events(self): @rumps.timer(1) def update_bar_str(self, _): + MAX_LENGHT = 200 if not self.creds: return if self.settings["show_menu_bar"]: @@ -384,14 +466,14 @@ def update_bar_str(self, _): event["end"], current_datetime, True ) summary = event["summary"] - if not event or len(event["attendees"]) <= 1: - summary = "👤" + # if not event or len(event["attendees"]) <= 1: + # summary = "👤" if hours > 0 or minutes > 15: title += ( - f" {str_truncate(summary,20)}: {hours:02d}:{minutes:02d}" + f" {str_truncate(summary, 20)}: {hours:02d}:{minutes:02d}" ) else: - title += f" {str_truncate(summary,20)}: {hours:02d}:{minutes:02d}:{seconds:02d}" + title += f" {str_truncate(summary, 20)}: {hours:02d}:{minutes:02d}:{seconds:02d}" i_current_events += 1 # separated with comma if more than one if i_current_events < len_current_events: @@ -414,15 +496,18 @@ def update_bar_str(self, _): title += " [" for event in next_events: if not event or len(event["attendees"]) <= 1: - title += "👤 " + title += " 👤" hours, minutes = self._time_left(event["start"], current_datetime) - title += f"{str_truncate(event['summary'],20)}: in {hours:02d}:{minutes:02d}" + title += f"{str_truncate(event['summary'], 20)}: in {hours:02d}:{minutes:02d}" i_next_events += 1 if i_next_events < len_next_events: title += ", " if len_next_events: title += "]" - self.title = title + if len(title) > MAX_LENGHT: + self.title = f"..." + else: + self.title = title else: self.title = f"" else: @@ -451,7 +536,7 @@ def _str_event_menu_next(self, element): return "" @rumps.timer(1) - def send_notification_(self, _): + def send_notification(self, _): if not self.creds: return if self.settings["notifications"]: @@ -477,7 +562,12 @@ def send_notification_(self, _): message=f"I said {minute_notification} mins left", sound=notification["sound"], ) - + if event["id"] not in self.countdown_windows.keys(): + self.countdown_windows[event["id"]] = CountdownWindow( + event, parent=self + ) + self.countdown_windows[event["id"]].start_countdown() + self.countdown_windows[event["id"]].show() # and when it's over if hours == 0 and minutes == 0 and seconds == 0: rumps.notification( @@ -486,6 +576,57 @@ def send_notification_(self, _): message="It's over", sound=True, ) + if event["id"] not in self.countdown_windows.keys(): + self.countdown_windows[event["id"]] = CountdownWindow( + event, parent=self + ) + self.countdown_windows[event["id"]].start_countdown() + self.countdown_windows[event["id"]].show() + for event in self.menu_items: + hours, minutes, seconds = self._time_left( + event["start"], current_datetime, show_seconds=True + ) + + if hours == 0 and minutes == 1 and seconds == 0: + self.countdown_windows[event["id"]] = CountdownWindow( + event, parent=self + ) + self.countdown_windows[event["id"]].start_countdown() + self.countdown_windows[event["id"]].show() + + def force_popup(self, _): + current_events = self._get_current_events() + for event in current_events: + if event["id"] not in self.countdown_windows.keys(): + self.countdown_windows[event["id"]] = CountdownWindow( + event, parent=self + ) + self.countdown_windows[event["id"]].start_countdown() + self.countdown_windows[event["id"]].show() + + def arrange_countdown_windows(self): + windows = sorted( + [w for w in self.countdown_windows.values()], + key=lambda x: x.event["id"], + ) + screen = AppKit.NSScreen.mainScreen() + screen_frame = screen.frame() + window_height = 50.0 + vertical_spacing = window_height + 15.0 + y_position = screen_frame.size.height - vertical_spacing + + for window in windows: + if window.window.isVisible(): + frame = window.window.frame() + frame.origin.y = y_position + y_position -= vertical_spacing + window.window.setFrame_display_(frame, True) + + @rumps.timer(1) + def update_window_positions(self, _): + self.arrange_countdown_windows() + + # for window in self.countdown_windows.values(): @rumps.timer(1) def send_and_open_link(self, _): @@ -531,7 +672,7 @@ def dnd(self, event, reset=False): else: minutes = (event["end"] - current_datetime).seconds // 60 # set DND for minutes, note that this is already 1 min before. - pars = minutes + pars = minutes + 1 try: logging.info(f'shortcuts run "Calm Notifications" -i {pars}') os.system(f'shortcuts run "Calm Notifications" -i {pars}') @@ -667,5 +808,16 @@ def open_settings_window(self, _): if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "--debug", action="store_true", help="Set logger to debug level" + ) + args = parser.parse_args() + + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + app = Vince() app.run() From 6a83af3e7078220b417bd3a05c9a215c6867da02 Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Mon, 17 Feb 2025 08:51:36 +0100 Subject: [PATCH 08/11] adding window for popup on time left --- src/countdown_window.py | 18 +++++--- src/vince.py | 98 ++++++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/countdown_window.py b/src/countdown_window.py index 1f8311f..fb625e1 100644 --- a/src/countdown_window.py +++ b/src/countdown_window.py @@ -176,21 +176,25 @@ def timerCallback_(self, timer): # Update background color based on time if sign == "+": color = AppKit.NSColor.colorWithRed_green_blue_alpha_( - 0.5, 0.0, 0.5, 0.95 - ) # Green + 155 / 255, 89 / 255, 182 / 255, 0.95 + ) # purple elif total_seconds < 0: # Red for expired color = AppKit.NSColor.colorWithRed_green_blue_alpha_( - 0.9, 0.2, 0.2, 0.95 + 231 / 255, 76 / 255, 60 / 255, 1.0 ) # Bright red elif total_seconds <= 300 and total_seconds > 60: # Between 1 and 5 minutes color = AppKit.NSColor.colorWithRed_green_blue_alpha_( - 241 / 255, 196 / 255, 15 / 255, 0.95 + 241 / 255, 196 / 255, 15 / 255, 1.0 ) # Yellow + elif total_seconds <= 60 and total_seconds > 0: # Less than 1 minute + color = AppKit.NSColor.colorWithRed_green_blue_alpha_( + 230 / 255, 126 / 255, 34 / 255, 1.0 + ) # Orange else: # More than 5 minutes color = AppKit.NSColor.colorWithRed_green_blue_alpha_( - 46 / 255, 204 / 255, 113 / 255, 0.95 - ) + 46 / 255, 204 / 255, 113 / 255, 1.0 + ) # Green if not sign: sign = "-" if total_seconds < 0 else "" @@ -209,4 +213,4 @@ def close(self): self.window.close() if self.parent and self.event and "id" in self.event: if self.event["id"] in self.parent.countdown_windows: - del self.parent.countdown_windows[self.event["id"]] + self.parent.countdown_windows[self.event["id"]]["closed"] = True diff --git a/src/vince.py b/src/vince.py index 1c7cd3e..ed615ae 100644 --- a/src/vince.py +++ b/src/vince.py @@ -44,7 +44,7 @@ def __init__(self): self.settings = self.load_settings() self.current_events = [] self.creds = None - self.countdown_windows = {} + self.countdown_windows = {} # Format: {id: {'window': CountdownWindow, 'closed': bool}} def _has_internet(self): try: @@ -208,7 +208,7 @@ def load_events(self): "visibility": "default", } ) - start_time = end_time + timedelta(seconds=60) + start_time = end_time + timedelta(seconds=10) end_time = start_time + timedelta(seconds=60) d_events.append( { @@ -562,12 +562,6 @@ def send_notification(self, _): message=f"I said {minute_notification} mins left", sound=notification["sound"], ) - if event["id"] not in self.countdown_windows.keys(): - self.countdown_windows[event["id"]] = CountdownWindow( - event, parent=self - ) - self.countdown_windows[event["id"]].start_countdown() - self.countdown_windows[event["id"]].show() # and when it's over if hours == 0 and minutes == 0 and seconds == 0: rumps.notification( @@ -576,55 +570,57 @@ def send_notification(self, _): message="It's over", sound=True, ) - if event["id"] not in self.countdown_windows.keys(): - self.countdown_windows[event["id"]] = CountdownWindow( - event, parent=self - ) - self.countdown_windows[event["id"]].start_countdown() - self.countdown_windows[event["id"]].show() - for event in self.menu_items: - hours, minutes, seconds = self._time_left( - event["start"], current_datetime, show_seconds=True - ) - if hours == 0 and minutes == 1 and seconds == 0: - self.countdown_windows[event["id"]] = CountdownWindow( - event, parent=self - ) - self.countdown_windows[event["id"]].start_countdown() - self.countdown_windows[event["id"]].show() + @rumps.timer(1) + def popup_for_upcoming(self, _): + current_datetime = datetime.now(pytz.utc) + for event in self.menu_items: + hours, minutes, seconds = self._time_left( + event["start"], current_datetime, show_seconds=True + ) + if hours == 0 and minutes == 1 and seconds == 0: + self.countdown_windows[event["id"]] = { + "window": CountdownWindow(event, parent=self), + "closed": False, + } + self.countdown_windows[event["id"]]["window"].start_countdown() + self.countdown_windows[event["id"]]["window"].show() def force_popup(self, _): current_events = self._get_current_events() for event in current_events: - if event["id"] not in self.countdown_windows.keys(): - self.countdown_windows[event["id"]] = CountdownWindow( - event, parent=self - ) - self.countdown_windows[event["id"]].start_countdown() - self.countdown_windows[event["id"]].show() + if event["id"] in self.countdown_windows.keys(): + self.countdown_windows[event["id"]]["window"].close() - def arrange_countdown_windows(self): - windows = sorted( - [w for w in self.countdown_windows.values()], - key=lambda x: x.event["id"], - ) - screen = AppKit.NSScreen.mainScreen() - screen_frame = screen.frame() - window_height = 50.0 - vertical_spacing = window_height + 15.0 - y_position = screen_frame.size.height - vertical_spacing - - for window in windows: - if window.window.isVisible(): - frame = window.window.frame() - frame.origin.y = y_position - y_position -= vertical_spacing - window.window.setFrame_display_(frame, True) - - @rumps.timer(1) - def update_window_positions(self, _): - self.arrange_countdown_windows() + self.countdown_windows[event["id"]] = { + "window": CountdownWindow(event, parent=self), + "closed": False, + } + self.countdown_windows[event["id"]]["window"].start_countdown() + self.countdown_windows[event["id"]]["closed"] = False + self.countdown_windows[event["id"]]["window"].show() + + # def arrange_countdown_windows(self): + # windows = sorted( + # [w["window"] for w in self.countdown_windows.values()], + # key=lambda x: x.event["id"], + # ) + # screen = AppKit.NSScreen.mainScreen() + # screen_frame = screen.frame() + # window_height = 50.0 + # vertical_spacing = window_height + 15.0 + # y_position = screen_frame.size.height - vertical_spacing + + # for window in windows: + # if window.window.isVisible(): + # frame = window.window.frame() + # frame.origin.y = y_position + # y_position -= vertical_spacing + # window.window.setFrame_display_(frame, True) + + # @rumps.timer(1) + # def update_window_positions(self, _): + # self.arrange_countdown_windows() # for window in self.countdown_windows.values(): From 13f9e4a3e842e7f1332c9f7834496ebadeeb9d13 Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Mon, 17 Feb 2025 08:55:55 +0100 Subject: [PATCH 09/11] chengelog --- README.md | 23 +++++++++++++---------- changelog.md | 3 +++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 878796b..ba75e2f 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,41 @@ # Vince + Vince - Early is on time, on time is late and late is unacceptable! www.stefanotranquillini.com/vince ## Main features + ![img](https://github.com/esseti/vince/assets/1928354/a78b9221-67de-4e5a-a13d-e0a874ec5237) - Today's Meeting Showcase: -Vince conveniently displays your current events in the menu bar. It highlights events that are currently happening, accompanied by a dynamic countdown showing the time remaining in the format HH:MM:SS. + Vince conveniently displays your current events in the menu bar. It highlights events that are currently happening, accompanied by a dynamic countdown showing the time remaining in the format HH:MM:SS. - Next Meeting Countdown: -Stay one step ahead with Vince's countdown timer for your next meeting. It shows the time left until your next event, ensuring you're prepared and punctual in the format HH:MM. + Stay one step ahead with Vince's countdown timer for your next meeting. It shows the time left until your next event, ensuring you're prepared and punctual in the format HH:MM. - Real-Time Meeting Progress: -Stay engaged and in control during your current meeting. Vince keeps you informed about the exact time remaining in your ongoing meeting, allowing you to contribute confidently and manage your time efficiently. + Stay engaged and in control during your current meeting. Vince keeps you informed about the exact time remaining in your ongoing meeting, allowing you to contribute confidently and manage your time efficiently. - Comprehensive Meeting Overview: -Vince offers a comprehensive drop-down menu with a complete overview of all your scheduled meetings for the day. Each entry includes the start and end time of the meeting, along with the time left before it begins. + Vince offers a comprehensive drop-down menu with a complete overview of all your scheduled meetings for the day. Each entry includes the start and end time of the meeting, along with the time left before it begins. - One-Click Meeting Access: -If your events include a meeting link, Vince makes it effortless to join. Simply click on the event, and Vince will automatically open your preferred web browser, ensuring you never miss an important meeting. + If your events include a meeting link, Vince makes it effortless to join. Simply click on the event, and Vince will automatically open your preferred web browser, ensuring you never miss an important meeting. - Never miss a call: -When a meeting includes a Meet link for a video call, Vince takes it a step further. One minute before the start of the event, Vince automatically opens the Meet URL, ensuring you effortlessly join the meeting on time and without any delays. + When a meeting includes a Meet link for a video call, Vince takes it a step further. One minute before the start of the event, Vince automatically opens the Meet URL, ensuring you effortlessly join the meeting on time and without any delays. - Smart Meeting Notifications: -Vince provides timely notifications to keep you informed. It sends a notification one minute before a meeting starts, giving you a gentle reminder to prepare. Additionally, Vince notifies you five minutes before a meeting ends, ensuring a smooth transition to your next task. + Vince provides timely notifications to keep you informed. It sends a notification one minute before a meeting starts, giving you a gentle reminder to prepare. Additionally, Vince notifies you five minutes before a meeting ends, ensuring a smooth transition to your next task. - Meeting Completion Alerts: -When a meeting's scheduled time is over, Vince sends a notification, ensuring you stay on top of your schedule and can efficiently manage your time. + When a meeting's scheduled time is over, Vince sends a notification, ensuring you stay on top of your schedule and can efficiently manage your time. - Seamless Google Calendar Integration: -Vince seamlessly connects to your Google Calendar, providing real-time access to your events and appointments. Stay updated and never miss a beat. + Vince seamlessly connects to your Google Calendar, providing real-time access to your events and appointments. Stay updated and never miss a beat. #### NOTE + The name in honor of vince lombardi and his attitute on being on time. -Still, the quote is from "Eric Jerome Dickey." +Still, the quote is from "Eric Jerome Dickey." diff --git a/changelog.md b/changelog.md index 3efe1f5..8e3a385 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,7 @@ https://github.com/jaredks/rumps/issues/200#issuecomment-1475380684 +[2.0.0] 2025-02-17 + +- Adding popup that shows a timer. It's shown +1:00 before the meeting starts in pink, then green until 5:00 minutes left to the end of the meeting, then yellow, orange and red whe time is over. If you close the popup you can re-open it from the menu bar (only if the meeting is still going on). [1.2.0] 2024-06-05 From 0707d71f5c5d93d6673699d025058e3bff54fe3e Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Mon, 17 Feb 2025 09:00:11 +0100 Subject: [PATCH 10/11] util to build release --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8f3081c..8fc4ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,5 @@ cython_debug/ # avoiding crendtials and token.. for time being src/token.json src/*.json -.vscode \ No newline at end of file +.vscode +src/Vince.zip From eae1cb4d870fbfd8e29d686a6b016a4b69da49c5 Mon Sep 17 00:00:00 2001 From: Stefano Tranquillini Date: Mon, 17 Feb 2025 09:00:19 +0100 Subject: [PATCH 11/11] util to build release --- src/dist.fish | 3 +++ 1 file changed, 3 insertions(+) create mode 100755 src/dist.fish diff --git a/src/dist.fish b/src/dist.fish new file mode 100755 index 0000000..3cf86d9 --- /dev/null +++ b/src/dist.fish @@ -0,0 +1,3 @@ +#!/bin/bash +build.fish +cd dist && zip -r ../VInce.zip VInce.app \ No newline at end of file