diff --git a/README.md b/README.md index 70ddb02..ce91c39 100644 --- a/README.md +++ b/README.md @@ -279,12 +279,41 @@ brew install figlet ### Command Line Options ```bash -./goodmorning.sh # Run with default settings -./goodmorning.sh --noisy # Enable text-to-speech greeting -./goodmorning.sh --doctor # Run system diagnostics and validation -./goodmorning.sh --help # Show help message +./goodmorning.sh # Run with default settings +./goodmorning.sh --noisy # Enable text-to-speech greeting +./goodmorning.sh --doctor # Run system diagnostics and validation +./goodmorning.sh --section cat # Run a single section (skips preflight/updates) +./goodmorning.sh --help # Show help message ``` +**Running Individual Sections:** +You can run a single section without preflight checks or background updates: +```bash +./goodmorning.sh --section weather +./goodmorning.sh --section word +./goodmorning.sh --section github-prs +``` + +Available sections: +- `weather` - Current weather conditions +- `history` - Historical events for today +- `tech-versions` - Latest versions of tools and technologies +- `country` - Random country of the day +- `word` - Word of the day with definition +- `wikipedia` - Wikipedia featured article +- `apod` - NASA Astronomy Picture of the Day +- `cat` - Random cat picture +- `calendar` - Today's calendar events +- `reminders` - Upcoming reminders +- `github` - GitHub notifications +- `github-prs` - GitHub pull requests requiring review +- `github-issues` - GitHub issues assigned to you +- `alias-suggestions` - Shell alias suggestions +- `system-info` - System information and updates +- `learning` - Daily learning resource +- `sanity` - Sanity maintenance tips +- `tips` - AI-powered learning tips + **Text-to-Speech:** By default, the spoken "Good morning" greeting is disabled. Enable it with: - Runtime flag: `./goodmorning.sh --noisy` @@ -315,6 +344,7 @@ Configuration is managed through environment variables. The setup script handles | `GOODMORNING_LOGS_DIR` | Directory for log files | `$GOODMORNING_CONFIG_DIR/logs` | No | | `GOODMORNING_OUTPUT_HISTORY_DIR` | Directory for output history | `$GOODMORNING_CONFIG_DIR/output_history` | No | | `GOODMORNING_SHOW_WEATHER` | Show weather section | `true` | No | +| `GOODMORNING_WEATHER_LOCATION` | City for weather (e.g., San_Francisco, London) | (none) | **Yes** | | `GOODMORNING_SHOW_HISTORY` | Show history section | `true` | No | | `GOODMORNING_SHOW_LEARNING` | Show daily learning section | `true` | No | | `GOODMORNING_SHOW_SANITY` | Show sanity maintenance section | `true` | No | diff --git a/goodmorning.sh b/goodmorning.sh index 8552396..3015a26 100755 --- a/goodmorning.sh +++ b/goodmorning.sh @@ -169,6 +169,7 @@ OPEN_LINKS="${GOODMORNING_OPEN_LINKS:-true}" # Feature flags - briefing sections (all enabled by default) SHOW_WEATHER="${GOODMORNING_SHOW_WEATHER:-true}" +GOODMORNING_WEATHER_LOCATION="${GOODMORNING_WEATHER_LOCATION:-}" SHOW_HISTORY="${GOODMORNING_SHOW_HISTORY:-true}" SHOW_TECH_VERSIONS="${GOODMORNING_SHOW_TECH_VERSIONS:-true}" SHOW_COUNTRY="${GOODMORNING_SHOW_COUNTRY:-true}" @@ -221,32 +222,84 @@ _setup_output_history() { OUTPUT_HISTORY_FILE="$day_dir/goodmorning-${count}.txt" } +# Run a single section without preflight checks or background updates +_run_single_section() { + local section="$1" + + # Map section names to functions + case "$section" in + weather) show_weather ;; + history) show_history ;; + tech-versions) show_tech_versions ;; + country) show_country_of_day ;; + word) show_word_of_day ;; + wikipedia) show_wikipedia_featured ;; + apod) show_apod ;; + cat) show_cat_of_day ;; + calendar) show_calendar ;; + reminders) show_reminders ;; + github) show_github_notifications ;; + github-prs) show_github_prs ;; + github-issues) show_github_issues ;; + alias-suggestions) show_alias_suggestions ;; + system-info) show_system_info ;; + learning) show_daily_learning ;; + sanity) show_sanity_maintenance ;; + tips) show_learning_tips ;; + *) + echo_error "Unknown section: $section" + echo "Run with --help to see available sections" + return 1 + ;; + esac +} + main() { # Load zsh utilities module for zparseopts zmodload zsh/zutil # Parse command line arguments using zparseopts local -A opts - zparseopts -D -E -A opts -- \ + local -a section_arg + zparseopts -D -E -A opts -section:=section_arg -- \ h -help \ -noisy \ -doctor \ -offline # Handle help - if [[ -n "${opts[--help]}" || -n "${opts[-h]}" ]]; then + if (( ${+opts[--help]} || ${+opts[-h]} )); then echo "Usage: goodmorning.sh [OPTIONS]" show_new_line echo "Options:" - echo " --noisy Enable text-to-speech greeting" - echo " --doctor Run system diagnostics and validation" - echo " --offline Run in offline mode (skip network features)" - echo " -h, --help Show this help message" + echo " --section NAME Run a single section (skips preflight/updates)" + echo " --noisy Enable text-to-speech greeting" + echo " --doctor Run system diagnostics and validation" + echo " --offline Run in offline mode (skip network features)" + echo " -h, --help Show this help message" + show_new_line + echo "Available sections:" + echo " weather, history, tech-versions, country, word, wikipedia, apod," + echo " cat, calendar, reminders, github, github-prs, github-issues," + echo " alias-suggestions, system-info, learning, sanity, tips" return 0 fi + # Handle single section mode (skip all preflight and run just one function) + if [[ ${#section_arg[@]} -gt 0 ]]; then + # zparseopts stores option name at index 1 and value at index 2 + # Use the last element to be more robust against zparseopts behavior changes + if [[ ${#section_arg[@]} -lt 2 ]]; then + echo "Error: --section requires a value" >&2 + return 1 + fi + local section_name="${section_arg[-1]}" + _run_single_section "$section_name" + return $? + fi + # Handle doctor mode - if [[ -n "${opts[--doctor]}" ]]; then + if (( ${+opts[--doctor]} )); then # Source validation for doctor functions if [ -f "$SCRIPT_DIR/lib/validation.sh" ]; then source "$SCRIPT_DIR/lib/validation.sh" @@ -259,12 +312,12 @@ main() { fi # Handle offline mode - if [[ -n "${opts[--offline]}" ]]; then + if (( ${+opts[--offline]} )); then export GOODMORNING_FORCE_OFFLINE=true fi # Handle TTS mode - if [[ -n "${opts[--noisy]}" ]]; then + if (( ${+opts[--noisy]} )); then export GOODMORNING_ENABLE_TTS=true fi diff --git a/lib/app/display.sh b/lib/app/display.sh index bf81adb..8e33e5a 100644 --- a/lib/app/display.sh +++ b/lib/app/display.sh @@ -33,11 +33,28 @@ show_banner() { show_weather() { print_section "🌤️ Weather:" "yellow" - local weather=$(fetch_with_spinner "Fetching weather..." curl -s --max-time 10 "wttr.in/?format=3") - if [ -n "$weather" ]; then + local location="${GOODMORNING_WEATHER_LOCATION:-}" + + if [ -z "$location" ]; then + show_setup_message "Set GOODMORNING_WEATHER_LOCATION to your city (e.g., San_Francisco)" + show_new_line + return 0 + fi + + # Validate location to avoid unsafe characters in the URL + if ! [[ "$location" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Invalid weather location: '$location'" >&2 + echo "Use only letters, numbers, underscores, and hyphens (e.g., San_Francisco)." >&2 + show_new_line + return 1 + fi + local url="wttr.in/${location}?format=3&u" + local weather=$(fetch_with_spinner "Fetching weather..." curl -s --max-time 10 "$url") + + if [ -n "$weather" ] && [[ "$weather" != "not found:"* ]]; then echo "$weather" else - echo "Weather unavailable" + echo "Weather unavailable for: $location" fi show_new_line } diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index d36ef4b..37467d9 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -3,115 +3,127 @@ ############################################################################### # Word of the Day Section # -# Displays word definitions using Free Dictionary API +# Displays word definitions using macOS built-in dictionary (fully offline) ############################################################################### -register_section "word_of_day" --tools "curl" "jq" --network +register_section "word_of_day" --tools "swift" ############################################################################### -# fetch_word_of_day - Try words until one has a definition in the API +# fetch_word_of_day - Get word and definition from macOS dictionary (offline) # -# Uses day-based selection from system dictionary, iterating through candidates -# until finding one that exists in the Free Dictionary API. +# Uses Swift to access macOS CoreServices dictionary. Selects an interesting +# word from /usr/share/dict/words based on day of year. ############################################################################### fetch_word_of_day() { local dict_file="/usr/share/dict/words" local day_of_year=$(date +%j | sed 's/^0*//') - local max_attempts=10 - - # Get candidate words based on day of year - local candidates=() - if [[ -f "$dict_file" ]]; then - # Get multiple candidates by offsetting from the day - for offset in 0 1 2 3 4 5 6 7 8 9; do - local idx=$(( (day_of_year + offset) % 50 )) - local word=$(grep -E '^[a-z]{7,12}$' "$dict_file" | awk "NR % 50 == $idx" | head -1) - [[ -n "$word" ]] && candidates+=("$word") - done + + # Select a word: filter for interesting words (7-12 chars, lowercase only) + # Use day of year to pick consistently for the day + local word + + # Count matching words to ensure selection index is within bounds + local total_words selected_index + total_words=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | wc -l | tr -d ' ') + + if [[ -n "$total_words" && "$total_words" -gt 0 ]]; then + # Compute a stable index in the range [1, total_words] + selected_index=$(( (day_of_year * 7) % total_words + 1 )) + word=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | awk "NR == $selected_index" | head -1) fi - # Add fallbacks - candidates+=("ephemeral" "serendipity" "eloquent" "resilient" "catalyst") - - # Try each candidate until one works - for word in "${candidates[@]}"; do - local word_data - local retry - - # Retry up to 2 times per word with increasing timeout - for retry in 1 2; do - word_data=$(fetch_url "https://api.dictionaryapi.dev/api/v2/entries/en/$word" $((retry * 5))) - [[ -n "$word_data" ]] && break - sleep $((RANDOM % 3 + 1)) - done - - # Check if API returned a valid definition (not an error) - if [[ -n "$word_data" ]] && ! echo "$word_data" | grep -q '"title":"No Definitions Found"'; then - local fetched_word=$(jq_extract "$word_data" '.[0].word') - [[ -z "$fetched_word" ]] && continue - - local phonetic=$(printf '%s' "$word_data" | jq -r '.[0].phonetic // .[0].phonetics[0].text // ""' 2>> "$LOG_FILE") - local part_of_speech=$(jq_extract "$word_data" '.[0].meanings[0].partOfSpeech') - local definition=$(jq_extract "$word_data" '.[0].meanings[0].definitions[0].definition') - local example=$(printf '%s' "$word_data" | jq -r '.[0].meanings[0].definitions[0].example // ""' 2>> "$LOG_FILE") - - jq -n \ - --arg word "$fetched_word" \ - --arg phonetic "$phonetic" \ - --arg pos "$part_of_speech" \ - --arg def "$definition" \ - --arg ex "$example" \ - '{word: $word, phonetic: $phonetic, partOfSpeech: $pos, definition: $def, example: $ex}' - return 0 + # Fallback word if dict file fails or selection produced no word + [[ -z "$word" ]] && word="ephemeral" + + # Use Swift to get definition from macOS dictionary + local definition + definition=$(swift -e ' +import Foundation +import CoreServices +let args = CommandLine.arguments +guard args.count > 1 else { exit(1) } +let word = args[1] +if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { + print(def) +} +' -- "$word" 2>> "$LOG_FILE") + + # If no definition found, try fallback words in a single Swift invocation + if [[ -z "$definition" ]]; then + local fallback_result + fallback_result=$(swift -e ' +import Foundation +import CoreServices +let fallbackWords = ["ephemeral", "serendipity", "eloquent", "resilient", "catalyst"] +for word in fallbackWords { + if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { + print(word) + print("<<>>") + print(def) + break + } +} +' 2>> "$LOG_FILE") + + if [[ -n "$fallback_result" ]]; then + word=$(echo "$fallback_result" | awk 'BEGIN {found=0} /<<>>/ {found=1; exit} !found {print}' | tr -d '\n') + definition=$(echo "$fallback_result" | awk 'BEGIN {found=0} /<<>>/ {found=1; next} found {print}') fi - done + fi + + [[ -z "$definition" ]] && return 1 - return 1 + # Parse macOS dictionary format: "word syllables | phonetic | part of speech definition..." + local phonetic part_of_speech def_text + + # Extract phonetic (between | symbols) + phonetic=$(echo "$definition" | grep -o '| [^|]* |' | head -1 | sed 's/|//g' | xargs) + + # Extract part of speech (first occurrence) + part_of_speech=$(echo "$definition" | grep -oE '\b(noun|verb|adjective|adverb)\b' | head -1) + + # Extract just the definition text + # Remove everything up to and including the part of speech, stop at DERIVATIVES/ORIGIN + def_text=$(echo "$definition" | sed -E "s/^.*$part_of_speech //" | sed -E 's/(DERIVATIVES|ORIGIN|PHRASES|•).*$//' | head -c 250 | xargs) + + # Output as simple tab-separated values + printf '%s\t%s\t%s\t%s\n' "$word" "$phonetic" "$part_of_speech" "$def_text" } show_word_of_day() { print_section "Word of the Day" "cyan" - local word_data=$(fetch_with_spinner "Fetching word..." fetch_word_of_day) + local word_data + word_data=$(fetch_with_spinner "Looking up word..." fetch_word_of_day) - if [ -z "$word_data" ]; then - show_setup_message "$(echo_yellow ' ⚠ Could not fetch word of the day')" + if [[ -z "$word_data" ]]; then + show_setup_message "$(echo_yellow ' ⚠ Could not find word definition in dictionary')" return 0 fi - local word=$(jq_extract "$word_data" '.word') - local phonetic=$(jq_extract "$word_data" '.phonetic') - local part_of_speech=$(jq_extract "$word_data" '.partOfSpeech') - local definition=$(jq_extract "$word_data" '.definition') - local example=$(jq_extract "$word_data" '.example') + # Parse tab-separated values: word, phonetic, part_of_speech, definition + local word phonetic part_of_speech definition + word=$(echo "$word_data" | cut -f1) + phonetic=$(echo "$word_data" | cut -f2) + part_of_speech=$(echo "$word_data" | cut -f3) + definition=$(echo "$word_data" | cut -f4-) - word=$(safe_display "$word" "") - definition=$(safe_display "$definition" "") - - if [ -z "$word" ] || [ -z "$definition" ]; then + if [[ -z "$word" ]] || [[ -z "$definition" ]]; then show_setup_message "$(echo_yellow ' ⚠ Word data unavailable')" return 0 fi show_new_line - phonetic=$(safe_display "$phonetic" "") - if [ -n "$phonetic" ]; then + if [[ -n "$phonetic" ]]; then echo_cyan " 📖 $(echo_green "$word") $(echo_gray "$phonetic")" else echo_cyan " 📖 $(echo_green "$word")" fi - part_of_speech=$(safe_display "$part_of_speech" "") - if [ -n "$part_of_speech" ]; then + if [[ -n "$part_of_speech" ]]; then echo_gray " $part_of_speech" fi show_new_line - echo " $definition" | fold -s -w 70 | sed 's/^/ /' - - example=$(safe_display "$example" "") - if [ -n "$example" ] && [ "$example" != "N/A" ]; then - show_new_line - echo_gray " Example: \"$example\"" - fi + echo "$definition" | fold -s -w 70 | sed 's/^/ /' show_new_line } diff --git a/lib/setup/doctor.sh b/lib/setup/doctor.sh index 016fd5f..a14bff0 100755 --- a/lib/setup/doctor.sh +++ b/lib/setup/doctor.sh @@ -348,9 +348,13 @@ doctor_check_configured_paths() { doctor_check_api_keys() { validation_section "🔑 API Keys & Authentication" - # Weather uses wttr.in (free, no API key required) + # Weather uses wttr.in (free, requires location to be configured) if [[ "${GOODMORNING_SHOW_WEATHER:-true}" == "true" ]]; then - validation_pass "Weather: uses wttr.in (no API key required)" + if [[ -n "${GOODMORNING_WEATHER_LOCATION:-}" ]]; then + validation_pass "Weather: configured for ${GOODMORNING_WEATHER_LOCATION}" + else + validation_warn "Weather: GOODMORNING_WEATHER_LOCATION not set" "Set to your city (e.g., San_Francisco)" + fi else validation_info "Weather: disabled in config" fi diff --git a/setup.sh b/setup.sh index fdcb6ac..90b17c9 100755 --- a/setup.sh +++ b/setup.sh @@ -599,6 +599,17 @@ setup_section_features() { read -r response [[ -z "$response" || "$response" =~ ^[Yy]$ ]] && SETUP_SHOW_WEATHER="true" || SETUP_SHOW_WEATHER="false" + if [[ "$SETUP_SHOW_WEATHER" == "true" ]]; then + show_new_line + echo_gray " Weather requires a location (IP geolocation is unreliable)." + echo_gray " Use underscores for spaces (e.g., san_francisco, new_york)" + show_new_line + echo_green -n " Your city: " + read -r location_input + # Lowercase the location for wttr.in compatibility + SETUP_WEATHER_LOCATION="${location_input:l}" + fi + show_new_line section_box "📜 On This Day in History" show_new_line @@ -901,6 +912,7 @@ EOF # Briefing feature flags export GOODMORNING_SHOW_SETUP_MESSAGES="${SETUP_SHOW_SETUP_MESSAGES:-true}" export GOODMORNING_SHOW_WEATHER="${SETUP_SHOW_WEATHER:-true}" +export GOODMORNING_WEATHER_LOCATION="${SETUP_WEATHER_LOCATION:-}" export GOODMORNING_SHOW_HISTORY="${SETUP_SHOW_HISTORY:-true}" export GOODMORNING_SHOW_TECH_VERSIONS="${SETUP_SHOW_TECH_VERSIONS:-true}" export GOODMORNING_SHOW_SYSTEM_INFO="${SETUP_SHOW_SYSTEM_INFO:-true}" diff --git a/spec/lib/display_spec.sh b/spec/lib/display_spec.sh index aae722c..1b72c67 100644 --- a/spec/lib/display_spec.sh +++ b/spec/lib/display_spec.sh @@ -50,6 +50,116 @@ Describe 'lib/app/display.sh - Display Functions' The status should be success The output should not be blank End + + # Integration tests with mocked fetch_with_spinner + It 'returns early when GOODMORNING_WEATHER_LOCATION is not set' + GOODMORNING_WEATHER_LOCATION="" + SHOW_SETUP_MESSAGES="true" + When call show_weather + The status should be success + The output should include "Set GOODMORNING_WEATHER_LOCATION" + End + + Context 'with mocked fetch_with_spinner' + setup_basic_mock() { + fetch_with_spinner() { + shift # Skip the message parameter + # Verify we're being called with curl command + if [ "$1" != "curl" ]; then + echo "Expected curl command, got: $1" >&2 + return 1 + fi + echo "$MOCK_WEATHER_RESPONSE" + } + } + + Before 'setup_basic_mock' + + It 'successfully fetches weather when location is set' + GOODMORNING_WEATHER_LOCATION="San_Francisco" + MOCK_WEATHER_RESPONSE="San Francisco ☀️ +72°F" + When call show_weather + The status should be success + The output should include "☀️" + The output should include "+72°F" + End + + It 'handles not found response from API' + GOODMORNING_WEATHER_LOCATION="InvalidCity123" + MOCK_WEATHER_RESPONSE="not found: InvalidCity123" + When call show_weather + The status should be success + The output should include "Weather unavailable for" + The output should include "InvalidCity123" + End + + It 'handles empty response from API' + GOODMORNING_WEATHER_LOCATION="TestCity" + MOCK_WEATHER_RESPONSE="" + When call show_weather + The status should be success + The output should include "Weather unavailable for" + The output should include "TestCity" + End + End + + Context 'URL and parameter validation' + It 'uses location in wttr.in URL construction' + setup_url_check() { + fetch_with_spinner() { + shift # Skip message parameter + # Verify URL contains location + for arg in "$@"; do + if [[ "$arg" == *"wttr.in/New_York"* ]]; then + echo "New York 🌧️ +65°F" + return 0 + fi + done + echo "URL validation failed" >&2 + return 1 + } + } + + setup_url_check + GOODMORNING_WEATHER_LOCATION="New_York" + When call show_weather + The status should be success + The output should include "🌧️" + End + + It 'passes correct parameters to curl command' + setup_param_check() { + fetch_with_spinner() { + shift # Skip message parameter + # Check for expected curl command and flags + local has_curl=false + local has_silent=false + local has_timeout=false + local has_url=false + + for arg in "$@"; do + [[ "$arg" == "curl" ]] && has_curl=true + [[ "$arg" == "-s" ]] && has_silent=true + [[ "$arg" == "--max-time" ]] && has_timeout=true + [[ "$arg" == *"wttr.in/Boston"* ]] && has_url=true + done + + if $has_curl && $has_silent && $has_timeout && $has_url; then + echo "Boston ⛅ +68°F" + return 0 + fi + echo "Missing expected curl parameters" >&2 + return 1 + } + } + + setup_param_check + GOODMORNING_WEATHER_LOCATION="Boston" + When call show_weather + The status should be success + The output should include "⛅" + End + End End Describe 'show_history function'