From 456111627d0c63a0fbea4a68a9be9f56cd25fe80 Mon Sep 17 00:00:00 2001 From: Matthias Johnson Date: Fri, 13 Feb 2026 23:01:17 -0700 Subject: [PATCH] Implement full -E edit mode with short IDs Add 6-char hex short IDs (SYSTAB_ID) embedded in unit files for human-friendly job identification. The -E flag now opens a real crontab-like editor where jobs can be created, updated, and deleted by editing tab-separated ID/SCHEDULE/COMMAND lines. Legacy jobs without IDs get one auto-assigned on first edit. Co-Authored-By: Claude Opus 4.6 --- systab | 341 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 295 insertions(+), 46 deletions(-) diff --git a/systab b/systab index 7d1580a..ceff36c 100755 --- a/systab +++ b/systab @@ -1,20 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -# catbsysd - A cron/at/batch-like interface for systemd -# Managed jobs are marked with: # CATBSYSD_MANAGED +# systab - A cron/at/batch-like interface for systemd +# Managed jobs are marked with: # SYSTAB_MANAGED readonly SCRIPT_NAME="systab" readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" -readonly MARKER="# CATBSYSD_MANAGED" -readonly CRONTAB_EXAMPLE="# Example crontab format (for reference): -# Min Hour Day Month DayOfWeek Command -# * * * * * /path/to/command -# 0 2 * * * /path/to/nightly-job -# */15 * * * * /path/to/every-15-min -# -# Actual systemd timers below (OnCalendar format): -" +readonly MARKER="# SYSTAB_MANAGED" # Global variables for options opt_time="" @@ -128,6 +120,56 @@ generate_job_name() { echo "${SCRIPT_NAME}_${timestamp}_$$" } +# Generate a random 6-char hex short ID +generate_short_id() { + od -An -tx1 -N3 /dev/urandom | tr -d ' \n' +} + +# Extract SYSTAB_ID from a job's timer file; empty if missing +get_job_id() { + local job_name="$1" + local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer" + if [[ -f "$timer_file" ]]; then + sed -n 's/^# SYSTAB_ID=//p' "$timer_file" + fi +} + +# Find job name by short ID; empty if not found +get_job_by_id() { + local target_id="$1" + local job + while IFS= read -r job; do + [[ -z "$job" ]] && continue + local jid + jid=$(get_job_id "$job") + if [[ "$jid" == "$target_id" ]]; then + echo "$job" + return + fi + done < <(get_managed_timers) +} + +# Ensure a managed job has a SYSTAB_ID; assign one if missing +ensure_job_id() { + local job_name="$1" + local existing_id + existing_id=$(get_job_id "$job_name") + if [[ -n "$existing_id" ]]; then + echo "$existing_id" + return + fi + local new_id + new_id=$(generate_short_id) + # Insert SYSTAB_ID line after the MARKER in both files + for ext in timer service; do + local unit_file="$SYSTEMD_USER_DIR/${job_name}.${ext}" + if [[ -f "$unit_file" ]]; then + sed -i "s/^$MARKER$/&\n# SYSTAB_ID=$new_id/" "$unit_file" + fi + done + echo "$new_id" +} + # Get all managed service files get_managed_services() { if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then @@ -194,11 +236,16 @@ create_job() { # Create systemd user directory if needed mkdir -p "$SYSTEMD_USER_DIR" - + + # Generate short ID for human-friendly display + local short_id + short_id=$(generate_short_id) + # Create service file local service_file="$SYSTEMD_USER_DIR/${job_name}.service" cat > "$service_file" < "$timer_file" </dev/null || true + systemctl --user disable "${job_name}.timer" 2>/dev/null || true + rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer" +} + +# Create a job from edit mode (schedule + command, returns short ID) +create_job_from_edit() { + local schedule="$1" command_to_run="$2" + local job_name short_id + + job_name=$(generate_job_name) + short_id=$(generate_short_id) + + mkdir -p "$SYSTEMD_USER_DIR" + + # Service file + cat > "$SYSTEMD_USER_DIR/${job_name}.service" < "$SYSTEMD_USER_DIR/${job_name}.timer" <> "$SYSTEMD_USER_DIR/${job_name}.timer" + fi + + cat >> "$SYSTEMD_USER_DIR/${job_name}.timer" < "$temp_file" - - # Add existing jobs + local temp_file orig_file + temp_file="${TMPDIR:-/dev/shm}/systab_edit_$$" + orig_file="${TMPDIR:-/dev/shm}/systab_orig_$$" + trap 'rm -f "$temp_file" "$orig_file"' EXIT + + # Build crontab content + { + cat <<'HEADER' +# systab jobs — edit schedule/command, add/remove lines +# Format: ID SCHEDULE COMMAND +# Lines starting with # are ignored. Remove a line to delete a job. +# Add a line with "new" as ID to create a job: new daily /path/to/cmd +HEADER + + local job + while IFS= read -r job; do + [[ -z "$job" ]] && continue + + local timer_file="$SYSTEMD_USER_DIR/${job}.timer" + local service_file="$SYSTEMD_USER_DIR/${job}.service" + + if [[ -f "$timer_file" && -f "$service_file" ]]; then + local short_id schedule command + short_id=$(ensure_job_id "$job") + schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-) + command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//") + printf '%s\t%s\t%s\n' "$short_id" "$schedule" "$command" + fi + done < <(get_managed_timers) + } > "$temp_file" + + # Save original for diffing + cp "$temp_file" "$orig_file" + + # Open in editor + if ! "${EDITOR:-vi}" "$temp_file"; then + echo "Editor exited with error, aborting." + return 1 + fi + + # Check for no changes + if diff -q "$orig_file" "$temp_file" &>/dev/null; then + echo "No changes." + return + fi + + # Parse files into associative arrays: id -> "schedule\tcmd" + declare -A orig_jobs edited_jobs + # Also track id order for original (to map id -> job_name) + declare -A id_to_jobname + + # Build id -> jobname mapping from current managed timers local job while IFS= read -r job; do [[ -z "$job" ]] && continue - - local timer_file="$SYSTEMD_USER_DIR/${job}.timer" - local service_file="$SYSTEMD_USER_DIR/${job}.service" - - if [[ -f "$timer_file" && -f "$service_file" ]]; then - local schedule command - schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-) - command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//") - - echo "# Job: $job" - echo "# Schedule: $schedule" - echo "# Command: $command" - echo "" + local jid + jid=$(get_job_id "$job") + if [[ -n "$jid" ]]; then + id_to_jobname["$jid"]="$job" fi done < <(get_managed_timers) - - # Open in editor - "${EDITOR:-vi}" "$temp_file" - - # Note: Full parsing and updating would be complex - # For now, just show the jobs for reference - echo "Note: Editing support is view-only. Use -C to clean up jobs." - echo "Use command-line options to create new jobs." + + # Parse original file + while IFS= read -r line; do + [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue + local id sched cmd + id=$(printf '%s' "$line" | cut -f1) + sched=$(printf '%s' "$line" | cut -f2) + cmd=$(printf '%s' "$line" | cut -f3-) + [[ -n "$id" && -n "$sched" ]] || continue + orig_jobs["$id"]="${sched} ${cmd}" + done < "$orig_file" + + # Parse edited file + while IFS= read -r line; do + [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue + local id sched cmd + id=$(printf '%s' "$line" | cut -f1) + sched=$(printf '%s' "$line" | cut -f2) + cmd=$(printf '%s' "$line" | cut -f3-) + [[ -n "$id" && -n "$sched" ]] || continue + edited_jobs["$id"]="${sched} ${cmd}" + done < "$temp_file" + + local created=0 deleted=0 updated=0 needs_reload=false + + # Deletions: IDs in original but not in edited + for id in "${!orig_jobs[@]}"; do + if [[ -z "${edited_jobs[$id]+x}" ]]; then + local jname="${id_to_jobname[$id]:-}" + if [[ -n "$jname" ]]; then + remove_job "$jname" + echo "Deleted: $id ($jname)" + deleted=$((deleted + 1)) + needs_reload=true + fi + fi + done + + # Creations: IDs in edited but not in original (must be "new") + for id in "${!edited_jobs[@]}"; do + if [[ -z "${orig_jobs[$id]+x}" ]]; then + if [[ "$id" != "new" ]]; then + warn "Ignoring unknown ID '$id' — use 'new' to create jobs" + continue + fi + local entry="${edited_jobs[$id]}" + local sched cmd + sched=$(printf '%s' "$entry" | cut -f1) + cmd=$(printf '%s' "$entry" | cut -f2-) + local result + result=$(create_job_from_edit "$sched" "$cmd") + echo "Created: $result" + created=$((created + 1)) + needs_reload=true + fi + done + + # Updates: IDs in both but with changed values + for id in "${!edited_jobs[@]}"; do + [[ "$id" == "new" ]] && continue + if [[ -n "${orig_jobs[$id]+x}" && "${orig_jobs[$id]}" != "${edited_jobs[$id]}" ]]; then + local jname="${id_to_jobname[$id]:-}" + if [[ -z "$jname" ]]; then + warn "Cannot find job for ID '$id', skipping update" + continue + fi + + local old_entry="${orig_jobs[$id]}" new_entry="${edited_jobs[$id]}" + local old_sched new_sched old_cmd new_cmd + old_sched=$(printf '%s' "$old_entry" | cut -f1) + new_sched=$(printf '%s' "$new_entry" | cut -f1) + old_cmd=$(printf '%s' "$old_entry" | cut -f2-) + new_cmd=$(printf '%s' "$new_entry" | cut -f2-) + + local timer_file="$SYSTEMD_USER_DIR/${jname}.timer" + local service_file="$SYSTEMD_USER_DIR/${jname}.service" + + if [[ "$old_sched" != "$new_sched" && -f "$timer_file" ]]; then + sed -i "s|^OnCalendar=.*|OnCalendar=$new_sched|" "$timer_file" + + # Update Persistent line based on new schedule + if is_recurring "$new_sched"; then + sed -i '/^Persistent=false$/d' "$timer_file" + elif ! grep -q "^Persistent=false" "$timer_file"; then + sed -i "/^OnCalendar=/a Persistent=false" "$timer_file" + fi + fi + + if [[ "$old_cmd" != "$new_cmd" && -f "$service_file" ]]; then + sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file" + fi + + systemctl --user restart "${jname}.timer" 2>/dev/null || true + echo "Updated: $id ($jname)" + updated=$((updated + 1)) + needs_reload=true + fi + done + + if $needs_reload; then + systemctl --user daemon-reload + fi + + echo "" + echo "Summary: $created created, $updated updated, $deleted deleted" } # Show status of all managed jobs @@ -325,8 +562,14 @@ show_status() { for job in "${job_list[@]}"; do local timer_file="$SYSTEMD_USER_DIR/${job}.timer" local service_file="$SYSTEMD_USER_DIR/${job}.service" - - echo "Job: $job" + + local short_id + short_id=$(get_job_id "$job") + if [[ -n "$short_id" ]]; then + echo "Job: $job (ID: $short_id)" + else + echo "Job: $job" + fi # Get schedule from timer file if [[ -f "$timer_file" ]]; then @@ -391,7 +634,7 @@ show_status() { # Show systemctl list-timers summary echo "Active Timers:" echo "--------------" - systemctl --user list-timers "catbsysd_*" --no-pager 2>/dev/null || echo "none active" + systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active" } # List logs @@ -420,7 +663,13 @@ list_logs() { echo "" for job in "${job_list[@]}"; do - echo "=== Logs for $job ===" + local short_id + short_id=$(get_job_id "$job") + if [[ -n "$short_id" ]]; then + echo "=== Logs for $job (ID: $short_id) ===" + else + echo "=== Logs for $job ===" + fi # Show timer status if systemctl --user is-active "${job}.timer" &>/dev/null; then