diff --git a/LICENSE b/LICENSE index 7ff682b..b932637 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,24 @@ -Copyright (c) 2026 amin +BSD 2-Clause License -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +Copyright (c) 2026, Ämin Baumeler -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a5a699f --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +install: + install ./punch ~/.local/bin/ + install ./punch.bash ~/.local/share/bash-completion/completions/ + install ./punch.1.gz ~/.local/share/man/man1/ diff --git a/punch b/punch new file mode 100755 index 0000000..fbcfa07 --- /dev/null +++ b/punch @@ -0,0 +1,293 @@ +#!/bin/sh + +set -eu + +cmd=$(basename $0) +PUNCHWALLET=${PUNCHWALLET:-$HOME/.local/share/$cmd/wallet} +statefile=$HOME/.local/share/$cmd/state + +# Helper function to show an error and quit +err() { + printf "Error: %s\n" "$1" > /dev/stderr + exit 1 +} + +# List of memory states (cannot contain white spaces) +STATE_PAUSED="paused" +STATE_RUNNING="running" + +# Create missing directories and state file +[ -d "$PUNCHWALLET" ] || mkdir -p "$PUNCHWALLET" || err "Failed to create wallet $PUNCHWALLET" +statedir=$(dirname "$statefile") +[ -d "$statedir" ] || mkdir -p "$statedir" || err "Failed to create directory to keep the state" +[ -r "$statefile" ] || touch "$statefile" || err "Could not create state file" + +# Functions that define the respective commands +help() { + printf "\ +Usage: %s +Punch and inspect your cards for time keeping. + +Commands: + ls List available cards in your wallet + today [ ..] Show time spent at \$DATE (today) + week [ ..] Show time spent in the week containing \$DATE (today) + cat [ ..] In detail show time spent at \$DATE (today) + elapsed Show time elapsed in current timekeeping + rm [ ..] Remove the specified cards + start Start timekeeping in the specified card + pause Pause timekeeping + resume Resume timekeeping for paused card + toggle Toggle between paused and running states + stop Stop timekeeping + state Print current state + +By design, this application does not allow for double time keeping. + +The cards are stored in the wallet \$PUNCHWALLET which is set to +~/.local/share/%s/wallet by default. + +" "$cmd" "$cmd" + exit 0 +} + +## State machine + +# If timekeeping is currently active, then this function prints the card name +# and returns true. Otherwise, it prints nothing and returns false. +__is_running() { + grep -q "$STATE_RUNNING" "$statefile" && cut -d ' ' -f 2- "$statefile" +} + +# Same as __is_running but for the paused state +__is_paused() { + grep -q "^$STATE_PAUSED " "$statefile" && cut -d ' ' -f 2- "$statefile" +} + +# Set the current state +__set_running() { + printf "%s %s" "$STATE_RUNNING" "$1" > "$statefile" +} + +__set_paused() { + printf "%s %s" "$STATE_PAUSED" "$1" > "$statefile" +} + +__set_stopped() { + printf "" > "$statefile" +} + +## Database + +# Check cards +# This function aborts if a card is not given and prints the card names with +# duplicates removed. +__check_cards() { + [ "${1:-}" ] || err "No card specified" + for card in $(echo "$@" | tr ' ' "\n" | sort -u); do + [ ! -f "$PUNCHWALLET/$card" ] && err "Card \"$1\" does not exist" + echo "$card" + done +} + +# Write time to punch card +# @argument $1: punch card +# @argument $2: if set, write end time +__time_write() { + printf "%s%s" "${2:+ }" "$(date +"%s")" >> "$PUNCHWALLET/$1" + [ ! "${2:-}" ] || (printf '\n' >> "$PUNCHWALLET/$1") +} + +# Get times of specified cards +# This returns "closed" time spans in the sense that each line has two numbers, +# start and end time (even if some timekeeping process is still ongoing; just +# put `now`). +# @argument $1: start time +# @argument $2: range, any of {'day', 'week'} +# @argument $3, ...: cards +__times_retrieve() { + start=$1 + shift + range=$((24 * 60 * 60)) + [ "$1" = "week" ] && range=$((7 * range)) + shift + # Check availability and duplicates + # Retrieve times + now=$(date +"%s") + [ $now -lt $start ] && return + for card in $@; do + cat "$PUNCHWALLET/$card" + echo "" + done | sort -u | awk -vstart="$start" -vend="$((start + range))" -vnow="$now" ' + NF == 0 { next } + $1 > end { exit } + NF == 1 { $2 = now } + $2 < start { next } + $1 < start { $1 = start } + $2 > end { $2 = end } + { print }' +} + +## Commands + +# List available cards +list_cards() { + ls "$PUNCHWALLET" +} + +# Show time spent today +show_today() { + [ "$#" -eq 0 ] && set - $(ls "$PUNCHWALLET") + cards=$(__check_cards "$@") + daystart=$(date --date "$DATE 00:00" +"%s") + __times_retrieve "$daystart" "day" "$cards" | \ + awk '{total = total + $2 - $1} END { if (total) print "@"total }' | \ + date --utc -f /dev/stdin +"%H:%M" +} + +# Print weekly summary +show_week() { + [ "$#" -eq 0 ] && set - $(ls "$PUNCHWALLET") + cards=$(__check_cards "$@") + echo " Mon Tue Wed Thu Fri Sat Sun" + startofweek=$(date --date "$DATE - $(date --date "$DATE" +"%u") days + 1 day 00:00:00" +"%s") + now=$(date +"%s") + # Filter times, split at midnight, sort, sum, print, and also add "now now" + # as a trick to fill the times until today + __times_retrieve "$startofweek" "week" "$cards" | \ + awk -vstart="$startofweek" -vsecperday="$((60 * 60 * 24))" -vnow="$now" '{ + t0 = $1 - start + t1 = $2 - start + day0 = int(t0 / secperday) + day1 = int(t1 / secperday) + delta = 0 + while(day0 + delta < day1) { + delta = delta + 1 + p = (day0+delta)*secperday+start-1 + print $1, p + $1 = p + 1 + } + print $1, $2 + } + END { for(d=0; d<7 && start + d * secperday < now; d++) + print start + d * secperday, start + d * secperday }' | \ + sort | \ + awk -vstart="$startofweek" -vsecperday="$((60 * 60 * 24))" ' + BEGIN { total = 0 } + { + day = int(($1 - start) / secperday) + if (day > currentday) { + print "@"total + currentday = day + total = 0 + } + total = total + $2 - $1 + } + END {print "@"total}' | \ + date --utc -f /dev/stdin +"%H:%M" | xargs -I {} printf "%s " {} + printf "\n" +} + +# Show details of today +show_details() { + [ "$#" -eq 0 ] && set - $(ls "$PUNCHWALLET") + cards=$(__check_cards "$@") + daystart=$(date --date "$DATE 00:00" +"%s") + tmpf=$(mktemp) + __times_retrieve "$daystart" "day" "$cards" > "$tmpf" + # Start times + fstart=$(mktemp) + awk '{print "@"$1}' < "$tmpf" | date -f /dev/stdin +"%H:%M" > "$fstart" + # duration + fdur=$(mktemp) + awk '{print "@"($2-$1)}' < "$tmpf" | date --utc -f /dev/stdin +"%H:%M:%S" | \ + sed 's/^\(.*\)$/(up \1)/'> "$fdur" + # combine + paste "$fstart" "$fdur" + # clean up + rm -f "$tmpf" "$fstart" "$fdur" +} + + +# Delete punch card +remove_card() { + [ "${1:-}" ] || err "No card specified" + for card in $@; do + [ ! -f "$PUNCHWALLET/$card" ] && err "Card \"$card\" does not exist" + rm -i "$PUNCHWALLET/$card" + done + # Update state if necessary + active=$(__is_running || __is_paused) + for card in $@; do + if [ "$active" = "$card" ]; then + __set_stopped + break + fi + done +} + +# Pause currently running time-keeping +pause_timekeeping() { + card=$(__is_running) + __time_write "$card" end + __set_paused "$card" +} + +# Resume paused time-keeping +resume_timekeeping() { + card=$(__is_paused) + __time_write "$card" + __set_running "$card" +} + +# Toggle pause/resume +toggle_timekeeping() { + __is_running && pause_timekeeping || (__is_paused && resume_timekeeping) +} + +# Stop time-keeping +stop_timekeeping() { + __is_running && pause_timekeeping || true + __is_paused && __set_stopped +} + +# Punch card +punch_card() { + [ "${1:-}" ] || err "No card specified" + echo "$1" | grep -q '[^A-Za-z0-9]' && err "Use only alphanumeric characters" + __is_running && stop_timekeeping || true + __time_write "$1" + __set_running "$1" +} + +# Show elapsed time +show_elapsed() { + card=$(__is_running) + t0=$(tail -1 "$PUNCHWALLET/$card") + t1=$(date +"%s") + date --date @$((t1 - t0)) --utc +"%T" +} + +# Show current state +show_state() { + cat "$statefile" +} + +# Run command +DATE=${DATE:-today} + +case "${1:-}" in + ls) list_cards ;; + today) shift; show_today "$@" ;; + week) shift; show_week "$@" ;; + cat) shift; show_details "$@" ;; + rm) shift; remove_card "$@" ;; + start) punch_card "${2:-}" > /dev/null ;; + pause) pause_timekeeping > /dev/null ;; + resume) resume_timekeeping > /dev/null ;; + toggle) toggle_timekeeping > /dev/null ;; + stop) stop_timekeeping > /dev/null ;; + state) show_state ;; + elapsed) show_elapsed ;; + *) help ;; +esac diff --git a/punch.1.gz b/punch.1.gz new file mode 100644 index 0000000..1bfe5ea Binary files /dev/null and b/punch.1.gz differ diff --git a/punch.bash b/punch.bash new file mode 100644 index 0000000..e55ffd5 --- /dev/null +++ b/punch.bash @@ -0,0 +1,14 @@ +__punch_complete() { + local cmd=$1 + local cur=$2 + local prev=$3 + if [ $COMP_CWORD -eq 1 ]; then + COMPREPLY=( $(compgen -W "$(punch|grep '^ '|sed 's/^ //'|cut -d' ' -f1)" -- $cur) ) + else + case "${COMP_WORDS[1]}" in + start) [ $COMP_CWORD -eq 2 ] && COMPREPLY=( $(compgen -W "$(punch ls)" -- $cur) ) ;; + today|week|cat|rm) COMPREPLY=( $(compgen -W "$(punch ls)" -- $cur) ) ;; + esac + fi +} && +complete -F __punch_complete punch