#!/bin/python3 """ toggl2sheets is a script that automatically gets time entries from toggl and puts them into a google spreadsheet. toggl2sheets will only add *new* entries (since it last ran), modified entries will not be updated. A hidden spreadsheet "_toggl2sheets" will be created to keep track when was the last execution of this script. PLEASE FILL OUT ALL THE CONSTANTS JUST UNDER ALL THE IMPORT STATEMENTS OR PROVIDE APPROPRIATE ENVIRONMENT VARIABLES ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The script operates based on project names. It will put all entries that are a part of project "A" into a worksheet with with the name "A". It can create new sheets when the project worksheet does not exist, or just ignore it. (IGNORE_PROJECTS_WITHOUT_SHEET option) """ from collections import defaultdict import os from typing import Dict, List, Tuple, Any import traceback import sys import datetime import pygsheets from pygsheets.spreadsheet import Spreadsheet from pygsheets.worksheet import Worksheet import requests # ------------------------------ # To obtain a Google service account private key, follow instructions # here: https://pygsheets.readthedocs.io/en/stable/authorization.html#service-account # You will need to: # 1. create a new Google Cloud project (https://console.developers.google.com/) # 2. go to the credentials tab and make a new service account credential # - when creating a new service account, you do not need to add any roles to it # 3. go to the created service account and add a key. Click: create new private key. # You should now have the key downloaded. # 4. point the GOOGLE_SAF variable to that file # 5. got to "Enabled APIs & services" in the Google developer console and enable # "Google Sheets API" GOOGLE_SAF = os.getenv("GOOGLE_SAF", "./service.json") # To get your toggl token go to your profile page (https://track.toggl.com/profile), # scroll down, and click reveal on your API key. TOGGL_TOKEN = os.getenv("TOGGL_TOKEN", "9e5c9000000000000000000000fd2291") # To get the workspace id open a report from it in toggl. # You can see the ID in the URL TOGGL_WORKSPACE = int(os.getenv("TOGGL_WORKSPACE", "42000")) # To get your Google sheet ID, open the sheet, and copy this part of the URL: # https://docs.google.com/spreadsheets/d/1Li3djx7xbC00000000000000000iyd2zWv7jjXI_qFA/edit # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GOOGLE_SHEET = os.getenv("GOOGLE_SHEET", "1Li3djx7xbC000000v7jjXI_qFA") # Self explainatory. If set to False, then the script will create a new sheet IGNORE_PROJECTS_WITHOUT_SHEET = bool( int(os.getenv("IGNORE_PROJECTS_WITHOUT_SHEET", "1")) ) # houry wage HOURLY_WAGE = os.getenv("HOURLY_WAGE", "0,00 Kč") # ------------------------------ today = datetime.datetime.now().date() - datetime.timedelta(days=1) def append_hours(worksheet: Worksheet, data: List[List[str]]): column = worksheet.get_col(1) # get last non-empty row last_row = next(filter(lambda x: not x[1], enumerate(column)))[0] # add a calculated hours * wage cell to all rows d = [[*x, f"=C{i}*D{i}"] for i, x in enumerate(data, start=last_row + 1)] worksheet.insert_rows(last_row, len(d), d) def get_toggl_entries( token: str, work_id: int, last_edit: datetime.datetime ) -> Tuple[Dict[str, List], Any]: last_edit = last_edit.astimezone(datetime.timezone.utc) resp = requests.get( f"https://api.track.toggl.com/api/v9/workspaces/{work_id}/projects", auth=(token, "api_token"), ) if resp.status_code != 200: raise ValueError( f'Toggl responded with non-200 status code: {resp}, body: "{resp.text}"' ) projects = {p["id"]: p["name"] for p in resp.json()} entries = [] resp = requests.get( f"https://api.track.toggl.com/api/v9/me/time_entries", params={"user_agent": "toggl2sheets", "since": int(last_edit.timestamp())}, auth=(token, "api_token"), ) if resp.status_code != 200: raise ValueError( f'Toggl responded with non-200 status code: {resp}, body: "{resp.text}"' ) entries = resp.json() last_entry = next(filter(lambda x: x["duration"] > 0, entries), None) # construct a dict of d["project name"] = [[date, description, duration (hours), wage], ...] out = defaultdict(list) for entry in reversed(entries): if ( entry["duration"] <= 0 # running entry or entry["server_deleted_at"] is not None # deleted entry or entry["project_id"] not in projects # entry not from selected workspace ): continue start = datetime.datetime.fromisoformat(entry["start"].replace("Z", "+00:00")) end = datetime.datetime.fromisoformat(entry["stop"].replace("Z", "+00:00")) if end <= last_edit: continue # get better accuracy than toggl lets us in their requests out[projects[entry["project_id"]]].append( [ start.astimezone().strftime("%-d. %-m. %Y"), entry["description"], round(entry["duration"] / 60 / 60, 2), HOURLY_WAGE, ] ) return out, last_entry def get_or_create_last_edit(sh: Spreadsheet) -> datetime.datetime: try: worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore except pygsheets.WorksheetNotFound: worksheet = sh.add_worksheet("_toggl2sheets") worksheet.hidden = True worksheet.cell("A2").set_text_format("bold", True).value = ( # type: ignore "Internal record of when toggl2sheets last " "updated the spreadsheet, please do not modify" ) else: val = worksheet.get_value("A1") if val: return datetime.datetime.fromisoformat(val) return datetime.datetime.now() - datetime.timedelta(days=7) def update_last_edit(sh: Spreadsheet, last_entry: Dict[str, Any]): worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore worksheet.update_value("A1", last_entry["stop"].replace("Z", "+00:00")) def main(): gc = pygsheets.authorize(service_file=GOOGLE_SAF) try: sh = gc.open_by_key(GOOGLE_SHEET) except: raise ValueError( f"Cannot open google sheet. Do you have permission? Share sheet with \n{gc.oauth.service_account_email}" ) # get last edit time last_edit = get_or_create_last_edit(sh) entries, last_entry = get_toggl_entries(TOGGL_TOKEN, TOGGL_WORKSPACE, last_edit) for key, val in entries.items(): try: worksheet: Worksheet = sh.worksheet("title", key) # type: ignore except pygsheets.WorksheetNotFound: if not IGNORE_PROJECTS_WITHOUT_SHEET: # create a new worksheet with a header worksheet = sh.add_worksheet(key) worksheet.insert_rows( 0, 1, [["Datum", "Popis", "=SUM(C2:C)", "Hodinovka", "=SUM(E2:E)"]] ) else: continue append_hours(worksheet, val) if last_entry is not None: update_last_edit(sh, last_entry) if __name__ == "__main__": try: main() except Exception as e: stk = [ f"{s[0].split('/')[-1]}:{s[1]}:{s[2]}" for s in traceback.extract_tb(sys.exc_info()[2]) ][:5] print(f"ERROR: func stack: {stk}") print(f"ERROR: {e.__class__.__name__}: {e}") exit(1)