From 392e6768f9f60c761b65f8774c1667ab8d00f230 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Sun, 3 Jan 2021 01:14:47 +0100 Subject: gkleen@sif: systemd --- accounts/gkleen@sif/default.nix | 5 +- accounts/gkleen@sif/store.kdbx.lftp | 6 + accounts/gkleen@sif/systemd.nix | 107 +++++++ accounts/gkleen@sif/xmobar/default.nix | 7 + accounts/gkleen@sif/xmobar/nixpkgs.nix | 9 + accounts/gkleen@sif/xmobar/package.yaml | 13 + accounts/gkleen@sif/xmobar/shell.nix | 28 ++ accounts/gkleen@sif/xmobar/stack.nix | 17 ++ accounts/gkleen@sif/xmobar/stack.yaml | 10 + accounts/gkleen@sif/xmobar/stack.yaml.lock | 12 + accounts/gkleen@sif/xmobar/stackage.nix | 31 ++ accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix | 13 + accounts/gkleen@sif/xmobar/xmobar.hs | 52 ++++ overlays/worktime/default.nix | 19 ++ overlays/worktime/worktime.py | 387 ++++++++++++++++++++++++ 15 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 accounts/gkleen@sif/store.kdbx.lftp create mode 100644 accounts/gkleen@sif/systemd.nix create mode 100644 accounts/gkleen@sif/xmobar/default.nix create mode 100644 accounts/gkleen@sif/xmobar/nixpkgs.nix create mode 100644 accounts/gkleen@sif/xmobar/package.yaml create mode 100644 accounts/gkleen@sif/xmobar/shell.nix create mode 100644 accounts/gkleen@sif/xmobar/stack.nix create mode 100644 accounts/gkleen@sif/xmobar/stack.yaml create mode 100644 accounts/gkleen@sif/xmobar/stack.yaml.lock create mode 100644 accounts/gkleen@sif/xmobar/stackage.nix create mode 100644 accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix create mode 100644 accounts/gkleen@sif/xmobar/xmobar.hs create mode 100644 overlays/worktime/default.nix create mode 100755 overlays/worktime/worktime.py diff --git a/accounts/gkleen@sif/default.nix b/accounts/gkleen@sif/default.nix index 9f2df668..163e75f1 100644 --- a/accounts/gkleen@sif/default.nix +++ b/accounts/gkleen@sif/default.nix @@ -1,4 +1,4 @@ -{ flake, userName, pkgs, customUtils, lib, config, ... }: +{ flake, userName, pkgs, customUtils, lib, config, ... }@inputs: let cfg = config.home-manager.users.${userName}; xmonad = import ./xmonad pkgs.haskellPackages; @@ -159,6 +159,7 @@ in { google-play-music-desktop-player qt5ct playerctl evince thunderbird zulip zoom-us steam steam-run wireshark skype virt-manager rclone cached-nix-shell xournal discord xmonad + worktime ]; file = { @@ -177,5 +178,7 @@ in { stateVersion = "20.03"; }; + + systemd.user = import ./systemd.nix inputs; }; } diff --git a/accounts/gkleen@sif/store.kdbx.lftp b/accounts/gkleen@sif/store.kdbx.lftp new file mode 100644 index 00000000..4447aded --- /dev/null +++ b/accounts/gkleen@sif/store.kdbx.lftp @@ -0,0 +1,6 @@ +open ftp://gkleen.keepass@yggdrasil.li/ + +lcd /home/gkleen + +mirror -v --only-newer -f store.kdbx +mirror -v --reverse --only-newer -f store.kdbx \ No newline at end of file diff --git a/accounts/gkleen@sif/systemd.nix b/accounts/gkleen@sif/systemd.nix new file mode 100644 index 00000000..c6ec6f64 --- /dev/null +++ b/accounts/gkleen@sif/systemd.nix @@ -0,0 +1,107 @@ +{ pkgs, config, userName, ... }: +let + xmobar = import ./xmobar pkgs.haskellPackages; + cfg = config.home-manager.users.${userName}; +in { + services = { + "dynamic-forward@" = { + Service = { + WorkingDirectory = "~"; + ExecStart = "${pkgs.autossh}/bin/autossh -M 20000 -- -vN -o ControlMaster=no \"%I\""; + Environment = [ "AUTOSSH_POLL=30" "AUTOSSH_PIDFILE=.ssh/autossh.%i.pid" ]; + PIDFile = "~/.ssh/autossh.%i.pid"; + Restart = "on-failure"; + RestartSec = "30"; + }; + Install = { + WantedBy = ["default.target"]; + }; + }; + sync-keepass = { + Service = { + Type = "oneshot"; + WorkingDirectory = "~"; + ExecStart = "${pkgs.lftp}/bin/lftp -f ${./store.kdbx.lftp}"; + }; + }; + urxvtd = { + Service = { + Type = "simple"; + WorkingDirectory = "~"; + ExecStart = "${cfg.programs.urxvt.package}/bin/urxvtd"; + Restart = "always"; + }; + Unit = { + After = ["graphical-session.target"]; + }; + Install = { + WantedBy = ["graphical-session.target"]; + }; + }; + emacs = { + Unit = { + After = ["graphical-session-pre.target"]; + }; + }; + trayer = { + Service = { + Type = "simple"; + WorkingDirectory = "~"; + ExecStart = "${pkgs.trayer}/bin/trayer --edge top --align right --SetDockType true --SetPartialStrut true --expand true --width 8 --tint 0x000000 --alpha 0 --transparent true --height 32 --monitor primary"; + Restart = "always"; + }; + Install = { + WantedBy = ["graphical-session.target"]; + }; + }; + xmobar = { + Service = { + Type = "simple"; + WorkingDirectory = "~"; + ExecStart = "${xmobar}/bin/xmobar"; + Restart = "always"; + Environment = "PATH=${pkgs.worktime}/bin:${pkgs.openssh}/bin"; + + }; + Install = { + WantedBy = ["graphical-session.target"]; + }; + }; + dunst = { + Service = { + Restart = "always"; + }; + Install = { + WantedBy = ["graphical-session.target"]; + }; + }; + xiccd = { + Service = { + Type = "simple"; + WorkingDirectory = "~"; + ExecStart = "${pkgs.xiccd}/bin/xiccd"; + Restart = "always"; + }; + }; + }; + timers = { + sync-keepass = { + Timer = { + OnActiveSec = "1m"; + OnUnitActiveSec = "1m"; + }; + + Install = { + WantedBy = ["default.target"]; + }; + }; + }; + targets = { + graphical-session = { + Unit = { + BindsTo = ["default.target"]; + After = ["basic.target"]; + }; + }; + }; +} diff --git a/accounts/gkleen@sif/xmobar/default.nix b/accounts/gkleen@sif/xmobar/default.nix new file mode 100644 index 00000000..fcac5e60 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/default.nix @@ -0,0 +1,7 @@ +argumentPackages@{ ... }: + +let + # defaultPackages = (import ./stackage.nix {}); + # haskellPackages = defaultPackages // argumentPackages; + haskellPackages = argumentPackages; +in haskellPackages.callPackage ./xmobar-yggdrasil.nix {} diff --git a/accounts/gkleen@sif/xmobar/nixpkgs.nix b/accounts/gkleen@sif/xmobar/nixpkgs.nix new file mode 100644 index 00000000..783ede00 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/nixpkgs.nix @@ -0,0 +1,9 @@ +{ nixpkgs ? import +}: + +import ((nixpkgs {}).fetchFromGitHub { + owner = "NixOS"; + repo = "nixpkgs"; + rev = "10e61bf5be57736035ec7a804cb0bf3d083bf2cf"; + sha256 = "0fplfm2zx4vk7gs8bdcxnvzkdmpx2w0llqwf8475z9dz9cl132rm"; +}) diff --git a/accounts/gkleen@sif/xmobar/package.yaml b/accounts/gkleen@sif/xmobar/package.yaml new file mode 100644 index 00000000..b638b6ac --- /dev/null +++ b/accounts/gkleen@sif/xmobar/package.yaml @@ -0,0 +1,13 @@ +name: xmobar-yggdrasil + +executables: + xmobar: + dependencies: + - base + - xmobar + + main: xmobar.hs + source-dirs: + - . + + ghc-options: -threaded diff --git a/accounts/gkleen@sif/xmobar/shell.nix b/accounts/gkleen@sif/xmobar/shell.nix new file mode 100644 index 00000000..18188e78 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/shell.nix @@ -0,0 +1,28 @@ +{ nixpkgs ? import ./nixpkgs.nix {} }: + +let + inherit (nixpkgs {}) pkgs; + haskellPackages = import ./stackage.nix { inherit nixpkgs; }; + + drv = haskellPackages.callPackage ./xmobar-yggdrasil.nix {}; + override = oldAttrs: { + nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; []) ++ (with haskellPackages; [ stack cabal-install cabal2nix ]); + shellHook = '' + export PROMPT_INFO="${oldAttrs.name}" + + if [ -n "$ZSH_VERSION" ]; then + autoload -U +X compinit && compinit + autoload -U +X bashcompinit && bashcompinit + fi + eval "$(stack --bash-completion-script stack)" + + ${oldAttrs.shellHook} + ''; + }; + + dummy = pkgs.stdenv.mkDerivation { + name = "interactive-xmobar-environment"; + shellHook = ""; + }; +in pkgs.stdenv.lib.overrideDerivation dummy override + #pkgs.stdenv.lib.overrideDerivation drv.env override diff --git a/accounts/gkleen@sif/xmobar/stack.nix b/accounts/gkleen@sif/xmobar/stack.nix new file mode 100644 index 00000000..17a49e04 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/stack.nix @@ -0,0 +1,17 @@ +{ ghc, nixpkgs ? import ./nixpkgs.nix {} }: + +let + haskellPackages = import ./stackage.nix { inherit nixpkgs; }; + inherit (nixpkgs {}) pkgs; +in pkgs.haskell.lib.buildStackProject { + inherit ghc; + inherit (haskellPackages) stack; + name = "stackenv"; + buildInputs = (with pkgs; + [ xorg.libX11 xorg.libXrandr xorg.libXinerama xorg.libXScrnSaver xorg.libXext xorg.libXft + cairo + glib + ]) ++ (with haskellPackages; + [ + ]); +} diff --git a/accounts/gkleen@sif/xmobar/stack.yaml b/accounts/gkleen@sif/xmobar/stack.yaml new file mode 100644 index 00000000..b8ed1147 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/stack.yaml @@ -0,0 +1,10 @@ +nix: + enable: true + shell-file: stack.nix + +resolver: lts-13.21 + +packages: + - . + +extra-deps: [] diff --git a/accounts/gkleen@sif/xmobar/stack.yaml.lock b/accounts/gkleen@sif/xmobar/stack.yaml.lock new file mode 100644 index 00000000..fcc2f5f3 --- /dev/null +++ b/accounts/gkleen@sif/xmobar/stack.yaml.lock @@ -0,0 +1,12 @@ +# This file was autogenerated by Stack. +# You should not edit this file by hand. +# For more information, please see the documentation at: +# https://docs.haskellstack.org/en/stable/lock_files + +packages: [] +snapshots: +- completed: + size: 498180 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/13/21.yaml + sha256: eff2de19a6d4691ccbf6edc1fba858f1918683047dce0f09adede874bbd2a8f3 + original: lts-13.21 diff --git a/accounts/gkleen@sif/xmobar/stackage.nix b/accounts/gkleen@sif/xmobar/stackage.nix new file mode 100644 index 00000000..c162ca2c --- /dev/null +++ b/accounts/gkleen@sif/xmobar/stackage.nix @@ -0,0 +1,31 @@ +{ nixpkgs ? import ./nixpkgs.nix {} +, snapshot ? "lts-13.21" +}: + +let + stackage = import (fetchTarball { + url = "https://stackage.serokell.io/zb36jsy3r5h4ydz0pnp00g9vk94dvv03-stackage/default.nix.tar.gz"; + sha256 = "0h6f80gds0ds77y51hhiadh2h2k8njqq8n0gayp729ana9m9agma"; + }); + + overlays = + [ stackage."${snapshot}" + (self: super: { + haskell = super.haskell // { + packages = super.haskell.packages // { + "${snapshot}" = super.haskell.packages."${snapshot}".override { + overrides = hself: hsuper: { + zip-archive = self.haskell.lib.overrideCabal hsuper.zip-archive (old: { + testToolDepends = old.testToolDepends ++ (with self; [ unzip which ]); + }); + alex = self.haskell.lib.dontCheck hsuper.alex; + }; + }; + }; + }; + } + ) + ]; + + inherit (nixpkgs { inherit overlays; }) pkgs; +in pkgs.haskell.packages."${snapshot}" diff --git a/accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix b/accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix new file mode 100644 index 00000000..1dfc619b --- /dev/null +++ b/accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix @@ -0,0 +1,13 @@ +{ mkDerivation, base, hpack, stdenv, xmobar }: +mkDerivation { + pname = "xmobar-yggdrasil"; + version = "0.0.0"; + src = ./.; + isLibrary = false; + isExecutable = true; + libraryToolDepends = [ hpack ]; + executableHaskellDepends = [ base xmobar ]; + preConfigure = "hpack"; + license = "unknown"; + hydraPlatforms = stdenv.lib.platforms.none; +} diff --git a/accounts/gkleen@sif/xmobar/xmobar.hs b/accounts/gkleen@sif/xmobar/xmobar.hs new file mode 100644 index 00000000..ea53082d --- /dev/null +++ b/accounts/gkleen@sif/xmobar/xmobar.hs @@ -0,0 +1,52 @@ +import Xmobar + +import Data.List (intercalate) + + +main :: IO () +main = xmobar config + where + config = defaultConfig + { font = "xft:Fira Mono for Powerline:style=Medium:pixelsize=22.5" + , position = OnScreen 0 $ TopP 0 307 + , bgColor = "black" + , fgColor = "grey" + , overrideRedirect = False + , template = + let left = intercalate " | " + [ "%XMonadWorkspaces%" + , "%XMonadLayout%" + , "%XMonadTitle%" + ] + right = intercalate " | " + [ {- "%status%" + , -} "%battery%" + , "%kbd%" + , "%worktime%" + , "%worktime-today%" + , "%time%" + , "%date%" + ] + in left <> "}{" <> right + , commands = + [ Run $ NamedXPropertyLog "_XMONAD_WORKSPACES" "XMonadWorkspaces" + , Run $ NamedXPropertyLog "_XMONAD_LAYOUT" "XMonadLayout" + , Run $ NamedXPropertyLog "_XMONAD_TITLE" "XMonadTitle" + , Run $ Date "%H:%M" "time" 50 + , Run $ Date "%a %b %_d" "date" 50 + , Run $ Com "worktime" [] "worktime" 1500 + , Run $ Com "worktime" ["today"] "worktime-today" 1500 + , Run $ Com "ssh" ["status.odin"] "status" 600 + , Run $ Kbd [("us(dvp)", "dvp")] + , Run $ Battery + [ "--template", " () AC " + , "--suffix", "On" + , "--Low", "10" + , "--High", "80" + , "--low", "darkred" + , "--normal", "darkorange" + , "--high", "darkgreen" + , "-p", "3" + ] 50 + ] + } diff --git a/overlays/worktime/default.nix b/overlays/worktime/default.nix new file mode 100644 index 00000000..26e1dfed --- /dev/null +++ b/overlays/worktime/default.nix @@ -0,0 +1,19 @@ +final: prev: { + worktime = prev.stdenv.mkDerivation rec { + name = "worktime"; + src = ./worktime.py; + + phases = [ "buildPhase" "installPhase" ]; + + python = prev.python37.withPackages (ps: with ps; [pyxdg dateutil uritools requests configparser]); + + buildPhase = '' + substituteAll $src worktime + ''; + + installPhase = '' + install -m 0755 -D -t $out/bin \ + worktime + ''; + }; +} diff --git a/overlays/worktime/worktime.py b/overlays/worktime/worktime.py new file mode 100755 index 00000000..9e514e65 --- /dev/null +++ b/overlays/worktime/worktime.py @@ -0,0 +1,387 @@ +#!@python@/bin/python + +import requests +from requests.exceptions import HTTPError +from requests.auth import HTTPBasicAuth +from datetime import * +from xdg import (BaseDirectory) +import configparser +from uritools import uricompose + +from dateutil.easter import * +from dateutil.tz import * +from dateutil.parser import isoparse + +from enum import Enum + +from math import (copysign, ceil) + +import calendar + +import argparse + +from copy import deepcopy + +class TogglAPISection(Enum): + TOGGL = '/api/v8' + REPORTS = '/reports/api/v2' + +class TogglAPIError(Exception): + def __init__(self, http_error, response): + self.http_error = http_error + self.response = response + + def __str__(self): + if not self.http_error is None: + return str(self.http_error) + else: + return self.response.text + +class TogglAPI(object): + def __init__(self, api_token, workspace_id): + self._api_token = api_token + self._workspace_id = workspace_id + + def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}): + if api is TogglAPISection.REPORTS: + params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id}) + + api_path = api.value + section_path = '/'.join(section) + uri = uricompose(scheme='https', host='www.toggl.com', path=f"{api_path}/{section_path}", query=params) + + return uri + + def _query(self, url, method): + + headers = {'content-type': 'application/json'} + response = None + + if method == 'GET': + response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) + elif method == 'POST': + response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) + else: + raise ValueError(f"Undefined HTTP method “{method}”") + + response.raise_for_status() + + return response + + def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): + url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params={'since': start_date.astimezone(timezone.utc).isoformat(), 'until': end_date.astimezone(timezone.utc).isoformat(), 'rounding': rounding}) + r = self._query(url = url, method='GET') + if not r or not r.json(): + raise TogglAPIError(r) + + return timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0) + + def get_running_clock(self, now=datetime.now(timezone.utc)): + url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current']) + r = self._query(url = url, method='GET') + + if not r or not r.json(): + raise TogglAPIError(r) + + if not r.json()['data'] or not r.json()['data']['billable']: + return None + + start = isoparse(r.json()['data']['start']) + + return now - start if start <= now else None + +class Worktime(object): + time_worked = timedelta() + running_entry = None + now = datetime.now(tzlocal()) + time_pulled_forward = timedelta() + is_workday = False + include_running = True + time_to_work = None + force_day_to_work = True + + def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): + self.include_running = include_running + self.force_day_to_work = force_day_to_work + + if now: + self.now = now + + config = configparser.ConfigParser() + config_dir = BaseDirectory.load_first_config('worktime') + config.read(f"{config_dir}/worktime.ini") + api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace']) + date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') + + start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal()) + end_date = end_datetime or self.now + + try: + with open(f"{config_dir}/reset", 'r') as reset: + for line in reset: + stripped_line = line.strip() + reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()) + + if reset_date > start_date and reset_date < end_date: + start_date = reset_date + except IOError as e: + if e.errno != 2: + raise e + + + hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) + workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) + time_per_day = timedelta(hours = hours_per_week) / len(workdays) + + holidays = dict() + + for year in range(start_date.year, end_date.year + 1): + y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal()) + + # Legal holidays in munich, bavaria + holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = time_per_day + holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = time_per_day + holidays[(y_easter+timedelta(days=-2)).date()] = time_per_day + holidays[(y_easter+timedelta(days=+1)).date()] = time_per_day + holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = time_per_day + holidays[(y_easter+timedelta(days=+39)).date()] = time_per_day + holidays[(y_easter+timedelta(days=+50)).date()] = time_per_day + holidays[(y_easter+timedelta(days=+60)).date()] = time_per_day + holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = time_per_day + holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = time_per_day + holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = time_per_day + holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = time_per_day + holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = time_per_day + + try: + with open(f"{config_dir}/excused", 'r') as excused: + for line in excused: + stripped_line = line.strip() + if stripped_line: + splitLine = stripped_line.split(' ') + if len(splitLine) == 2: + [hours, date] = splitLine + day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date() + holidays[day] = timedelta(hours = float(hours)) + else: + holidays[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day + except IOError as e: + if e.errno != 2: + raise e + + pull_forward = dict() + + start_day = start_date.date() + end_day = end_date.date() + + try: + with open(f"{config_dir}/pull-forward", 'r') as excused: + for line in excused: + stripped_line = line.strip() + if stripped_line: + [hours, date] = stripped_line.split(' ') + constr = date.split(',') + for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(workdays)) * 2))]: + for c in constr: + if c in calendar.day_abbr: + if not d.strftime('%a') == c: break + elif "--" in c: + [fromDay,toDay] = c.split('--') + if fromDay != "": + fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date() + if not fromDay <= d: break + if toDay != "": + toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date() + if not d <= toDay: break + else: + if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break + else: + if d >= end_date.date(): + pull_forward[d] = min(timedelta(hours = float(hours)), time_per_day - (holidays[d] if d in holidays else timedelta())) + except IOError as e: + if e.errno != 2: + raise e + + days_to_work = dict() + + if pull_forward: + end_day = max(end_day, max(list(pull_forward))) + + for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: + if day.isoweekday() in workdays: + time_to_work = time_per_day + if day in holidays.keys(): + time_to_work -= holidays[day] + if time_to_work > timedelta(): + days_to_work[day] = time_to_work + + extra_days_to_work = dict() + + try: + with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file: + for line in extra_days_to_work_file: + stripped_line = line.strip() + if stripped_line: + splitLine = stripped_line.split(' ') + if len(splitLine) == 2: + [hours, date] = splitLine + day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date() + extra_days_to_work[day] = timedelta(hours = float(hours)) + else: + extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day + except IOError as e: + if e.errno != 2: + raise e + + + self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work + + self.time_worked = timedelta() + + if self.include_running: + self.running_entry = api.get_running_clock(self.now) + + if self.running_entry: + self.time_worked += self.running_entry + + if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work): + extra_days_to_work[self.now.date()] = timedelta() + + self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta()) + for day in [d for d in list(pull_forward) if d > end_date.date()]: + days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) + extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) + days_forward = days_forward.union(extra_days_forward) + + extra_day_time_left = timedelta() + for extra_day in extra_days_forward: + day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day]) + extra_day_time_left += day_time + extra_day_time = min(extra_day_time_left, pull_forward[day]) + time_forward = pull_forward[day] - extra_day_time + if extra_day_time_left > timedelta(): + for extra_day in extra_days_forward: + day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day]) + extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left) + + hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta() + days_forward.discard(end_date.date()) + + self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward) + + if end_date.date() in extra_days_to_work: + self.time_pulled_forward += extra_days_to_work[end_date.date()] + + self.time_to_work += self.time_pulled_forward + + self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True)) + +def worktime(**args): + worktime = Worktime(**args) + + def format_worktime(worktime): + def difference_string(difference): + total_minutes_difference = round(difference / timedelta(minutes = 1)) + (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) + sign = '' if total_minutes_difference >= 0 else '-' + + difference_string = f"{sign}" + if hours_difference != 0: + difference_string += f"{hours_difference}h" + if hours_difference == 0 or minutes_difference != 0: + difference_string += f"{minutes_difference}m" + + return difference_string + + difference = worktime.time_to_work - worktime.time_worked + total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5)) + + if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) : + clockout_time = worktime.now + difference + clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) + clockout_time = clockout_time.replace(second = 0, microsecond = 0) + + if total_minutes_difference >= 0: + difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) + return "{difference_string}/{clockout_time}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")) + else: + difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) + return "{clockout_time}/{difference_string}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")) + else: + if worktime.running_entry: + difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) + indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6' + + return f"{indicator}{difference_string}" + else: + difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) + if worktime.is_workday: + return difference_string + else: + return f"({difference_string})" + + if worktime.time_pulled_forward >= timedelta(minutes = 15): + worktime_no_pulled_forward = deepcopy(worktime) + worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward + worktime_no_pulled_forward.time_pulled_forward = timedelta() + + difference_string = format_worktime(worktime) + difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward) + + print(f"{difference_string_no_pulled_forward}…{difference_string}") + else: + print(format_worktime(worktime)) + +def time_worked(now, **args): + then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) + if now.time() == time(): + now = now + timedelta(days = 1) + + then = Worktime(**dict(args, now = then)) + now = Worktime(**dict(args, now = now)) + + worked = now.time_worked - then.time_worked + + if args['do_round']: + total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5)) + (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) + sign = '' if total_minutes_difference >= 0 else '-' + + difference_string = f"{sign}" + if hours_difference != 0: + difference_string += f"{hours_difference}h" + if hours_difference == 0 or minutes_difference != 0: + difference_string += f"{minutes_difference}m" + + print(difference_string) + else: + print(worked) + +def diff(now, **args): + now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) + then = now - timedelta.resolution + + then = Worktime(**dict(args, now = then, include_running = False)) + now = Worktime(**dict(args, now = now, include_running = False)) + + print(now.time_to_work - then.time_to_work) + + +def main(): + parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') + parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) + parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') + parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') + subparsers = parser.add_subparsers(help = 'Subcommands') + parser.set_defaults(cmd = worktime) + time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today']) + time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false') + time_worked_parser.set_defaults(cmd = time_worked) + diff_parser = subparsers.add_parser('diff') + diff_parser.set_defaults(cmd = diff) + args = parser.parse_args() + + args.cmd(**vars(args)) + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3