summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--accounts/gkleen@sif/default.nix5
-rw-r--r--accounts/gkleen@sif/store.kdbx.lftp6
-rw-r--r--accounts/gkleen@sif/systemd.nix107
-rw-r--r--accounts/gkleen@sif/xmobar/default.nix7
-rw-r--r--accounts/gkleen@sif/xmobar/nixpkgs.nix9
-rw-r--r--accounts/gkleen@sif/xmobar/package.yaml13
-rw-r--r--accounts/gkleen@sif/xmobar/shell.nix28
-rw-r--r--accounts/gkleen@sif/xmobar/stack.nix17
-rw-r--r--accounts/gkleen@sif/xmobar/stack.yaml10
-rw-r--r--accounts/gkleen@sif/xmobar/stack.yaml.lock12
-rw-r--r--accounts/gkleen@sif/xmobar/stackage.nix31
-rw-r--r--accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix13
-rw-r--r--accounts/gkleen@sif/xmobar/xmobar.hs52
-rw-r--r--overlays/worktime/default.nix19
-rwxr-xr-xoverlays/worktime/worktime.py387
15 files changed, 715 insertions, 1 deletions
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 @@
1{ flake, userName, pkgs, customUtils, lib, config, ... }: 1{ flake, userName, pkgs, customUtils, lib, config, ... }@inputs:
2let 2let
3 cfg = config.home-manager.users.${userName}; 3 cfg = config.home-manager.users.${userName};
4 xmonad = import ./xmonad pkgs.haskellPackages; 4 xmonad = import ./xmonad pkgs.haskellPackages;
@@ -159,6 +159,7 @@ in {
159 google-play-music-desktop-player qt5ct playerctl evince 159 google-play-music-desktop-player qt5ct playerctl evince
160 thunderbird zulip zoom-us steam steam-run wireshark skype 160 thunderbird zulip zoom-us steam steam-run wireshark skype
161 virt-manager rclone cached-nix-shell xournal discord xmonad 161 virt-manager rclone cached-nix-shell xournal discord xmonad
162 worktime
162 ]; 163 ];
163 164
164 file = { 165 file = {
@@ -177,5 +178,7 @@ in {
177 178
178 stateVersion = "20.03"; 179 stateVersion = "20.03";
179 }; 180 };
181
182 systemd.user = import ./systemd.nix inputs;
180 }; 183 };
181} 184}
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 @@
1open ftp://gkleen.keepass@yggdrasil.li/
2
3lcd /home/gkleen
4
5mirror -v --only-newer -f store.kdbx
6mirror -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 @@
1{ pkgs, config, userName, ... }:
2let
3 xmobar = import ./xmobar pkgs.haskellPackages;
4 cfg = config.home-manager.users.${userName};
5in {
6 services = {
7 "dynamic-forward@" = {
8 Service = {
9 WorkingDirectory = "~";
10 ExecStart = "${pkgs.autossh}/bin/autossh -M 20000 -- -vN -o ControlMaster=no \"%I\"";
11 Environment = [ "AUTOSSH_POLL=30" "AUTOSSH_PIDFILE=.ssh/autossh.%i.pid" ];
12 PIDFile = "~/.ssh/autossh.%i.pid";
13 Restart = "on-failure";
14 RestartSec = "30";
15 };
16 Install = {
17 WantedBy = ["default.target"];
18 };
19 };
20 sync-keepass = {
21 Service = {
22 Type = "oneshot";
23 WorkingDirectory = "~";
24 ExecStart = "${pkgs.lftp}/bin/lftp -f ${./store.kdbx.lftp}";
25 };
26 };
27 urxvtd = {
28 Service = {
29 Type = "simple";
30 WorkingDirectory = "~";
31 ExecStart = "${cfg.programs.urxvt.package}/bin/urxvtd";
32 Restart = "always";
33 };
34 Unit = {
35 After = ["graphical-session.target"];
36 };
37 Install = {
38 WantedBy = ["graphical-session.target"];
39 };
40 };
41 emacs = {
42 Unit = {
43 After = ["graphical-session-pre.target"];
44 };
45 };
46 trayer = {
47 Service = {
48 Type = "simple";
49 WorkingDirectory = "~";
50 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";
51 Restart = "always";
52 };
53 Install = {
54 WantedBy = ["graphical-session.target"];
55 };
56 };
57 xmobar = {
58 Service = {
59 Type = "simple";
60 WorkingDirectory = "~";
61 ExecStart = "${xmobar}/bin/xmobar";
62 Restart = "always";
63 Environment = "PATH=${pkgs.worktime}/bin:${pkgs.openssh}/bin";
64
65 };
66 Install = {
67 WantedBy = ["graphical-session.target"];
68 };
69 };
70 dunst = {
71 Service = {
72 Restart = "always";
73 };
74 Install = {
75 WantedBy = ["graphical-session.target"];
76 };
77 };
78 xiccd = {
79 Service = {
80 Type = "simple";
81 WorkingDirectory = "~";
82 ExecStart = "${pkgs.xiccd}/bin/xiccd";
83 Restart = "always";
84 };
85 };
86 };
87 timers = {
88 sync-keepass = {
89 Timer = {
90 OnActiveSec = "1m";
91 OnUnitActiveSec = "1m";
92 };
93
94 Install = {
95 WantedBy = ["default.target"];
96 };
97 };
98 };
99 targets = {
100 graphical-session = {
101 Unit = {
102 BindsTo = ["default.target"];
103 After = ["basic.target"];
104 };
105 };
106 };
107}
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 @@
1argumentPackages@{ ... }:
2
3let
4 # defaultPackages = (import ./stackage.nix {});
5 # haskellPackages = defaultPackages // argumentPackages;
6 haskellPackages = argumentPackages;
7in 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 @@
1{ nixpkgs ? import <nixpkgs>
2}:
3
4import ((nixpkgs {}).fetchFromGitHub {
5 owner = "NixOS";
6 repo = "nixpkgs";
7 rev = "10e61bf5be57736035ec7a804cb0bf3d083bf2cf";
8 sha256 = "0fplfm2zx4vk7gs8bdcxnvzkdmpx2w0llqwf8475z9dz9cl132rm";
9})
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 @@
1name: xmobar-yggdrasil
2
3executables:
4 xmobar:
5 dependencies:
6 - base
7 - xmobar
8
9 main: xmobar.hs
10 source-dirs:
11 - .
12
13 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 @@
1{ nixpkgs ? import ./nixpkgs.nix {} }:
2
3let
4 inherit (nixpkgs {}) pkgs;
5 haskellPackages = import ./stackage.nix { inherit nixpkgs; };
6
7 drv = haskellPackages.callPackage ./xmobar-yggdrasil.nix {};
8 override = oldAttrs: {
9 nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; []) ++ (with haskellPackages; [ stack cabal-install cabal2nix ]);
10 shellHook = ''
11 export PROMPT_INFO="${oldAttrs.name}"
12
13 if [ -n "$ZSH_VERSION" ]; then
14 autoload -U +X compinit && compinit
15 autoload -U +X bashcompinit && bashcompinit
16 fi
17 eval "$(stack --bash-completion-script stack)"
18
19 ${oldAttrs.shellHook}
20 '';
21 };
22
23 dummy = pkgs.stdenv.mkDerivation {
24 name = "interactive-xmobar-environment";
25 shellHook = "";
26 };
27in pkgs.stdenv.lib.overrideDerivation dummy override
28 #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 @@
1{ ghc, nixpkgs ? import ./nixpkgs.nix {} }:
2
3let
4 haskellPackages = import ./stackage.nix { inherit nixpkgs; };
5 inherit (nixpkgs {}) pkgs;
6in pkgs.haskell.lib.buildStackProject {
7 inherit ghc;
8 inherit (haskellPackages) stack;
9 name = "stackenv";
10 buildInputs = (with pkgs;
11 [ xorg.libX11 xorg.libXrandr xorg.libXinerama xorg.libXScrnSaver xorg.libXext xorg.libXft
12 cairo
13 glib
14 ]) ++ (with haskellPackages;
15 [
16 ]);
17}
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 @@
1nix:
2 enable: true
3 shell-file: stack.nix
4
5resolver: lts-13.21
6
7packages:
8 - .
9
10extra-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 @@
1# This file was autogenerated by Stack.
2# You should not edit this file by hand.
3# For more information, please see the documentation at:
4# https://docs.haskellstack.org/en/stable/lock_files
5
6packages: []
7snapshots:
8- completed:
9 size: 498180
10 url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/13/21.yaml
11 sha256: eff2de19a6d4691ccbf6edc1fba858f1918683047dce0f09adede874bbd2a8f3
12 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 @@
1{ nixpkgs ? import ./nixpkgs.nix {}
2, snapshot ? "lts-13.21"
3}:
4
5let
6 stackage = import (fetchTarball {
7 url = "https://stackage.serokell.io/zb36jsy3r5h4ydz0pnp00g9vk94dvv03-stackage/default.nix.tar.gz";
8 sha256 = "0h6f80gds0ds77y51hhiadh2h2k8njqq8n0gayp729ana9m9agma";
9 });
10
11 overlays =
12 [ stackage."${snapshot}"
13 (self: super: {
14 haskell = super.haskell // {
15 packages = super.haskell.packages // {
16 "${snapshot}" = super.haskell.packages."${snapshot}".override {
17 overrides = hself: hsuper: {
18 zip-archive = self.haskell.lib.overrideCabal hsuper.zip-archive (old: {
19 testToolDepends = old.testToolDepends ++ (with self; [ unzip which ]);
20 });
21 alex = self.haskell.lib.dontCheck hsuper.alex;
22 };
23 };
24 };
25 };
26 }
27 )
28 ];
29
30 inherit (nixpkgs { inherit overlays; }) pkgs;
31in 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 @@
1{ mkDerivation, base, hpack, stdenv, xmobar }:
2mkDerivation {
3 pname = "xmobar-yggdrasil";
4 version = "0.0.0";
5 src = ./.;
6 isLibrary = false;
7 isExecutable = true;
8 libraryToolDepends = [ hpack ];
9 executableHaskellDepends = [ base xmobar ];
10 preConfigure = "hpack";
11 license = "unknown";
12 hydraPlatforms = stdenv.lib.platforms.none;
13}
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 @@
1import Xmobar
2
3import Data.List (intercalate)
4
5
6main :: IO ()
7main = xmobar config
8 where
9 config = defaultConfig
10 { font = "xft:Fira Mono for Powerline:style=Medium:pixelsize=22.5"
11 , position = OnScreen 0 $ TopP 0 307
12 , bgColor = "black"
13 , fgColor = "grey"
14 , overrideRedirect = False
15 , template =
16 let left = intercalate " | "
17 [ "%XMonadWorkspaces%"
18 , "%XMonadLayout%"
19 , "%XMonadTitle%"
20 ]
21 right = intercalate " | "
22 [ {- "%status%"
23 , -} "%battery%"
24 , "%kbd%"
25 , "%worktime%"
26 , "%worktime-today%"
27 , "%time%"
28 , "%date%"
29 ]
30 in left <> "}{" <> right
31 , commands =
32 [ Run $ NamedXPropertyLog "_XMONAD_WORKSPACES" "XMonadWorkspaces"
33 , Run $ NamedXPropertyLog "_XMONAD_LAYOUT" "XMonadLayout"
34 , Run $ NamedXPropertyLog "_XMONAD_TITLE" "XMonadTitle"
35 , Run $ Date "%H:%M" "time" 50
36 , Run $ Date "%a %b %_d" "date" 50
37 , Run $ Com "worktime" [] "worktime" 1500
38 , Run $ Com "worktime" ["today"] "worktime-today" 1500
39 , Run $ Com "ssh" ["status.odin"] "status" 600
40 , Run $ Kbd [("us(dvp)", "dvp")]
41 , Run $ Battery
42 [ "--template", "<watts> <left> (<timeleft>) AC <acstatus>"
43 , "--suffix", "On"
44 , "--Low", "10"
45 , "--High", "80"
46 , "--low", "darkred"
47 , "--normal", "darkorange"
48 , "--high", "darkgreen"
49 , "-p", "3"
50 ] 50
51 ]
52 }
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 @@
1final: prev: {
2 worktime = prev.stdenv.mkDerivation rec {
3 name = "worktime";
4 src = ./worktime.py;
5
6 phases = [ "buildPhase" "installPhase" ];
7
8 python = prev.python37.withPackages (ps: with ps; [pyxdg dateutil uritools requests configparser]);
9
10 buildPhase = ''
11 substituteAll $src worktime
12 '';
13
14 installPhase = ''
15 install -m 0755 -D -t $out/bin \
16 worktime
17 '';
18 };
19}
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 @@
1#!@python@/bin/python
2
3import requests
4from requests.exceptions import HTTPError
5from requests.auth import HTTPBasicAuth
6from datetime import *
7from xdg import (BaseDirectory)
8import configparser
9from uritools import uricompose
10
11from dateutil.easter import *
12from dateutil.tz import *
13from dateutil.parser import isoparse
14
15from enum import Enum
16
17from math import (copysign, ceil)
18
19import calendar
20
21import argparse
22
23from copy import deepcopy
24
25class TogglAPISection(Enum):
26 TOGGL = '/api/v8'
27 REPORTS = '/reports/api/v2'
28
29class TogglAPIError(Exception):
30 def __init__(self, http_error, response):
31 self.http_error = http_error
32 self.response = response
33
34 def __str__(self):
35 if not self.http_error is None:
36 return str(self.http_error)
37 else:
38 return self.response.text
39
40class TogglAPI(object):
41 def __init__(self, api_token, workspace_id):
42 self._api_token = api_token
43 self._workspace_id = workspace_id
44
45 def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}):
46 if api is TogglAPISection.REPORTS:
47 params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id})
48
49 api_path = api.value
50 section_path = '/'.join(section)
51 uri = uricompose(scheme='https', host='www.toggl.com', path=f"{api_path}/{section_path}", query=params)
52
53 return uri
54
55 def _query(self, url, method):
56
57 headers = {'content-type': 'application/json'}
58 response = None
59
60 if method == 'GET':
61 response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
62 elif method == 'POST':
63 response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
64 else:
65 raise ValueError(f"Undefined HTTP method “{method}”")
66
67 response.raise_for_status()
68
69 return response
70
71 def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False):
72 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})
73 r = self._query(url = url, method='GET')
74 if not r or not r.json():
75 raise TogglAPIError(r)
76
77 return timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0)
78
79 def get_running_clock(self, now=datetime.now(timezone.utc)):
80 url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current'])
81 r = self._query(url = url, method='GET')
82
83 if not r or not r.json():
84 raise TogglAPIError(r)
85
86 if not r.json()['data'] or not r.json()['data']['billable']:
87 return None
88
89 start = isoparse(r.json()['data']['start'])
90
91 return now - start if start <= now else None
92
93class Worktime(object):
94 time_worked = timedelta()
95 running_entry = None
96 now = datetime.now(tzlocal())
97 time_pulled_forward = timedelta()
98 is_workday = False
99 include_running = True
100 time_to_work = None
101 force_day_to_work = True
102
103 def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs):
104 self.include_running = include_running
105 self.force_day_to_work = force_day_to_work
106
107 if now:
108 self.now = now
109
110 config = configparser.ConfigParser()
111 config_dir = BaseDirectory.load_first_config('worktime')
112 config.read(f"{config_dir}/worktime.ini")
113 api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace'])
114 date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d')
115
116 start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal())
117 end_date = end_datetime or self.now
118
119 try:
120 with open(f"{config_dir}/reset", 'r') as reset:
121 for line in reset:
122 stripped_line = line.strip()
123 reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal())
124
125 if reset_date > start_date and reset_date < end_date:
126 start_date = reset_date
127 except IOError as e:
128 if e.errno != 2:
129 raise e
130
131
132 hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40))
133 workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')])
134 time_per_day = timedelta(hours = hours_per_week) / len(workdays)
135
136 holidays = dict()
137
138 for year in range(start_date.year, end_date.year + 1):
139 y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal())
140
141 # Legal holidays in munich, bavaria
142 holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = time_per_day
143 holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = time_per_day
144 holidays[(y_easter+timedelta(days=-2)).date()] = time_per_day
145 holidays[(y_easter+timedelta(days=+1)).date()] = time_per_day
146 holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = time_per_day
147 holidays[(y_easter+timedelta(days=+39)).date()] = time_per_day
148 holidays[(y_easter+timedelta(days=+50)).date()] = time_per_day
149 holidays[(y_easter+timedelta(days=+60)).date()] = time_per_day
150 holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = time_per_day
151 holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = time_per_day
152 holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = time_per_day
153 holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = time_per_day
154 holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = time_per_day
155
156 try:
157 with open(f"{config_dir}/excused", 'r') as excused:
158 for line in excused:
159 stripped_line = line.strip()
160 if stripped_line:
161 splitLine = stripped_line.split(' ')
162 if len(splitLine) == 2:
163 [hours, date] = splitLine
164 day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date()
165 holidays[day] = timedelta(hours = float(hours))
166 else:
167 holidays[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day
168 except IOError as e:
169 if e.errno != 2:
170 raise e
171
172 pull_forward = dict()
173
174 start_day = start_date.date()
175 end_day = end_date.date()
176
177 try:
178 with open(f"{config_dir}/pull-forward", 'r') as excused:
179 for line in excused:
180 stripped_line = line.strip()
181 if stripped_line:
182 [hours, date] = stripped_line.split(' ')
183 constr = date.split(',')
184 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))]:
185 for c in constr:
186 if c in calendar.day_abbr:
187 if not d.strftime('%a') == c: break
188 elif "--" in c:
189 [fromDay,toDay] = c.split('--')
190 if fromDay != "":
191 fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date()
192 if not fromDay <= d: break
193 if toDay != "":
194 toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date()
195 if not d <= toDay: break
196 else:
197 if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break
198 else:
199 if d >= end_date.date():
200 pull_forward[d] = min(timedelta(hours = float(hours)), time_per_day - (holidays[d] if d in holidays else timedelta()))
201 except IOError as e:
202 if e.errno != 2:
203 raise e
204
205 days_to_work = dict()
206
207 if pull_forward:
208 end_day = max(end_day, max(list(pull_forward)))
209
210 for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]:
211 if day.isoweekday() in workdays:
212 time_to_work = time_per_day
213 if day in holidays.keys():
214 time_to_work -= holidays[day]
215 if time_to_work > timedelta():
216 days_to_work[day] = time_to_work
217
218 extra_days_to_work = dict()
219
220 try:
221 with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file:
222 for line in extra_days_to_work_file:
223 stripped_line = line.strip()
224 if stripped_line:
225 splitLine = stripped_line.split(' ')
226 if len(splitLine) == 2:
227 [hours, date] = splitLine
228 day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date()
229 extra_days_to_work[day] = timedelta(hours = float(hours))
230 else:
231 extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day
232 except IOError as e:
233 if e.errno != 2:
234 raise e
235
236
237 self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work
238
239 self.time_worked = timedelta()
240
241 if self.include_running:
242 self.running_entry = api.get_running_clock(self.now)
243
244 if self.running_entry:
245 self.time_worked += self.running_entry
246
247 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):
248 extra_days_to_work[self.now.date()] = timedelta()
249
250 self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta())
251 for day in [d for d in list(pull_forward) if d > end_date.date()]:
252 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())])
253 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())])
254 days_forward = days_forward.union(extra_days_forward)
255
256 extra_day_time_left = timedelta()
257 for extra_day in extra_days_forward:
258 day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day])
259 extra_day_time_left += day_time
260 extra_day_time = min(extra_day_time_left, pull_forward[day])
261 time_forward = pull_forward[day] - extra_day_time
262 if extra_day_time_left > timedelta():
263 for extra_day in extra_days_forward:
264 day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day])
265 extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left)
266
267 hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta()
268 days_forward.discard(end_date.date())
269
270 self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward)
271
272 if end_date.date() in extra_days_to_work:
273 self.time_pulled_forward += extra_days_to_work[end_date.date()]
274
275 self.time_to_work += self.time_pulled_forward
276
277 self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True))
278
279def worktime(**args):
280 worktime = Worktime(**args)
281
282 def format_worktime(worktime):
283 def difference_string(difference):
284 total_minutes_difference = round(difference / timedelta(minutes = 1))
285 (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60)
286 sign = '' if total_minutes_difference >= 0 else '-'
287
288 difference_string = f"{sign}"
289 if hours_difference != 0:
290 difference_string += f"{hours_difference}h"
291 if hours_difference == 0 or minutes_difference != 0:
292 difference_string += f"{minutes_difference}m"
293
294 return difference_string
295
296 difference = worktime.time_to_work - worktime.time_worked
297 total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5))
298
299 if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) :
300 clockout_time = worktime.now + difference
301 clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1)
302 clockout_time = clockout_time.replace(second = 0, microsecond = 0)
303
304 if total_minutes_difference >= 0:
305 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
306 return "{difference_string}/{clockout_time}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M"))
307 else:
308 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
309 return "{clockout_time}/{difference_string}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M"))
310 else:
311 if worktime.running_entry:
312 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
313 indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6'
314
315 return f"{indicator}{difference_string}"
316 else:
317 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
318 if worktime.is_workday:
319 return difference_string
320 else:
321 return f"({difference_string})"
322
323 if worktime.time_pulled_forward >= timedelta(minutes = 15):
324 worktime_no_pulled_forward = deepcopy(worktime)
325 worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward
326 worktime_no_pulled_forward.time_pulled_forward = timedelta()
327
328 difference_string = format_worktime(worktime)
329 difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward)
330
331 print(f"{difference_string_no_pulled_forward}…{difference_string}")
332 else:
333 print(format_worktime(worktime))
334
335def time_worked(now, **args):
336 then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0)
337 if now.time() == time():
338 now = now + timedelta(days = 1)
339
340 then = Worktime(**dict(args, now = then))
341 now = Worktime(**dict(args, now = now))
342
343 worked = now.time_worked - then.time_worked
344
345 if args['do_round']:
346 total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5))
347 (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60)
348 sign = '' if total_minutes_difference >= 0 else '-'
349
350 difference_string = f"{sign}"
351 if hours_difference != 0:
352 difference_string += f"{hours_difference}h"
353 if hours_difference == 0 or minutes_difference != 0:
354 difference_string += f"{minutes_difference}m"
355
356 print(difference_string)
357 else:
358 print(worked)
359
360def diff(now, **args):
361 now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0)
362 then = now - timedelta.resolution
363
364 then = Worktime(**dict(args, now = then, include_running = False))
365 now = Worktime(**dict(args, now = now, include_running = False))
366
367 print(now.time_to_work - then.time_to_work)
368
369
370def main():
371 parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API')
372 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()))
373 parser.add_argument('--no-running', dest = 'include_running', action = 'store_false')
374 parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false')
375 subparsers = parser.add_subparsers(help = 'Subcommands')
376 parser.set_defaults(cmd = worktime)
377 time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today'])
378 time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false')
379 time_worked_parser.set_defaults(cmd = time_worked)
380 diff_parser = subparsers.add_parser('diff')
381 diff_parser.set_defaults(cmd = diff)
382 args = parser.parse_args()
383
384 args.cmd(**vars(args))
385
386if __name__ == "__main__":
387 sys.exit(main())