toggl2sheets

toggl entries to google sheets
git clone git://git.bain.cz/toggl2sheets.git
Log | Files | Refs

commit 58c2df8faa8ae575d4e03d22b380268d6c6e81e7
Author: bain <bain@bain.cz>
Date:   Thu, 11 Aug 2022 23:11:21 +0200

initial commit

Diffstat:
Arequirements.txt | 2++
Atoggl2sheets.py | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 200 insertions(+), 0 deletions(-)

diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,2 @@ +pygsheets~=2.0.5 +requests~=2.27.1 diff --git a/toggl2sheets.py b/toggl2sheets.py @@ -0,0 +1,198 @@ +#!/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]]): + # add a calculated hours * wage cell to all rows + # start at number 2 to skip header + d = [[*x, f"=C{i}*D{i}"] for i, x in enumerate(data, start=2)] + column = worksheet.get_col(1) + + # get last non-empty row + last_row = next(filter(lambda x: not x[1], enumerate(column)))[0] + + 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)