#!/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 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) # inserting rows changes the top sums worksheet.update_value("C1", f"=SUM(C2:C{last_row+len(d)})", True) worksheet.update_value("E1", f"=SUM(E2:E{last_row+len(d)})", True) def get_toggl_entries( token: str, work_id: int, last_edit: datetime.datetime ) -> Dict[str, List]: entries = [] page = 1 count = 0 total_count = 1 # 0 < 1, so we will certainly make at least one request while count < total_count: resp = requests.get( "https://api.track.toggl.com/reports/api/v2/details", params={ "workspace_id": f"{work_id}", "user_agent": "autohours", "since": last_edit.strftime("%Y-%m-%d"), "page": page, "order_desc": "off", }, auth=(token, "api_token"), ) if resp.status_code != 200: raise ValueError( f"Toggl sent a non 200 response: {resp.status_code}, {resp.text}" ) json = resp.json() entries.extend(json["data"]) total_count = json["total_count"] count += json["per_page"] page += 1 # construct a dict of d["project name"] = [[date, description, duration (hours), wage], ...] out = defaultdict(list) for entry in entries: start = datetime.datetime.fromisoformat(entry["start"]) end = datetime.datetime.fromisoformat(entry["end"]) if end < last_edit: continue # get better accuracy than toggl lets us in their requests out[entry["project"]].append( [ start.strftime("%-d. %-m. %Y"), entry["description"], round(entry["dur"] / 1000 / 60 / 60, 2), HOURLY_WAGE, ] ) return out def get_or_create_last_edit(sh: Spreadsheet) -> datetime.datetime: try: worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore except pygsheets.WorksheetNotFound: time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) worksheet = sh.add_worksheet("_toggl2sheets") worksheet.hidden = True worksheet.update_value("A1", time.isoformat()) worksheet.cell("A2").set_text_format("bold", True).value = ( # type: ignore "Internal record of when toggl2sheets last " "updated the spreadsheet, please do not modify" ) return time else: val = worksheet.get_value("A1") return datetime.datetime.fromisoformat(val) def update_last_edit(sh: Spreadsheet): worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore time = datetime.datetime.now(datetime.timezone.utc) worksheet.update_value("A1", time.isoformat()) 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 = 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", "0", "Hodinovka", "0,0 Kč"]] ) else: continue append_hours(worksheet, val) update_last_edit(sh) if __name__ == "__main__": try: main() except Exception as e: print("ERROR:", str(e)) exit(1)