diff options
-rw-r--r-- | accounts/gkleen@sif/default.nix | 5 | ||||
-rw-r--r-- | accounts/gkleen@sif/store.kdbx.lftp | 6 | ||||
-rw-r--r-- | accounts/gkleen@sif/systemd.nix | 107 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/default.nix | 7 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/nixpkgs.nix | 9 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/package.yaml | 13 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/shell.nix | 28 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/stack.nix | 17 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/stack.yaml | 10 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/stack.yaml.lock | 12 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/stackage.nix | 31 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/xmobar-yggdrasil.nix | 13 | ||||
-rw-r--r-- | accounts/gkleen@sif/xmobar/xmobar.hs | 52 | ||||
-rw-r--r-- | overlays/worktime/default.nix | 19 | ||||
-rwxr-xr-x | overlays/worktime/worktime.py | 387 |
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: |
2 | let | 2 | let |
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 @@ | |||
1 | open ftp://gkleen.keepass@yggdrasil.li/ | ||
2 | |||
3 | lcd /home/gkleen | ||
4 | |||
5 | mirror -v --only-newer -f store.kdbx | ||
6 | 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 @@ | |||
1 | { pkgs, config, userName, ... }: | ||
2 | let | ||
3 | xmobar = import ./xmobar pkgs.haskellPackages; | ||
4 | cfg = config.home-manager.users.${userName}; | ||
5 | in { | ||
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 @@ | |||
1 | argumentPackages@{ ... }: | ||
2 | |||
3 | let | ||
4 | # defaultPackages = (import ./stackage.nix {}); | ||
5 | # haskellPackages = defaultPackages // argumentPackages; | ||
6 | haskellPackages = argumentPackages; | ||
7 | 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 @@ | |||
1 | { nixpkgs ? import <nixpkgs> | ||
2 | }: | ||
3 | |||
4 | import ((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 @@ | |||
1 | name: xmobar-yggdrasil | ||
2 | |||
3 | executables: | ||
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 | |||
3 | let | ||
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 | }; | ||
27 | in 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 | |||
3 | let | ||
4 | haskellPackages = import ./stackage.nix { inherit nixpkgs; }; | ||
5 | inherit (nixpkgs {}) pkgs; | ||
6 | in 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 @@ | |||
1 | nix: | ||
2 | enable: true | ||
3 | shell-file: stack.nix | ||
4 | |||
5 | resolver: lts-13.21 | ||
6 | |||
7 | packages: | ||
8 | - . | ||
9 | |||
10 | 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 @@ | |||
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 | |||
6 | packages: [] | ||
7 | snapshots: | ||
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 | |||
5 | let | ||
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; | ||
31 | 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 @@ | |||
1 | { mkDerivation, base, hpack, stdenv, xmobar }: | ||
2 | mkDerivation { | ||
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 @@ | |||
1 | import Xmobar | ||
2 | |||
3 | import Data.List (intercalate) | ||
4 | |||
5 | |||
6 | main :: IO () | ||
7 | main = 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 @@ | |||
1 | final: 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 | |||
3 | import requests | ||
4 | from requests.exceptions import HTTPError | ||
5 | from requests.auth import HTTPBasicAuth | ||
6 | from datetime import * | ||
7 | from xdg import (BaseDirectory) | ||
8 | import configparser | ||
9 | from uritools import uricompose | ||
10 | |||
11 | from dateutil.easter import * | ||
12 | from dateutil.tz import * | ||
13 | from dateutil.parser import isoparse | ||
14 | |||
15 | from enum import Enum | ||
16 | |||
17 | from math import (copysign, ceil) | ||
18 | |||
19 | import calendar | ||
20 | |||
21 | import argparse | ||
22 | |||
23 | from copy import deepcopy | ||
24 | |||
25 | class TogglAPISection(Enum): | ||
26 | TOGGL = '/api/v8' | ||
27 | REPORTS = '/reports/api/v2' | ||
28 | |||
29 | class 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 | |||
40 | class 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 | |||
93 | class 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 | |||
279 | def 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 | |||
335 | def 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 | |||
360 | def 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 | |||
370 | def 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 | |||
386 | if __name__ == "__main__": | ||
387 | sys.exit(main()) | ||