Wer viel an verschiedenen Projekten arbeitet, kennt das Problem: Die Zeiterfassung im Browser zu starten und zu stoppen, wird im Eifer des Gefechts oft vergessen. Viel intuitiver ist ein physisches Stream Deck oder ein Tablet mit Touch Portal auf dem Schreibtisch. Ein Klick: Projekt X startet und der Button wird grün. Ein weiterer Klick: Die Zeit stoppt und der Button wird wieder blau.
In diesem Beitrag zeige ich, wie man die InvoiceNinja v5 API über ein simples Bash-Skript ansteuert und mit Touch Portal verknüpft – inklusive dynamischer Laufzeit-Anzeige auf dem Button.
Voraussetzungen
- Ein laufendes InvoiceNinja v5 (z. B. selbstgehostet auf einem Proxmox-Server).
- Ein generierter API-Token aus InvoiceNinja (Einstellungen -> API-Token).
- Touch Portal installiert (in diesem Beispiel unter Linux).
- Das Tool
jqauf deinem Linux-System (installierbar viasudo apt install jq), um die API-Antworten zu verarbeiten.
Teil 1: Das Backend – Das Bash-Skript
Damit Touch Portal nicht direkt mit komplexen API-Aufrufen kämpfen muss, lagern wir die Logik in ein Bash-Skript aus. Dieses Skript prüft, ob der Timer für ein bestimmtes Projekt läuft, startet oder stoppt ihn entsprechend (Toggle) und berechnet die bisherige Laufzeit.
Wichtig für Linux-Nutzer (Sandbox-Falle): Touch Portal läuft unter Linux oft als isoliertes AppImage. Es hat standardmäßig keinen Zugriff auf globale Verzeichnisse wie /tmp/. Wir speichern die Status-Dateien daher zwingend im eigenen Home-Verzeichnis (z. B. direkt beim Skript).
Erstelle eine Datei namens invoice_timer.sh in deinem Skript-Ordner (z. B. /home/DEIN_NAME/scripts/touchportal/) und füge diesen Code ein:
Bash
#!/bin/bash
# ==========================================
# Konfiguration (Hier anpassen!)
# ==========================================
IN_URL="http://DEINE_INVOICENINJA_IP"
API_TOKEN="DEIN_API_TOKEN"
# WICHTIG: Nutze absolute Pfade in deinem Home-Verzeichnis!
STATUS_FILE="/home/DEIN_NAME/scripts/touchportal/in_status.txt"
ID_FILE="/home/DEIN_NAME/scripts/touchportal/in_active_id.txt"
MIN_FILE="/home/DEIN_NAME/scripts/touchportal/in_minutes.txt"
# Aktueller Timestamp (Linux Epoche)
NOW=$(date +%s)
# 1. Aktuellen Status abfragen
JSON_RESPONSE=$(curl -s -m 5 -X GET "$IN_URL/api/v1/tasks" \
-H "X-Api-Token: $API_TOKEN" \
-H "X-Requested-With: XMLHttpRequest")
if [ $? -ne 0 ] || [ -z "$JSON_RESPONSE" ]; then
echo -n "ERROR" > "$STATUS_FILE"
exit 1
fi
# ID des aktuell laufenden Tasks sicher extrahieren
ACTIVE_ID=$(echo "$JSON_RESPONSE" | jq -r '.data | map(select(.is_running == true)) | if length > 0 then .[0].id else "none" end')
# ==========================================
# Hilfsfunktionen für Start / Stop
# ==========================================
stop_task() {
local task_id="$1"
local task_data=$(echo "$JSON_RESPONSE" | jq -c --arg id "$task_id" '.data[] | select(.id == $id)')
local old_time_log=$(echo "$task_data" | jq -r '.time_log')
if [ "$old_time_log" == "null" ] || [ -z "$old_time_log" ]; then old_time_log="[]"; fi
local new_time_log=$(echo "$old_time_log" | jq -c --argjson now "$NOW" '
if type == "string" then fromjson else . end |
if length > 0 then if .[-1][1] == 0 then .[-1][1] = $now else . end else . end
')
local payload=$(jq -n --arg tl "$new_time_log" '{"is_running": false, "time_log": $tl}')
curl -s -X PUT "$IN_URL/api/v1/tasks/$task_id" \
-H "X-Api-Token: $API_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" > /dev/null
}
start_task() {
local task_id="$1"
local target_json=$(curl -s -X GET "$IN_URL/api/v1/tasks/$task_id" -H "X-Api-Token: $API_TOKEN" -H "X-Requested-With: XMLHttpRequest")
local old_time_log=$(echo "$target_json" | jq -r '.data.time_log // "[]"')
if [ "$old_time_log" == "null" ] || [ -z "$old_time_log" ]; then old_time_log="[]"; fi
local new_time_log=$(echo "$old_time_log" | jq -c --argjson now "$NOW" '
if type == "string" then fromjson else . end | . + [[$now, 0]]
')
local payload=$(jq -n --arg tl "$new_time_log" '{"is_running": true, "time_log": $tl}')
curl -s -X PUT "$IN_URL/api/v1/tasks/$task_id" \
-H "X-Api-Token: $API_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" > /dev/null
}
# ==========================================
# 2. Toggle-Logik (Beim Button-Druck)
# ==========================================
if [ "$1" == "--toggle" ]; then
TARGET_ID="$2"
if [ "$ACTIVE_ID" == "$TARGET_ID" ]; then
stop_task "$ACTIVE_ID"
else
if [ "$ACTIVE_ID" != "none" ]; then
stop_task "$ACTIVE_ID"
fi
start_task "$TARGET_ID"
fi
"$0"
exit 0
fi
# ==========================================
# 3. Laufzeit berechnen für Touch Portal
# ==========================================
if [ "$ACTIVE_ID" != "none" ]; then
ACTIVE_TASK=$(echo "$JSON_RESPONSE" | jq -c --arg id "$ACTIVE_ID" '.data[] | select(.id == $id)')
TOTAL_SECONDS=$(echo "$ACTIVE_TASK" | jq -r --argjson now "$NOW" '
(.time_log | if type == "string" then fromjson else . end) as $log |
if ($log | length) > 0 then
$log | map(if .[1] == 0 then ($now - .[0]) else (.[1] - .[0]) end) | add
else
0
end
')
if [ -z "$TOTAL_SECONDS" ] || [ "$TOTAL_SECONDS" == "null" ]; then TOTAL_SECONDS=0; fi
MINUTES=$(( TOTAL_SECONDS / 60 ))
# Wichtig: "echo -n" verhindert unsichtbare Zeilenumbrüche!
echo -n "RUNNING" > "$STATUS_FILE"
echo -n "$ACTIVE_ID" > "$ID_FILE"
echo -n "$MINUTES" > "$MIN_FILE"
else
echo -n "STOPPED" > "$STATUS_FILE"
echo -n "none" > "$ID_FILE"
echo -n "0" > "$MIN_FILE"
fi
Mache die Datei ausführbar: chmod +x invoice_timer.sh
Teil 2: Touch Portal vorbereiten
Lege im linken Menü von Touch Portal unter Werte (Values) drei neue Platzhalter an, in denen wir die Daten aus den Textdateien speichern:
-
IN_Status(Standardwert:STOPPED) -
IN_ActiveID(Standardwert:none) -
IN_Minutes(Standardwert:0)
Teil 3: Den Button konfigurieren (Beim Drücken / On Pressed)
Erstelle einen neuen Button. Im Reiter Beim Drücken legen wir fest, was passiert, wenn du auf das Tablet tippst:
-
Aktion: Skript ausführen
- Pfad:
/home/DEIN_NAME/scripts/touchportal/invoice_timer.sh - mit Argumenten:
--toggle DEINE_INVOICE_TASK_ID(Die Task-ID findest du in der URL, wenn du das Projekt in InvoiceNinja öffnest).
- Pfad:
-
Aktion: Warte auf Timer
- Dauer:
1500 Millisekunden - Warum? Das Skript braucht kurz, um mit der API zu sprechen. Ohne diese Pause würde Touch Portal die Dateien einlesen, bevor das Skript die neuen Daten geschrieben hat (Race Condition).
- Dauer:
-
Aktion: Datei auswählen (3x)
- Dateiinhalt in Wert
IN_Status - Dateiinhalt in Wert
IN_ActiveID - Dateiinhalt in Wert
IN_Minutes -
🚨 WICHTIGER LINUX-TIPP: Tippe den Pfad zu den Textdateien nicht von Hand ein! Klicke unbedingt auf den Button mit den drei Punkten (
...) und wähle die Dateien manuell aus. Nur so umgehst du die Sandbox-Blockade des AppImages.
- Dateiinhalt in Wert
Teil 4: Die optische Logik (Beim Event / On Event)
Damit der Button seine Farbe ändert, bauen wir im Reiter Beim Event eine fehlerresistente Kaskade.
Der Auslöser (Ganz oben):
-
Wenn der Wert IN_ActiveID->ändert sich nicht zu->[Feld komplett leer lassen](Dadurch triggert Touch Portal bei jeder Aktion, da unser Skript niemals leere Werte, sondern immer mindestens „none“ schreibt).
Die Verschachtelung (Logik):
Wir nutzen verschachtelte IF/ELSE-Blöcke. Es ist extrem wichtig, dass die Prüfung für den laufenden Task in das innere ELSE gelegt wird, damit sich die Farben nicht gegenseitig überschreiben. Achte auf „enthält“ (contains), um unsichtbare Zeichenfehler auszuschließen!
-
IF (Wert IN_Status enthält ERROR)-
Button Optik ändern:Hintergrund Rot
-
-
ELSE-
IF (Wert IN_ActiveID enthält DEINE_INVOICE_TASK_ID)-
Button Optik ändern:Hintergrund Grün -
Button Optik ändern:Text:Projekt Name [ENTER-TASTE DRÜCKEN] ${value:IN_Minutes} Min.(Tipp: Für einen Zeilenumbruch nutze nicht\n, sondern drücke einfach direkt im Touch Portal Textfeld die Enter-Taste).
-
-
ELSE-
Button Optik ändern:Hintergrund Blau (Standard)
-
END IF
-
END IF
Sobald du das gespeichert hast, kannst du deinen Button testen. Er wird den Task per API starten und sofort auf Grün umspringen!
Teil 5: So legst du Buttons für weitere Projekte an
Das Geniale an diesem Aufbau: Wenn du das Setup für ein Projekt fertig hast, ist das Hinzufügen weiterer Projekte eine Sache von Sekunden. Du musst das Skript nicht mehr anfassen.
- Mache in Touch Portal einen Rechtsklick auf deinen fertigen Button und wähle Kopieren.
- Füge den Button an einer freien Stelle ein und öffne ihn.
- Im Reiter Beim Drücken: Ändere beim roten Block Skript ausführen das Argument ab. Ersetze die alte Task-ID nach dem
--toggledurch die neue ID (z.B.--toggle NeueTaskIDxyz). - Im Reiter Beim Event: Ändere im inneren grünen
IF-Block die Bedingung ab. Trage auch hier die neue ID ein (IF IN_ActiveID enthält NeueTaskIDxyz). - Passe im lila Block darunter noch den Text an, damit der Name des neuen Projekts auf dem Tablet steht.
Fertig! Startest du nun Projekt B, wird dessen Button sofort grün. Da Projekt A über das Event-System merkt, dass seine ID nicht mehr aktiv ist, schaltet es sich automatisch und in Echtzeit wieder auf Blau zurück. Ein perfektes, visuelles Tracking Board!