Repository: wtfutil/wtf Branch: trunk Commit: 108cab807b76 Files: 519 Total size: 1.1 MB Directory structure: gitextract_iprghv20/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── Bug.md │ │ ├── Feature.md │ │ └── Support.md │ ├── PULL_REQUEST_TEMPLATE/ │ │ ├── Improvement.md │ │ └── Other.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ ├── stale.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── golangci-lint.yml │ ├── goreleaser.yml │ ├── pr-checks.yml │ └── staticcheck.yml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── SECURITY.md ├── _sample_configs/ │ ├── bargraph_config.yml │ ├── dynamic_sizing.yml │ ├── kubernetes_config.yml │ ├── sample_config.yml │ ├── small_config.yml │ └── uniconfig.yml ├── app/ │ ├── app_manager.go │ ├── display.go │ ├── exit_message.go │ ├── exit_message_test.go │ ├── focus_tracker.go │ ├── module_validator.go │ ├── module_validator_test.go │ ├── scheduler.go │ ├── scheduler_test.go │ ├── widget_maker.go │ ├── widget_maker_test.go │ └── wtf_app.go ├── cfg/ │ ├── common_settings.go │ ├── common_settings_test.go │ ├── config_files.go │ ├── copy.go │ ├── default_color_theme.go │ ├── default_color_theme_test.go │ ├── default_config_file.go │ ├── error_messages.go │ ├── parsers.go │ ├── parsers_test.go │ ├── position_settings.go │ ├── position_validation.go │ ├── position_validation_test.go │ ├── secrets.go │ ├── validatable.go │ ├── validations.go │ └── validations_test.go ├── checklist/ │ ├── checklist.go │ ├── checklist_item.go │ ├── checklist_item_test.go │ └── checklist_test.go ├── flags/ │ └── flags.go ├── generator/ │ ├── settings.tpl │ ├── textwidget.go │ └── textwidget.tpl ├── go.mod ├── go.sum ├── help/ │ └── help.go ├── logger/ │ └── log.go ├── main.go ├── modules/ │ ├── airbrake/ │ │ ├── client.go │ │ ├── group_info_table.go │ │ ├── keyboard.go │ │ ├── result_table.go │ │ ├── settings.go │ │ ├── util.go │ │ └── widget.go │ ├── asana/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── azuredevops/ │ │ ├── client.go │ │ ├── example-conf.yml │ │ ├── settings.go │ │ └── widget.go │ ├── azurelogs/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── query.go │ │ ├── query_concurrent_test.go │ │ ├── query_test.go │ │ ├── session.go │ │ ├── session_test.go │ │ ├── settings.go │ │ ├── settings_test.go │ │ ├── widget.go │ │ └── widget_test.go │ ├── bamboohr/ │ │ ├── calendar.go │ │ ├── client.go │ │ ├── employee.go │ │ ├── item.go │ │ ├── request.go │ │ ├── settings.go │ │ └── widget.go │ ├── bargraph/ │ │ ├── settings.go │ │ └── widget.go │ ├── buildkite/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── pipelines_display_data.go │ │ ├── settings.go │ │ └── widget.go │ ├── cds/ │ │ ├── favorites/ │ │ │ ├── display.go │ │ │ ├── keyboard.go │ │ │ ├── settings.go │ │ │ └── widget.go │ │ ├── queue/ │ │ │ ├── display.go │ │ │ ├── keyboard.go │ │ │ ├── settings.go │ │ │ └── widget.go │ │ └── status/ │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── circleci/ │ │ ├── build.go │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── clocks/ │ │ ├── clock.go │ │ ├── clock_collection.go │ │ ├── display.go │ │ ├── settings.go │ │ └── widget.go │ ├── cmdrunner/ │ │ ├── settings.go │ │ └── widget.go │ ├── cryptocurrency/ │ │ ├── bittrex/ │ │ │ ├── bittrex.go │ │ │ ├── display.go │ │ │ ├── settings.go │ │ │ └── widget.go │ │ ├── blockfolio/ │ │ │ ├── settings.go │ │ │ └── widget.go │ │ ├── cryptolive/ │ │ │ ├── price/ │ │ │ │ ├── price.go │ │ │ │ ├── settings.go │ │ │ │ └── widget.go │ │ │ ├── settings.go │ │ │ ├── toplist/ │ │ │ │ ├── display.go │ │ │ │ ├── settings.go │ │ │ │ ├── toplist.go │ │ │ │ └── widget.go │ │ │ └── widget.go │ │ └── mempool/ │ │ ├── settings.go │ │ └── widget.go │ ├── datadog/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── devto/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── digitalclock/ │ │ ├── clocks.go │ │ ├── display.go │ │ ├── fonts.go │ │ ├── settings.go │ │ └── widget.go │ ├── digitalocean/ │ │ ├── display.go │ │ ├── droplet.go │ │ ├── droplet_properties_table.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── docker/ │ │ ├── client.go │ │ ├── example-conf.yml │ │ ├── settings.go │ │ ├── utils.go │ │ └── widget.go │ ├── feedreader/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── widget.go │ │ └── widget_test.go │ ├── football/ │ │ ├── client.go │ │ ├── settings.go │ │ ├── types.go │ │ ├── util.go │ │ └── widget.go │ ├── gcal/ │ │ ├── cal_event.go │ │ ├── client.go │ │ ├── display.go │ │ ├── display_test.go │ │ ├── settings.go │ │ └── widget.go │ ├── gerrit/ │ │ ├── display.go │ │ ├── gerrit_repo.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── git/ │ │ ├── display.go │ │ ├── git_repo.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── variables.go │ │ ├── variables_win.go │ │ └── widget.go │ ├── github/ │ │ ├── display.go │ │ ├── github_repo.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── gitlab/ │ │ ├── display.go │ │ ├── gitlab_project.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── gitlabtodo/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── gitter/ │ │ ├── client.go │ │ ├── gitter.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── googleanalytics/ │ │ ├── client.go │ │ ├── display.go │ │ ├── settings.go │ │ └── widget.go │ ├── grafana/ │ │ ├── client.go │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── gspreadsheets/ │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── hackernews/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── story.go │ │ ├── story_test.go │ │ └── widget.go │ ├── healthchecks/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── hibp/ │ │ ├── client.go │ │ ├── hibp_breach.go │ │ ├── hibp_status.go │ │ ├── settings.go │ │ └── widget.go │ ├── ipaddresses/ │ │ ├── ipapi/ │ │ │ ├── settings.go │ │ │ └── widget.go │ │ └── ipinfo/ │ │ ├── settings.go │ │ └── widget.go │ ├── jenkins/ │ │ ├── client.go │ │ ├── job.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── view.go │ │ └── widget.go │ ├── jira/ │ │ ├── client.go │ │ ├── client_test.go │ │ ├── issues.go │ │ ├── keyboard.go │ │ ├── search_result.go │ │ ├── settings.go │ │ └── widget.go │ ├── krisinformation/ │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── kubernetes/ │ │ ├── client.go │ │ ├── settings.go │ │ ├── widget.go │ │ └── widget_test.go │ ├── logger/ │ │ ├── settings.go │ │ └── widget.go │ ├── lunarphase/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── mercurial/ │ │ ├── display.go │ │ ├── hg_repo.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── nbascore/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── newrelic/ │ │ ├── client/ │ │ │ ├── README.md │ │ │ ├── alert_conditions.go │ │ │ ├── alert_events.go │ │ │ ├── application_deployments.go │ │ │ ├── application_host_metrics.go │ │ │ ├── application_hosts.go │ │ │ ├── application_instance_metrics.go │ │ │ ├── application_instances.go │ │ │ ├── application_metrics.go │ │ │ ├── applications.go │ │ │ ├── array.go │ │ │ ├── browser_applications.go │ │ │ ├── component_metrics.go │ │ │ ├── http_helper.go │ │ │ ├── key_transactions.go │ │ │ ├── legacy_alert_policies.go │ │ │ ├── main.go │ │ │ ├── metrics.go │ │ │ ├── mobile_application_metrics.go │ │ │ ├── mobile_applications.go │ │ │ ├── notification_channels.go │ │ │ ├── server_metrics.go │ │ │ ├── servers.go │ │ │ └── usages.go │ │ ├── client.go │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── nextbus/ │ │ ├── settings.go │ │ └── widget.go │ ├── opsgenie/ │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── pagerduty/ │ │ ├── client.go │ │ ├── settings.go │ │ ├── sort.go │ │ └── widget.go │ ├── pihole/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── view.go │ │ └── widget.go │ ├── ping/ │ │ ├── settings.go │ │ └── widget.go │ ├── pivotal/ │ │ ├── client.go │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── structs.go │ │ ├── view.go │ │ └── widget.go │ ├── pocket/ │ │ ├── client.go │ │ ├── item_service.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── power/ │ │ ├── battery.go │ │ ├── battery_freebsd.go │ │ ├── battery_linux.go │ │ ├── managed_device_test.go │ │ ├── managed_devices.go │ │ ├── settings.go │ │ ├── source.go │ │ ├── source_freebsd.go │ │ ├── source_linux.go │ │ └── widget.go │ ├── progress/ │ │ ├── settings.go │ │ └── widget.go │ ├── resourceusage/ │ │ ├── settings.go │ │ └── widget.go │ ├── rollbar/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── rollbar.go │ │ ├── settings.go │ │ └── widget.go │ ├── security/ │ │ ├── dns.go │ │ ├── firewall.go │ │ ├── security_data.go │ │ ├── settings.go │ │ ├── users.go │ │ ├── widget.go │ │ └── wifi.go │ ├── spacex/ │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── spotify/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── spotifyweb/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── status/ │ │ ├── settings.go │ │ └── widget.go │ ├── steam/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── stocks/ │ │ ├── finnhub/ │ │ │ ├── client.go │ │ │ ├── quote.go │ │ │ ├── settings.go │ │ │ └── widget.go │ │ └── yfinance/ │ │ ├── settings.go │ │ ├── widget.go │ │ └── yquote.go │ ├── subreddit/ │ │ ├── api.go │ │ ├── keyboard.go │ │ ├── link.go │ │ ├── settings.go │ │ └── widget.go │ ├── system/ │ │ ├── settings.go │ │ ├── system_info.go │ │ ├── system_info_windows.go │ │ └── widget.go │ ├── textfile/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── todo/ │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── todo_plus/ │ │ ├── backend/ │ │ │ ├── backend.go │ │ │ ├── project.go │ │ │ ├── todoist.go │ │ │ └── trello.go │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── transmission/ │ │ ├── display.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── travisci/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ ├── travis.go │ │ └── widget.go │ ├── twitch/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── twitter/ │ │ ├── client.go │ │ ├── keyboard.go │ │ ├── request.go │ │ ├── settings.go │ │ ├── tweet.go │ │ ├── user.go │ │ └── widget.go │ ├── twitterstats/ │ │ ├── client.go │ │ ├── settings.go │ │ └── widget.go │ ├── unknown/ │ │ ├── settings.go │ │ └── widget.go │ ├── updown/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── uptimekuma/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── uptimerobot/ │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ ├── urlcheck/ │ │ ├── client.go │ │ ├── client_test.go │ │ ├── settings.go │ │ ├── urlResult.go │ │ ├── urlResult_test.go │ │ ├── view.go │ │ └── widget.go │ ├── victorops/ │ │ ├── client.go │ │ ├── oncallresponse.go │ │ ├── oncallteam.go │ │ ├── settings.go │ │ └── widget.go │ ├── weatherservices/ │ │ ├── arpansagovau/ │ │ │ ├── client.go │ │ │ ├── settings.go │ │ │ └── widget.go │ │ ├── prettyweather/ │ │ │ ├── settings.go │ │ │ └── widget.go │ │ └── weather/ │ │ ├── display.go │ │ ├── emoji.go │ │ ├── keyboard.go │ │ ├── settings.go │ │ └── widget.go │ └── zendesk/ │ ├── client.go │ ├── keyboard.go │ ├── settings.go │ ├── tickets.go │ └── widget.go ├── scripts/ │ └── check-uncommitted-vendor-files.sh ├── support/ │ └── github.go ├── utils/ │ ├── colors.go │ ├── colors_test.go │ ├── conversions.go │ ├── conversions_test.go │ ├── email_addresses.go │ ├── email_addresses_test.go │ ├── help_parser.go │ ├── homedir.go │ ├── homedir_test.go │ ├── init.go │ ├── init_test.go │ ├── reflective.go │ ├── sums.go │ ├── sums_test.go │ ├── text.go │ ├── text_test.go │ ├── utils.go │ └── utils_test.go ├── view/ │ ├── bargraph.go │ ├── bargraph_test.go │ ├── base.go │ ├── base_test.go │ ├── billboard_modal.go │ ├── info_table.go │ ├── info_table_test.go │ ├── keyboard_widget.go │ ├── keyboard_widget_test.go │ ├── multisource_widget.go │ ├── scrollable_widget.go │ ├── text_widget.go │ └── text_widget_test.go └── wtf/ ├── colors.go ├── colors_test.go ├── datetime.go ├── datetime_test.go ├── enablable.go ├── numbers.go ├── numbers_test.go ├── schedulable.go ├── stoppable.go ├── terminal.go └── wtfable.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true max_line_length=120 [*.go] indent_style = tab indent_size = 4 charset = utf-8 trim_trailing_whitespace = true [*.html] indent_style = tab indent_size = 4 charset = utf-8 trim_trailing_whitespace = false [*.md] trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ _site/* linguist-vendored docs/* linguist-vendored vendor/* linguist-vendored ================================================ FILE: .github/FUNDING.yml ================================================ github: FelicianoTech ================================================ FILE: .github/ISSUE_TEMPLATE/Bug.md ================================================ --- name: 🐞 Report a Bug about: Tell us what's broken --- ## What's broken? ================================================ FILE: .github/ISSUE_TEMPLATE/Feature.md ================================================ --- name: ⚡️ Request a Feature about: Tell us what it should do --- ## What should it do? ================================================ FILE: .github/ISSUE_TEMPLATE/Support.md ================================================ --- name: ❓Ask a Question about: Tell us how we can help --- ## How can we help? ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/Improvement.md ================================================ --- name: Improvement about: You have some improvement to make wtf better? --- Thanks for submitting a pull request. Please provide enough information so that others can review your pull request. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/Other.md ================================================ --- name: Other about: You have some other ideas you want to introduce? --- Thanks for submitting a pull request. Please provide enough information so that others can review your pull request. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ Thanks for submitting a pull request. Please provide enough information so that others can review your pull request. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gomod directory: "/" schedule: interval: daily open-pull-requests-limit: 10 assignees: - FelicianoTech - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 assignees: - FelicianoTech ================================================ FILE: .github/stale.yml ================================================ # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 180 # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. daysUntilClose: false # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) onlyLabels: [] # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - pinned - security - "[Status] Maybe Later" # Set to true to ignore issues in a project (defaults to false) exemptProjects: false # Set to true to ignore issues in a milestone (defaults to false) exemptMilestones: false # Set to true to ignore issues with an assignee (defaults to false) exemptAssignees: true # Label to use when marking as stale staleLabel: wontfix # Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when removing the stale label. # unmarkComment: > # Your comment here. # Comment to post when closing a stale Issue or Pull Request. # closeComment: > # Your comment here. # Limit the number of actions per hour, from 1-30. Default is 30 limitPerRun: 30 # Limit to only `issues` or `pulls` # only: issues # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': # pulls: # daysUntilStale: 30 # markComment: > # This pull request has been automatically marked as stale because it has not had # recent activity. It will be closed if no further activity occurs. Thank you # for your contributions. # issues: # exemptLabels: # - confirmed ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: "CodeQL Analysis" on: push: branches: - trunk pull_request: jobs: analyze: runs-on: ubuntu-24.04 steps: - name: "Checkout repository" uses: actions/checkout@v6.0.2 - name: "Initialize CodeQL" uses: github/codeql-action/init@v4 - name: "Compile code" uses: github/codeql-action/autobuild@v4 - name: "Perform CodeQL Analysis" uses: github/codeql-action/analyze@v4 ================================================ FILE: .github/workflows/golangci-lint.yml ================================================ name: golangci-lint on: push: tags: - v* branches: - trunk pull_request: jobs: golangci: name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.11 args: ./... --timeout=10m ================================================ FILE: .github/workflows/goreleaser.yml ================================================ name: goreleaser on: push: tags: - '*' jobs: goreleaser: runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6.2.0 with: go-version-file: 'go.mod' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6.4.0 with: version: 2.14.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} MASTODON_CLIENT_ID: ${{ secrets.MASTODON_CLIENT_ID }} MASTODON_CLIENT_SECRET: ${{ secrets.MASTODON_CLIENT_SECRET }} MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }} ================================================ FILE: .github/workflows/pr-checks.yml ================================================ name: "PR Checks" on: pull_request: branches: - trunk jobs: goreleaser: runs-on: ubuntu-24.04 steps: - name: "Checkout code" uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: "Set up Go" uses: actions/setup-go@v6.2.0 with: go-version-file: 'go.mod' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6.4.0 with: version: 2.14.0 args: release --snapshot ================================================ FILE: .github/workflows/staticcheck.yml ================================================ name: static check on: pull_request jobs: imports: name: Imports runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: check # uses: grandcolline/golang-github-actions@4356d0458ea4bfdb55fcb296437812acef970f9b uses: senorprogrammer/golang-github-actions@c2675d08254b17c070e524b3d907cfaf05fbae6f with: run: imports token: ${{ secrets.GITHUB_TOKEN }} errcheck: name: Errcheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: check # uses: grandcolline/golang-github-actions@4356d0458ea4bfdb55fcb296437812acef970f9b uses: senorprogrammer/golang-github-actions@c2675d08254b17c070e524b3d907cfaf05fbae6f with: run: errcheck token: ${{ secrets.GITHUB_TOKEN }} #lint: #name: Lint #runs-on: ubuntu-latest #steps: #- uses: actions/checkout@v6.0.2 #- name: check #uses: grandcolline/golang-github-actions@4356d04 #with: #run: lint #token: ${{ secrets.GITHUB_TOKEN }} shadow: name: Shadow runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: check # uses: grandcolline/golang-github-actions@4356d0458ea4bfdb55fcb296437812acef970f9b uses: senorprogrammer/golang-github-actions@c2675d08254b17c070e524b3d907cfaf05fbae6f with: run: shadow token: ${{ secrets.GITHUB_TOKEN }} staticcheck: name: StaticCheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - name: check # uses: grandcolline/golang-github-actions@4356d0458ea4bfdb55fcb296437812acef970f9b uses: senorprogrammer/golang-github-actions@c2675d08254b17c070e524b3d907cfaf05fbae6f with: run: staticcheck token: ${{ secrets.GITHUB_TOKEN }} #sec: #name: Sec #runs-on: ubuntu-latest #steps: #- uses: actions/checkout@v6.0.2 #- name: check #uses: grandcolline/golang-github-actions@4356d04 #with: #run: sec #token: ${{ secrets.GITHUB_TOKEN }} #flags: "-exclude=G104" ================================================ FILE: .gitignore ================================================ ### Go ### # Binaries for programs and plugins *.exe *.exe~ *.dll ftw* *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Misc .DS_Store gcal/client_secret.json gspreadsheets/client_secret.json profile.pdf report.* .vscode # All things node node_modules/ package-lock.json #intellij idea .idea/ dist/* bin/ ================================================ FILE: .gitmodules ================================================ ================================================ FILE: .golangci.yml ================================================ version: "2" run: timeout: 3m linters: enable: - govet - errcheck - staticcheck - unconvert exclusions: rules: - linters: - errcheck source: "^\\s*defer\\s+" formatters: enable: - gofmt ================================================ FILE: .goreleaser.yml ================================================ version: 2 builds: - binary: wtfutil goos: - darwin - linux goarch: - amd64 - arm - arm64 archives: - id: default homebrew_casks: - name: wtfutil homepage: 'https://wtfutil.com' description: 'The personal information dashboard for your terminal.' repository: owner: wtfutil name: homebrew-wtfutil hooks: post: # This hook is needed until this binary is signed and notarized install: | if system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0 system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/wtfutil"] end - name: wtfutil homepage: 'https://wtfutil.com' description: 'The personal information dashboard for your terminal.' repository: owner: linodians name: homebrew-tap hooks: post: # This hook is needed until this binary is signed and notarized install: | if system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0 system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/wtfutil"] end announce: mastodon: enabled: true server: "https://social.linodians.com" bluesky: enabled: true username: "wtfutil.bsky.social" discourse: enabled: true server: "https://discuss.linodians.com" username: "system" category_id: 7 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at chriscummer+wtf@me.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing ## Pull Request Process 1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. 2. Update the static documentation with details of changes to the interface, this includes new environment variables, useful file locations and configuration parameters. Documentation lives at [wtfdocs](https://github.com/wtfutil/wtfdocs) and is a [Hugo](https://gohugo.io) app. See Hugo's documentation for usage. ## Code of Conduct ### Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ### Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ### Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ### Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ### Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project owner at chriscummer@me.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ### Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: LICENSE.md ================================================ *Mozilla Public License, version 2.0* 1. Definitions 1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution. 1.3. “Contribution” means Covered Software of a particular Contributor. 1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. “Incompatible With Secondary Licenses” means that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. “Executable Form” means any form of the work other than Source Code Form. 1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. “License” means this document. 1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. “Modifications” means any of the following: any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or any new file in Source Code Form that contains any Covered Software. 1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. “Source Code Form” means the form of the work preferred for making modifications. 1.14. “You” (or “Your”) means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, “control” means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: for any code that a Contributor has removed from Covered Software; or for infringements caused by: (i) Your and any other third party’s modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. 6. Disclaimer of Warranty Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You. Should any Covered Software prove defective in any respect, You (not any Contributor) assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty constitutes an essential part of this License. No use of any Covered Software is authorized under this License except under this disclaimer. 7. Limitation of Liability Under no circumstances and under no legal theory, whether tort (including negligence), contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages. This limitation of liability shall not apply to liability for death or personal injury resulting from such party’s negligence to the extent applicable law prohibits such limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so this exclusion and limitation may not apply to You. 8. Litigation Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims. 9. Miscellaneous This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - “Incompatible With Secondary Licenses” Notice This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: Makefile ================================================ .PHONY: build clean contrib_check coverage docker-build docker-install help install isntall lint run size test uninstall # detect GOPATH if not set ifndef $(GOPATH) $(info GOPATH is not set, autodetecting..) TESTPATH := $(dir $(abspath ../../..)) DIRS := bin pkg src # create a ; separated line of tests and pass it to shell MISSING_DIRS := $(shell $(foreach entry,$(DIRS),test -d "$(TESTPATH)$(entry)" || echo "$(entry)";)) ifeq ($(MISSING_DIRS),) $(info Found GOPATH: $(TESTPATH)) export GOPATH := $(TESTPATH) else $(info ..missing dirs "$(MISSING_DIRS)" in "$(TESTDIR)") $(info GOPATH autodetection failed) endif endif # Set go modules to on and use GoCenter for immutable modules export GO111MODULE = on export GOPROXY = https://proxy.golang.org,direct # Determines the path to this Makefile THIS_FILE := $(lastword $(MAKEFILE_LIST)) GOBIN := $(GOPATH)/bin APP=wtfutil define HEADER ____ __ ____ .___________. _______ \ \ / \ / / | || ____| \ \/ \/ / `---| |----`| |__ \ / | | | __| \ /\ / | | | | \__/ \__/ |__| |__| endef export HEADER # -------------------- Actions -------------------- # ## build: builds a local version build: @echo "$$HEADER" @echo "Building..." go build -o bin/${APP} @echo "Done building" ## clean: removes old build cruft clean: rm -rf ./dist rm -rf ./bin/${APP} @echo "Done cleaning" ## contrib-check: checks for any contributors who have not been given due credit contrib-check: npx all-contributors-cli check ## coverage: figures out and displays test code coverage coverage: go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out ## docker-build: builds in docker docker-build: @echo "Building ${APP} in Docker..." docker build -t wtfutil:build --build-arg=version=master -f Dockerfile.build . @echo "Done with docker build" ## docker-install: installs a local version of the app from docker build docker-install: @echo "Installing..." docker create --name wtf_build wtfutil:build docker cp wtf_build:/usr/local/bin/wtfutil ~/.local/bin/ $(eval INSTALLPATH = $(shell which ${APP})) @echo "${APP} installed into ${INSTALLPATH}" docker rm wtf_build ## gosec: runs the gosec static security scanner against the source code gosec: $(GOBIN)/gosec gosec -tests ./... $(GOBIN)/gosec: cd && go install github.com/securego/gosec/v2/cmd/gosec@latest ## help: prints this help message help: @echo "Usage: \n" @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' ## isntall: an alias for 'install' isntall: @$(MAKE) -f $(THIS_FILE) install ## install: installs a local version of the app install: $(eval GOVERS = $(shell go version)) @echo "$$HEADER" @echo "Installing ${APP} with ${GOVERS}..." @go clean @go install -ldflags="-s -w" $(eval INSTALLPATH = $(shell which ${APP})) @echo "${APP} installed into ${INSTALLPATH}" ## lint: runs a number of code quality checks against the source code lint: $(GOBIN)/golangci-lint golangci-lint cache clean golangci-lint run $(GOBIN)/golangci-lint: cd && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # lint: # @echo "\033[35mhttps://github.com/kisielk/errcheck\033[0m" # errcheck ./app # errcheck ./cfg # errcheck ./flags # errcheck ./help # errcheck ./logger # errcheck ./modules/... # errcheck ./utils # errcheck ./view # errcheck ./wtf # errcheck ./main.go # @echo "\033[35mhttps://golang.org/cmd/vet/k\033[0m" # go vet ./app # go vet ./cfg # go vet ./flags # go vet ./help # go vet ./logger # go vet ./modules/... # go vet ./utils # go vet ./view # go vet ./wtf # go vet ./main.go # @echo "\033[35m# https://staticcheck.io/docs/k\033[0m" # staticcheck ./app # staticcheck ./cfg # staticcheck ./flags # staticcheck ./help # staticcheck ./logger # staticcheck ./modules/... # staticcheck ./utils # staticcheck ./view # staticcheck ./wtf # staticcheck ./main.go # @echo "\033[35m# https://github.com/mdempsky/unconvert\033[0m" # unconvert ./... ## loc: displays the lines of code (LoC) count loc: @loc --exclude _sample_configs/ _site/ docs/ Makefile *.md ## run: executes the locally-installed version run: build @echo "$$HEADER" bin/${APP} ## test: runs the test suite test: build @echo "$$HEADER" go test ./... ## uninstall: uninstals a locally-installed version uninstall: @rm $(GOBIN)/${APP} ================================================ FILE: README.md ================================================

WTF

[![GitHub Release](https://img.shields.io/github/v/release/wtfutil/wtf?logo=github&style=for-the-badge)](https://github.com/wtfutil/wtf/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/wtfutil/wtf?style=for-the-badge)](https://goreportcard.com/report/github.com/wtfutil/wtf) [![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscuss.linodians.com&logo=discourse&style=for-the-badge)](https://discuss.linodians.com/c/projects/wtf/7) [![Bluesky followers](https://img.shields.io/bluesky/followers/wtfutil.bsky.social?logo=bluesky&style=for-the-badge)](https://bsky.app/profile/wtfutil.bsky.social) [![Mastodon followers](https://img.shields.io/mastodon/follow/115007297910718188?domain=social.linodians.com&logo=mastodon&style=for-the-badge)](https://social.linodians.com/@WTFutil) ![Static Badge](https://img.shields.io/badge/LICENSE-MPL--2.0-orange?style=for-the-badge) --- WTF (aka 'wtfutil') is the personal information dashboard for your terminal, providing at-a-glance access to your very important but infrequently-needed stats and data. Used by thousands of developers and tech people around the world, WTF is free and open-source. To support the continued use and development of WTF, please consider sponsoring WTF via [GitHub Sponsors](https://github.com/sponsors/FelicianoTech). ### Are you a contributor or sponsor? Awesome! [See here](https://wtfutil.com/sponsors/exit_message/) for how you can change the exit message, the message WTF shows when quitting, to something special just for you. --- * [Installation](#installation) * [Installing via Homebrew](#installing-via-homebrew) * [Installing via `go install`](#installing-via-go-install) * [Installing via MacPorts](#installing-via-macports) * [Installing a Binary](#installing-a-binary) * [Installing from Source](#installing-from-source) * [Running via Docker](#running-via-docker) * [Communication](#communication) * [GitHub Discussions](#github-discussions) * [Twitter](#twitter) * [Documentation](#documentation) * [Modules](#modules) * [Getting Bugs Fixed or Features Added](#getting-bugs-fixed-or-features-added) * [Contributing to the Source Code](#contributing-to-the-source-code) * [Adding Dependencies](#adding-dependencies) * [Contributing to the Documentation](#contributing-to-the-documentation) * [Contributors](#contributors) * [Acknowledgements](#acknowledgments)

## Installation ### Installing via Homebrew The simplest way from Homebrew: ```console brew install wtfutil wtfutil ``` That version can sometimes lag a bit, as recipe updates take time to get accepted into `homebrew-core`. If you always want the bleeding edge of releases, you can tap it: ```console brew install linodians/tap/wtfutil wtfutil ``` ### Installing via `go install` Just run ```sh go install github.com/wtfutil/wtf@latest ``` ### Installing via MacPorts You can also install via [MacPorts](https://www.macports.org/): ```console sudo port selfupdate sudo port install wtfutil wtfutil ``` ### Installing a Binary [Download the latest binary](https://github.com/wtfutil/wtf/releases) from GitHub. WTF is a stand-alone binary. Once downloaded, copy it to a location you can run executables from (ie: `/usr/local/bin/`), and set the permissions accordingly: ```bash chmod a+x /usr/local/bin/wtfutil ``` and you should be good to go. ### Installing from Source If you want to run the build command from within your `$GOPATH`: ```bash # Set the Go proxy export GOPROXY="https://proxy.golang.org,direct" # Disable the Go checksum database export GOSUMDB=off # Enable Go modules export GO111MODULE=on go get -u github.com/wtfutil/wtf cd $GOPATH/src/github.com/wtfutil/wtf make install make run ``` If you want to run the build command from a folder that is not in your `$GOPATH`: ```bash # Set the Go proxy export GOPROXY="https://proxy.golang.org,direct" go get -u github.com/wtfutil/wtf cd $GOPATH/src/github.com/wtfutil/wtf make install make run ``` ### Installing via Arch User Repository Arch Linux users can utilise the [wtfutil](https://aur.archlinux.org/packages/wtfutil) package to build it from source, or [wtfutil-bin](https://aur.archlinux.org/packages/wtfutil-bin/) to install pre-built binaries. ## Documentation See [https://wtfutil.com](https://wtfutil.com) for the definitive documentation. Here's some short-cuts: * [Installation](https://wtfutil.com/quick_start/) * [Configuration](https://wtfutil.com/configuration/files/) * [Module Documentation](https://wtfutil.com/modules/) ## Modules Modules are the chunks of functionality that make WTF useful. Modules are added and configured by including their configuration values in your `config.yml` file. The documentation for each module describes how to configure them. Some interesting modules you might consider adding to get you started: * [DigitalOcean](https://wtfutil.com/modules/digitalocean/) * [GitHub](https://wtfutil.com/modules/github/) * [Google Calendar](https://wtfutil.com/modules/google/gcal/) * [HackerNews](https://wtfutil.com/modules/hackernews/) * [Have I Been Pwned](https://wtfutil.com/modules/hibp/) * [NewRelic](https://wtfutil.com/modules/newrelic/) * [OpsGenie](https://wtfutil.com/modules/opsgenie/) * [Security](https://wtfutil.com/modules/security/) * [Transmission](https://wtfutil.com/modules/transmission/) * [Trello](https://wtfutil.com/modules/trello/) ## Getting Bugs Fixed or Features Added WTF is open-source software, informally maintained by a small collection of volunteers who come and go at their leisure. There are absolutely no guarantees that, even if an issue is opened for them, bugs will be fixed or features added. If there is a bug that you really need to have fixed or a feature you really want to have implemented, you can greatly increase your chances of that happening by creating a bounty on [BountySource](https://www.bountysource.com) to provide an incentive for someone to tackle it. ## Contributing to the Source Code First, kindly read [Talk, then code](https://dave.cheney.net/2019/02/18/talk-then-code) by Dave Cheney. It's great advice and will often save a lot of time and effort. Next, kindly read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests. Then create your branch, write your code, submit your PR, and join the rest of the awesome people who've contributed their time and effort towards WTF. Without their contributors, WTF wouldn't be possible. Don't worry if you've never written Go before, or never contributed to an open source project before, or that your code won't be good enough. For a surprising number of people WTF has been their first Go project, or first open source contribution. If you're here, and you've read this far, you're the right stuff. ## Contributing to the Documentation Documentation now lives in its own repository here: [https://github.com/wtfutil/wtfdocs](https://github.com/wtfutil/wtfdocs). Please make all additions and updates to documentation in that repository. ### Adding Dependencies Dependency management in WTF is handled by [Go modules](https://github.com/golang/go/wiki/Modules). Please check out that page for more details on how Go modules work. ## Acknowledgments The inspiration for `WTF` came from Monica Dinculescu's [tiny-care-terminal](https://github.com/notwaldorf/tiny-care-terminal). WTF is built atop [tcell](https://github.com/gdamore/tcell) and [tview](https://github.com/rivo/tview), fantastic projects both. WTF is built, packaged, and deployed via [GoReleaser](https://goreleaser.com).

================================================ FILE: SECURITY.md ================================================ # Security Policy To file a security issue, open a new Issue in the Issues tab. ================================================ FILE: _sample_configs/bargraph_config.yml ================================================ wtf: colors: border: focusable: darkslateblue focused: orange normal: gray grid: columns: [40, 40] rows: [13, 13, 4] refreshInterval: 1 mods: bargraph: enabled: true graphIcon: "💀" graphStars: 25 position: top: 1 left: 0 height: 2 width: 2 refreshInterval: 30 ================================================ FILE: _sample_configs/dynamic_sizing.yml ================================================ wtf: mods: battery: type: power title: "⚡️" enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 15 security_info: type: security enabled: true position: top: 0 left: 1 height: 1 width: 1 refreshInterval: 3600 ================================================ FILE: _sample_configs/kubernetes_config.yml ================================================ wtf: colors: border: focusable: darkslateblue focused: orange normal: gray grid: columns: [32, 32, 32, 32, 32, 32] rows: [10, 10, 10, 10, 10, 10] refreshInterval: 2 mods: kubernetes: enabled: true kubeconfig: /Users/testuser/.kube/config namespaces: ["demo", "kube-system"] objects: ["nodes","deployments", "pods"] position: top: 0 left: 0 height: 6 width: 3 ================================================ FILE: _sample_configs/sample_config.yml ================================================ wtf: colors: background: black border: focusable: darkslateblue focused: orange normal: gray checked: yellow highlight: fore: black back: gray rows: even: yellow odd: white grid: # How _wide_ the columns are, in terminal characters. In this case we have # four columns, each of which are 35 characters wide. columns: [35, 35, 35, 35] # How _high_ the rows are, in terminal lines. In this case we have four rows # that support ten line of text and one of four. rows: [10, 10, 10, 10, 4] refreshInterval: 1 openFileUtil: "open" mods: # You can have multiple widgets of the same type. # The "key" is the name of the widget and the type is the actual # widget you want to implement. europe_time: title: "Europe" type: clocks colors: rows: even: "lightblue" odd: "white" enabled: true locations: GMT: "Etc/GMT" Amsterdam: "Europe/Amsterdam" Berlin: "Europe/Berlin" Barcelona: "Europe/Madrid" Copenhagen: "Europe/Copenhagen" London: "Europe/London" Rome: "Europe/Rome" Stockholm: "Europe/Stockholm" position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 15 sort: "alphabetical" americas_time: title: "Americas" type: clocks colors: rows: even: "lightblue" odd: "white" enabled: true locations: UTC: "Etc/UTC" Vancouver: "America/Vancouver" New_York: "America/New_York" Sao_Paulo: "America/Sao_Paulo" Denver: "America/Denver" Iqaluit: "America/Iqaluit" Bahamas: "America/Nassau" Chicago: "America/Chicago" position: top: 0 left: 1 height: 1 width: 1 refreshInterval: 15 sort: "alphabetical" battery: type: power title: "⚡️" enabled: true position: top: 1 left: 3 height: 1 width: 1 refreshInterval: 15 todolist: type: todo checkedIcon: "X" colors: checked: gray highlight: fore: "black" back: "orange" enabled: true filename: "todo.yml" position: top: 1 left: 0 height: 2 width: 1 refreshInterval: 3600 ip: type: ipinfo title: "My IP" colors: name: "lightblue" value: "white" enabled: true position: top: 0 left: 2 height: 1 width: 2 refreshInterval: 150 security_info: type: security title: "Staying safe" enabled: true position: top: 1 left: 2 height: 1 width: 1 refreshInterval: 3600 readme: type: textfile enabled: true filePaths: - "~/.config/wtf/config.yml" format: true formatStyle: "monokai" position: top: 1 left: 1 height: 1 width: 1 refreshInterval: 15 news: type: hackernews title: "HackerNews" enabled: true numberOfStories: 10 position: top: 2 left: 1 height: 1 width: 3 storyType: top refreshInterval: 900 resources: type: resourceusage enabled: true position: top: 3 left: 0 height: 2 width: 1 refreshInterval: 1 uptime: type: cmdrunner args: [] cmd: "uptime" enabled: true position: top: 4 left: 1 height: 1 width: 3 refreshInterval: 30 disks: type: cmdrunner cmd: "df" args: ["-h"] enabled: true position: top: 3 left: 1 height: 1 width: 3 refreshInterval: 3600 ================================================ FILE: _sample_configs/small_config.yml ================================================ wtf: grid: columns: [20, 20] rows: [3, 3] refreshInterval: 1 mods: uptime: type: cmdrunner args: [] cmd: "uptime" enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 30 ================================================ FILE: _sample_configs/uniconfig.yml ================================================ wtf: colors: background: black border: focusable: darkslateblue grid: columns: [40, 40] rows: [16] refreshInterval: 1 mods: americas_time: title: "Americas" type: clocks enabled: true locations: UTC: "Etc/UTC" Vancouver: "America/Vancouver" New_York: "America/New_York" Sao_Paolo: "America/Sao_Paulo" Denver: "America/Denver" Iqaluit: "America/Iqaluit" Bahamas: "America/Nassau" Chicago: "America/Chicago" position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 15 sort: "chronological" textfile: enabled: true filePaths: - "~/.config/wtf/config.yml" format: true formatStyle: "vim" position: top: 0 left: 1 height: 1 width: 1 refreshInterval: 15 ================================================ FILE: app/app_manager.go ================================================ package app import ( "errors" "github.com/olebedev/config" "github.com/rivo/tview" ) // WtfAppManager handles the instances of WtfApp, ensuring that they're displayed as requested type WtfAppManager struct { WtfApps []*WtfApp selected int } // NewAppManager creates and returns an instance of AppManager func NewAppManager() WtfAppManager { appMan := WtfAppManager{ WtfApps: []*WtfApp{}, } return appMan } // MakeNewWtfApp creates and starts a new instance of WtfApp from a set of configuration params func (appMan *WtfAppManager) MakeNewWtfApp(config *config.Config, configFilePath string) { wtfApp := NewWtfApp(tview.NewApplication(), config, configFilePath) appMan.Add(wtfApp) wtfApp.Start() } // Add adds a WtfApp to the collection of apps that the AppManager manages. // This app is then available for display onscreen. func (appMan *WtfAppManager) Add(wtfApp *WtfApp) { appMan.WtfApps = append(appMan.WtfApps, wtfApp) } // Current returns the currently-displaying instance of WtfApp func (appMan *WtfAppManager) Current() (*WtfApp, error) { if appMan.selected < 0 || appMan.selected >= len(appMan.WtfApps) { return nil, errors.New("invalid app index selected") } return appMan.WtfApps[appMan.selected], nil } // Next cycles the WtfApps forward by one, making the next one in the list // the current one. If there are none after the current one, it wraps around. func (appMan *WtfAppManager) Next() (*WtfApp, error) { appMan.selected++ if appMan.selected >= len(appMan.WtfApps) { appMan.selected = 0 } return appMan.Current() } // Prev cycles the WtfApps backwards by one, making the previous one in the // list the current one. If there are none before the current one, it wraps around. func (appMan *WtfAppManager) Prev() (*WtfApp, error) { appMan.selected-- if appMan.selected < 0 { appMan.selected = len(appMan.WtfApps) - 1 } return appMan.Current() } ================================================ FILE: app/display.go ================================================ package app import ( "github.com/olebedev/config" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/wtf" ) // Display is the container for the onscreen representation of a WtfApp type Display struct { Grid *tview.Grid config *config.Config } // NewDisplay creates and returns a Display func NewDisplay(widgets []wtf.Wtfable, config *config.Config) *Display { display := Display{ Grid: tview.NewGrid(), config: config, } firstWidget := widgets[0] display.Grid.SetBackgroundColor( wtf.ColorFor( firstWidget.CommonSettings().Colors.Background, ), ) display.build(widgets) return &display } /* -------------------- Unexported Functions -------------------- */ func (display *Display) add(widget wtf.Wtfable) { if widget.Disabled() { return } display.Grid.AddItem( widget.TextView(), widget.CommonSettings().Top, widget.CommonSettings().Left, widget.CommonSettings().Height, widget.CommonSettings().Width, 0, 0, false, ) } func (display *Display) build(widgets []wtf.Wtfable) *tview.Grid { cols := utils.ToInts(display.config.UList("wtf.grid.columns")) rows := utils.ToInts(display.config.UList("wtf.grid.rows")) display.Grid.SetColumns(cols...) display.Grid.SetRows(rows...) display.Grid.SetBorder(false) for _, widget := range widgets { display.add(widget) } return display.Grid } ================================================ FILE: app/exit_message.go ================================================ package app import ( "fmt" "os" "strings" "github.com/logrusorgru/aurora/v4" "github.com/olebedev/config" ) const exitMessageHeader = ` ____ __ ____ .___________. _______ \ \ / \ / / | || ____| \ \/ \/ / ----| |-----| |__ \ / | | | __| \ /\ / | | | | \__/ \__/ |__| |__| the personal information dashboard for your terminal ` // DisplayExitMessage displays the onscreen exit message when the app quits func (wtfApp *WtfApp) DisplayExitMessage() { exitMessageIsDisplayable := readDisplayableConfig(wtfApp.config) wtfApp.displayExitMsg(exitMessageIsDisplayable) } /* -------------------- Unexported Functions -------------------- */ func (wtfApp *WtfApp) displayExitMsg(exitMessageIsDisplayable bool) string { // If a sponsor or contributor and opt out of seeing the exit message, do not display it if (wtfApp.ghUser.IsContributor || wtfApp.ghUser.IsSponsor) && !exitMessageIsDisplayable { return "" } msgs := []string{} msgs = append(msgs, aurora.Magenta(exitMessageHeader).String()) if wtfApp.ghUser.IsContributor { msgs = append(msgs, wtfApp.contributorThankYouMessage()) } if wtfApp.ghUser.IsSponsor { msgs = append(msgs, wtfApp.sponsorThankYouMessage()) } if !wtfApp.ghUser.IsContributor && !wtfApp.ghUser.IsSponsor { msgs = append(msgs, wtfApp.supportRequestMessage()) } displayMsg := strings.Join(msgs, "\n") fmt.Println(displayMsg) return displayMsg } // readDisplayableConfig figures out whether or not the exit message should be displayed // per the user's wishes. It allows contributors and sponsors to opt out of the exit message func readDisplayableConfig(cfg *config.Config) bool { displayExitMsg := cfg.UBool("wtf.exitMessage.display", true) return displayExitMsg } // readGitHubAPIKey attempts to find a GitHub API key somewhere in the configuration file func readGitHubAPIKey(cfg *config.Config) string { apiKey := cfg.UString("wtf.exitMessage.githubAPIKey", os.Getenv("WTF_GITHUB_TOKEN")) if apiKey != "" { return apiKey } moduleConfig, err := cfg.Get("wtf.mods.github") if err != nil { return "" } return moduleConfig.UString("apiKey", "") } /* -------------------- Messaging -------------------- */ func (wtfApp *WtfApp) contributorThankYouMessage() string { str := " On behalf of all the users of WTF, thank you for contributing to the source code." str += fmt.Sprintf(" %s", aurora.Green("\n\n You rock.")) return str } func (wtfApp *WtfApp) sponsorThankYouMessage() string { str := " Your sponsorship of WTF makes a difference. Thank you for sponsoring and supporting WTF." str += fmt.Sprintf(" %s", aurora.Green("\n\n You're awesome.")) return str } func (wtfApp *WtfApp) supportRequestMessage() string { str := " The development and maintenance of WTF is supported by sponsorships.\n" str += fmt.Sprintf(" Sponsor the development of WTF at %s\n", aurora.Green("https://github.com/sponsors/FelicianoTech")) return str } ================================================ FILE: app/exit_message_test.go ================================================ package app import ( "strings" "testing" "github.com/wtfutil/wtf/support" "gotest.tools/assert" ) func Test_displayExitMessage(t *testing.T) { tests := []struct { name string isDisplayable bool isContributor bool isSponsor bool compareWith string expected string }{ { name: "when not displayable", isDisplayable: false, isContributor: true, isSponsor: true, compareWith: "equals", expected: "", }, { name: "when contributor", isDisplayable: true, isContributor: true, compareWith: "contains", expected: "thank you for contributing", }, { name: "when sponsor", isDisplayable: true, isSponsor: true, compareWith: "contains", expected: "Thank you for sponsoring", }, { name: "when user", isDisplayable: true, isContributor: false, isSponsor: false, compareWith: "contains", expected: "supported by sponsorships", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { wtfApp := WtfApp{} wtfApp.ghUser = &support.GitHubUser{ IsContributor: tt.isContributor, IsSponsor: tt.isSponsor, } actual := wtfApp.displayExitMsg(tt.isDisplayable) if tt.compareWith == "equals" { assert.Equal(t, actual, tt.expected) } if tt.compareWith == "contains" { assert.Equal(t, true, strings.Contains(actual, tt.expected)) } }) } } ================================================ FILE: app/focus_tracker.go ================================================ package app import ( "fmt" "sort" "github.com/olebedev/config" "github.com/rivo/tview" "github.com/wtfutil/wtf/wtf" ) // FocusState is a custom type that differentiates focusable scopes type FocusState int const ( widgetFocused FocusState = iota appBoardFocused neverFocused ) // FocusTracker is used by the app to track which onscreen widget currently has focus, // and to move focus between widgets. type FocusTracker struct { Idx int IsFocused bool Widgets []wtf.Wtfable config *config.Config tviewApp *tview.Application } // NewFocusTracker creates and returns an instance of FocusTracker func NewFocusTracker(tviewApp *tview.Application, widgets []wtf.Wtfable, config *config.Config) FocusTracker { focusTracker := FocusTracker{ tviewApp: tviewApp, Idx: -1, IsFocused: false, Widgets: widgets, config: config, } focusTracker.assignHotKeys() return focusTracker } /* -------------------- Exported Functions -------------------- */ // FocusOn puts the focus on the item that belongs to the focus character passed in func (tracker *FocusTracker) FocusOn(char string) bool { if !tracker.useNavShortcuts() { return false } if tracker.focusState() == appBoardFocused { return false } hasFocusable := false for idx, focusable := range tracker.focusables() { if focusable.FocusChar() == char { tracker.blur(tracker.Idx) tracker.Idx = idx tracker.focus(tracker.Idx) hasFocusable = true tracker.IsFocused = true break } } return hasFocusable } // Next sets the focus on the next widget in the widget list. If the current widget is // the last widget, sets focus on the first widget. func (tracker *FocusTracker) Next() { if tracker.focusState() == appBoardFocused { return } tracker.blur(tracker.Idx) tracker.increment() tracker.focus(tracker.Idx) tracker.IsFocused = true } // None removes focus from the currently-focused widget. func (tracker *FocusTracker) None() { if tracker.focusState() == appBoardFocused { return } tracker.blur(tracker.Idx) } // Prev sets the focus on the previous widget in the widget list. If the current widget is // the last widget, sets focus on the last widget. func (tracker *FocusTracker) Prev() { if tracker.focusState() == appBoardFocused { return } tracker.blur(tracker.Idx) tracker.decrement() tracker.focus(tracker.Idx) tracker.IsFocused = true } // Refocus forces the focus back to the currently-selected item func (tracker *FocusTracker) Refocus() { tracker.focus(tracker.Idx) } /* -------------------- Unexported Functions -------------------- */ // AssignHotKeys assigns an alphabetic keyboard character to each focusable // widget so that the widget can be brought into focus by pressing that keyboard key // Valid numbers are between 1 and 9, inclusive func (tracker *FocusTracker) assignHotKeys() { if !tracker.useNavShortcuts() { return } usedKeys := make(map[string]bool) focusables := tracker.focusables() // First, block out the explicitly-defined characters so they can't be automatically // assigned to other modules for _, focusable := range focusables { if focusable.FocusChar() != "" { usedKeys[focusable.FocusChar()] = true } } focusNum := 1 // Range over all the modules and assign focus characters to any that are focusable // and don't have explicitly-defined focus characters for _, focusable := range focusables { if focusable.FocusChar() != "" { continue } if _, foundKey := usedKeys[fmt.Sprint(focusNum)]; foundKey { for ; foundKey; _, foundKey = usedKeys[fmt.Sprint(focusNum)] { focusNum++ } } // Don't allow focus characters > "9" if focusNum >= 10 { break } focusable.SetFocusChar(fmt.Sprint(focusNum)) focusNum++ } } func (tracker *FocusTracker) blur(idx int) { widget := tracker.focusableAt(idx) if widget == nil { return } view := widget.TextView() view.Blur() view.SetBorderColor( wtf.ColorFor( widget.BorderColor(), ), ) tracker.IsFocused = false } func (tracker *FocusTracker) decrement() { tracker.Idx-- if tracker.Idx < 0 { tracker.Idx = len(tracker.focusables()) - 1 } } func (tracker *FocusTracker) focus(idx int) { widget := tracker.focusableAt(idx) if widget == nil { return } view := widget.TextView() view.SetBorderColor( wtf.ColorFor( widget.CommonSettings().Colors.Focused, ), ) tracker.tviewApp.SetFocus(view) } func (tracker *FocusTracker) focusables() []wtf.Wtfable { focusable := []wtf.Wtfable{} for _, widget := range tracker.Widgets { if widget.Focusable() { focusable = append(focusable, widget) } } // Sort for deterministic ordering sort.SliceStable(focusable, func(i, j int) bool { iTop := focusable[i].CommonSettings().Top jTop := focusable[j].CommonSettings().Top if iTop < jTop { return true } if iTop == jTop { return focusable[i].CommonSettings().Left < focusable[j].CommonSettings().Left } return false }) return focusable } func (tracker *FocusTracker) focusableAt(idx int) wtf.Wtfable { if idx < 0 || idx >= len(tracker.focusables()) { return nil } return tracker.focusables()[idx] } func (tracker *FocusTracker) focusState() FocusState { if tracker.Idx < 0 { return neverFocused } for _, widget := range tracker.Widgets { if widget.TextView() == tracker.tviewApp.GetFocus() { return widgetFocused } } return appBoardFocused } func (tracker *FocusTracker) increment() { tracker.Idx++ if tracker.Idx == len(tracker.focusables()) { tracker.Idx = 0 } } func (tracker *FocusTracker) useNavShortcuts() bool { return tracker.config.UBool("wtf.navigation.shortcuts", true) } ================================================ FILE: app/module_validator.go ================================================ package app import ( "fmt" "os" "github.com/logrusorgru/aurora/v4" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/wtf" ) // ModuleValidator is responsible for validating the state of a module's configuration type ModuleValidator struct{} type widgetError struct { name string validationErrors []cfg.Validatable } // NewModuleValidator creates and returns an instance of ModuleValidator func NewModuleValidator() *ModuleValidator { return &ModuleValidator{} } // Validate rolls through all the enabled widgets and looks for configuration errors. // If it finds any it stringifies them, writes them to the console, and kills the app gracefully func (val *ModuleValidator) Validate(widgets []wtf.Wtfable) { validationErrors := validate(widgets) if len(validationErrors) > 0 { fmt.Println() for _, error := range validationErrors { for _, message := range error.errorMessages() { fmt.Println(message) } } fmt.Println() os.Exit(1) } } func validate(widgets []wtf.Wtfable) (widgetErrors []widgetError) { for _, widget := range widgets { err := widgetError{name: widget.Name()} for _, val := range widget.CommonSettings().Validations() { if val.HasError() { err.validationErrors = append(err.validationErrors, val) } } if len(err.validationErrors) > 0 { widgetErrors = append(widgetErrors, err) } } return widgetErrors } func (err widgetError) errorMessages() (messages []string) { widgetMessage := fmt.Sprintf( "%s in %s configuration", aurora.Red("Errors"), aurora.Yellow( fmt.Sprintf( "%s.position", err.name, ), ), ) messages = append(messages, widgetMessage) for _, e := range err.validationErrors { configMessage := fmt.Sprintf(" - %s\t%s %v", e.String(), aurora.Red("Error:"), e.Error()) messages = append(messages, configMessage) } return messages } ================================================ FILE: app/module_validator_test.go ================================================ package app import ( "fmt" "testing" "github.com/logrusorgru/aurora/v4" "github.com/olebedev/config" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/wtf" ) const ( valid = ` wtf: mods: clocks: enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 30` invalid = ` wtf: mods: clocks: enabled: true position: top: abc left: 0 height: 1 width: 1 refreshInterval: 30` ) func Test_NewModuleValidator(t *testing.T) { assert.IsType(t, &ModuleValidator{}, NewModuleValidator()) } func Test_validate(t *testing.T) { tests := []struct { name string moduleName string config *config.Config expected []string }{ { name: "valid config", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(valid) return cfg }(), expected: []string{}, }, { name: "invalid config", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(invalid) return cfg }(), expected: []string{ fmt.Sprintf("%s in %s configuration", aurora.Red("Errors"), aurora.Yellow("clocks.position")), fmt.Sprintf( " - Invalid value for %s: 0 %s strconv.ParseInt: parsing \"abc\": invalid syntax", aurora.Yellow("top"), aurora.Red("Error:"), ), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { widget := MakeWidget(nil, nil, tt.moduleName, tt.config, make(chan bool)) if widget == nil { t.Logf("Failed to create widget %s", tt.moduleName) t.FailNow() } errs := validate([]wtf.Wtfable{widget}) if len(tt.expected) == 0 { assert.Empty(t, errs) } else { assert.NotEmpty(t, errs) var actual []string for _, err := range errs { actual = append(actual, err.errorMessages()...) } assert.Equal(t, tt.expected, actual) } }) } } ================================================ FILE: app/scheduler.go ================================================ package app import ( "time" "github.com/wtfutil/wtf/wtf" ) // Schedule kicks off the first refresh of a module's data and then queues the rest of the // data refreshes on a timer func Schedule(widget wtf.Wtfable) { widget.Refresh() interval := widget.CommonSettings().RefreshInterval if interval <= 0 { return } timer := time.NewTicker(interval) for { select { case <-timer.C: if widget.Enabled() { widget.Refresh() } else { timer.Stop() return } case quit := <-widget.QuitChan(): if quit { timer.Stop() return } } } } ================================================ FILE: app/scheduler_test.go ================================================ package app import ( "testing" "time" "github.com/olebedev/config" ) const ( configExample = ` wtf: mods: clocks: enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 2` new = ` wtf: mods: clocks: enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 100ms` ) func Test_RefreshInterval(t *testing.T) { t.Skip() // slow running test because a ticker is tested tests := []struct { name string moduleName string config *config.Config testAttempts int expected time.Duration }{ { name: "slow ticking module", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(configExample) return cfg }(), testAttempts: 10, expected: 2 * time.Second, }, { name: "fast ticking module", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(new) return cfg }(), testAttempts: 10, expected: 100 * time.Millisecond, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { widget := MakeWidget(nil, nil, tt.moduleName, tt.config, make(chan bool)) interval := widget.CommonSettings().RefreshInterval // same declaration as in scheduler.go#Schedule timer := time.NewTicker(interval) attempts := 0 for { select { case <-timer.C: attempts++ if attempts == tt.testAttempts { return } // allow for small window (50ms) where a timeout is not triggered case <-time.After(tt.expected + 50*time.Millisecond): t.Error("Timeout") } } }) } } ================================================ FILE: app/widget_maker.go ================================================ package app import ( "github.com/olebedev/config" "github.com/rivo/tview" "github.com/wtfutil/wtf/modules/airbrake" "github.com/wtfutil/wtf/modules/asana" "github.com/wtfutil/wtf/modules/azuredevops" "github.com/wtfutil/wtf/modules/azurelogs" "github.com/wtfutil/wtf/modules/bamboohr" "github.com/wtfutil/wtf/modules/bargraph" "github.com/wtfutil/wtf/modules/buildkite" cdsfavorites "github.com/wtfutil/wtf/modules/cds/favorites" cdsqueue "github.com/wtfutil/wtf/modules/cds/queue" cdsstatus "github.com/wtfutil/wtf/modules/cds/status" "github.com/wtfutil/wtf/modules/circleci" "github.com/wtfutil/wtf/modules/clocks" "github.com/wtfutil/wtf/modules/cmdrunner" "github.com/wtfutil/wtf/modules/cryptocurrency/bittrex" "github.com/wtfutil/wtf/modules/cryptocurrency/blockfolio" "github.com/wtfutil/wtf/modules/cryptocurrency/cryptolive" "github.com/wtfutil/wtf/modules/cryptocurrency/mempool" "github.com/wtfutil/wtf/modules/datadog" "github.com/wtfutil/wtf/modules/devto" "github.com/wtfutil/wtf/modules/digitalclock" "github.com/wtfutil/wtf/modules/digitalocean" "github.com/wtfutil/wtf/modules/docker" "github.com/wtfutil/wtf/modules/feedreader" "github.com/wtfutil/wtf/modules/football" "github.com/wtfutil/wtf/modules/gcal" "github.com/wtfutil/wtf/modules/gerrit" "github.com/wtfutil/wtf/modules/git" "github.com/wtfutil/wtf/modules/github" "github.com/wtfutil/wtf/modules/gitlab" "github.com/wtfutil/wtf/modules/gitlabtodo" "github.com/wtfutil/wtf/modules/gitter" "github.com/wtfutil/wtf/modules/googleanalytics" "github.com/wtfutil/wtf/modules/grafana" "github.com/wtfutil/wtf/modules/gspreadsheets" "github.com/wtfutil/wtf/modules/hackernews" "github.com/wtfutil/wtf/modules/healthchecks" "github.com/wtfutil/wtf/modules/hibp" "github.com/wtfutil/wtf/modules/ipaddresses/ipapi" "github.com/wtfutil/wtf/modules/ipaddresses/ipinfo" "github.com/wtfutil/wtf/modules/jenkins" "github.com/wtfutil/wtf/modules/jira" "github.com/wtfutil/wtf/modules/krisinformation" "github.com/wtfutil/wtf/modules/kubernetes" "github.com/wtfutil/wtf/modules/logger" "github.com/wtfutil/wtf/modules/lunarphase" "github.com/wtfutil/wtf/modules/mercurial" "github.com/wtfutil/wtf/modules/nbascore" "github.com/wtfutil/wtf/modules/newrelic" "github.com/wtfutil/wtf/modules/nextbus" "github.com/wtfutil/wtf/modules/opsgenie" "github.com/wtfutil/wtf/modules/pagerduty" "github.com/wtfutil/wtf/modules/pihole" "github.com/wtfutil/wtf/modules/ping" "github.com/wtfutil/wtf/modules/pivotal" "github.com/wtfutil/wtf/modules/pocket" "github.com/wtfutil/wtf/modules/power" "github.com/wtfutil/wtf/modules/progress" "github.com/wtfutil/wtf/modules/resourceusage" "github.com/wtfutil/wtf/modules/rollbar" "github.com/wtfutil/wtf/modules/security" "github.com/wtfutil/wtf/modules/spacex" "github.com/wtfutil/wtf/modules/spotify" "github.com/wtfutil/wtf/modules/spotifyweb" "github.com/wtfutil/wtf/modules/status" "github.com/wtfutil/wtf/modules/steam" "github.com/wtfutil/wtf/modules/stocks/finnhub" "github.com/wtfutil/wtf/modules/stocks/yfinance" "github.com/wtfutil/wtf/modules/subreddit" "github.com/wtfutil/wtf/modules/textfile" "github.com/wtfutil/wtf/modules/todo" "github.com/wtfutil/wtf/modules/todo_plus" "github.com/wtfutil/wtf/modules/transmission" "github.com/wtfutil/wtf/modules/travisci" "github.com/wtfutil/wtf/modules/twitch" "github.com/wtfutil/wtf/modules/twitter" "github.com/wtfutil/wtf/modules/twitterstats" "github.com/wtfutil/wtf/modules/unknown" "github.com/wtfutil/wtf/modules/updown" "github.com/wtfutil/wtf/modules/uptimekuma" "github.com/wtfutil/wtf/modules/uptimerobot" "github.com/wtfutil/wtf/modules/urlcheck" "github.com/wtfutil/wtf/modules/victorops" "github.com/wtfutil/wtf/modules/weatherservices/arpansagovau" "github.com/wtfutil/wtf/modules/weatherservices/prettyweather" "github.com/wtfutil/wtf/modules/weatherservices/weather" "github.com/wtfutil/wtf/modules/zendesk" "github.com/wtfutil/wtf/wtf" ) // MakeWidget creates and returns instances of widgets func MakeWidget( tviewApp *tview.Application, pages *tview.Pages, moduleName string, config *config.Config, redrawChan chan bool, ) wtf.Wtfable { var widget wtf.Wtfable moduleConfig, _ := config.Get("wtf.mods." + moduleName) // Don' try to initialize modules that don't exist if moduleConfig == nil { return nil } // Don't try to initialize modules that aren't enabled if enabled := moduleConfig.UBool("enabled", false); !enabled { return nil } // Always in alphabetical order switch moduleConfig.UString("type", moduleName) { case "airbrake": settings := airbrake.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = airbrake.NewWidget(tviewApp, redrawChan, pages, settings) case "arpansagovau": settings := arpansagovau.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = arpansagovau.NewWidget(tviewApp, redrawChan, settings) case "asana": settings := asana.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = asana.NewWidget(tviewApp, redrawChan, pages, settings) case "azuredevops": settings := azuredevops.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = azuredevops.NewWidget(tviewApp, redrawChan, pages, settings) case "azurelogs": settings := azurelogs.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = azurelogs.NewWidget(tviewApp, redrawChan, pages, settings) case "bamboohr": settings := bamboohr.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = bamboohr.NewWidget(tviewApp, redrawChan, settings) case "bargraph": settings := bargraph.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = bargraph.NewWidget(tviewApp, redrawChan, settings) case "bittrex": settings := bittrex.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = bittrex.NewWidget(tviewApp, redrawChan, settings) case "blockfolio": settings := blockfolio.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = blockfolio.NewWidget(tviewApp, redrawChan, settings) case "buildkite": settings := buildkite.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = buildkite.NewWidget(tviewApp, redrawChan, pages, settings) case "cdsFavorites": settings := cdsfavorites.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = cdsfavorites.NewWidget(tviewApp, redrawChan, pages, settings) case "cdsQueue": settings := cdsqueue.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = cdsqueue.NewWidget(tviewApp, redrawChan, pages, settings) case "cdsStatus": settings := cdsstatus.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = cdsstatus.NewWidget(tviewApp, redrawChan, pages, settings) case "circleci": settings := circleci.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = circleci.NewWidget(tviewApp, redrawChan, settings) case "clocks": settings := clocks.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = clocks.NewWidget(tviewApp, redrawChan, settings) case "cmdrunner": settings := cmdrunner.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = cmdrunner.NewWidget(tviewApp, redrawChan, settings) case "cryptolive": settings := cryptolive.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = cryptolive.NewWidget(tviewApp, redrawChan, settings) case "datadog": settings := datadog.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = datadog.NewWidget(tviewApp, redrawChan, pages, settings) case "devto": settings := devto.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = devto.NewWidget(tviewApp, redrawChan, pages, settings) case "digitalclock": settings := digitalclock.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = digitalclock.NewWidget(tviewApp, redrawChan, settings) case "digitalocean": settings := digitalocean.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = digitalocean.NewWidget(tviewApp, redrawChan, pages, settings) case "docker": settings := docker.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = docker.NewWidget(tviewApp, redrawChan, pages, settings) case "feedreader": settings := feedreader.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = feedreader.NewWidget(tviewApp, redrawChan, pages, settings) case "football": settings := football.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = football.NewWidget(tviewApp, redrawChan, pages, settings) case "gcal": settings := gcal.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gcal.NewWidget(tviewApp, redrawChan, settings) case "gerrit": settings := gerrit.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gerrit.NewWidget(tviewApp, redrawChan, pages, settings) case "git": settings := git.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = git.NewWidget(tviewApp, redrawChan, pages, settings) case "github": settings := github.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = github.NewWidget(tviewApp, redrawChan, pages, settings) case "gitlab": settings := gitlab.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gitlab.NewWidget(tviewApp, redrawChan, pages, settings) case "gitlabtodo": settings := gitlabtodo.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gitlabtodo.NewWidget(tviewApp, redrawChan, pages, settings) case "gitter": settings := gitter.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gitter.NewWidget(tviewApp, redrawChan, pages, settings) case "googleanalytics": settings := googleanalytics.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = googleanalytics.NewWidget(tviewApp, redrawChan, settings) case "gspreadsheets": settings := gspreadsheets.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gspreadsheets.NewWidget(tviewApp, redrawChan, settings) case "grafana": settings := grafana.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = grafana.NewWidget(tviewApp, redrawChan, pages, settings) case "hackernews": settings := hackernews.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = hackernews.NewWidget(tviewApp, redrawChan, pages, settings) case "healthchecks": settings := healthchecks.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = healthchecks.NewWidget(tviewApp, redrawChan, pages, settings) case "hibp": settings := hibp.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = hibp.NewWidget(tviewApp, redrawChan, settings) case "ipapi": settings := ipapi.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = ipapi.NewWidget(tviewApp, redrawChan, settings) case "ipinfo": settings := ipinfo.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = ipinfo.NewWidget(tviewApp, redrawChan, settings) case "jenkins": settings := jenkins.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = jenkins.NewWidget(tviewApp, redrawChan, pages, settings) case "jira": settings := jira.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = jira.NewWidget(tviewApp, redrawChan, pages, settings) case "kubernetes": settings := kubernetes.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = kubernetes.NewWidget(tviewApp, redrawChan, settings) case "krisinformation": settings := krisinformation.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = krisinformation.NewWidget(tviewApp, redrawChan, settings) case "logger": settings := logger.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = logger.NewWidget(tviewApp, redrawChan, settings) case "lunarphase": settings := lunarphase.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = lunarphase.NewWidget(tviewApp, redrawChan, pages, settings) case "mercurial": settings := mercurial.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = mercurial.NewWidget(tviewApp, redrawChan, pages, settings) case "mempool": settings := mempool.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = mempool.NewWidget(tviewApp, redrawChan, pages, settings) case "nbascore": settings := nbascore.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = nbascore.NewWidget(tviewApp, redrawChan, pages, settings) case "newrelic": settings := newrelic.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = newrelic.NewWidget(tviewApp, redrawChan, pages, settings) case "nextbus": settings := nextbus.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = nextbus.NewWidget(tviewApp, redrawChan, pages, settings) case "opsgenie": settings := opsgenie.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = opsgenie.NewWidget(tviewApp, redrawChan, settings) case "pagerduty": settings := pagerduty.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = pagerduty.NewWidget(tviewApp, redrawChan, settings) case "pihole": settings := pihole.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = pihole.NewWidget(tviewApp, redrawChan, pages, settings) case "ping": settings := ping.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = ping.NewWidget(tviewApp, redrawChan, settings) case "power": settings := power.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = power.NewWidget(tviewApp, redrawChan, settings) case "prettyweather": settings := prettyweather.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = prettyweather.NewWidget(tviewApp, redrawChan, settings) case "progress": settings := progress.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = progress.NewWidget(tviewApp, redrawChan, settings) case "pocket": settings := pocket.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = pocket.NewWidget(tviewApp, redrawChan, pages, settings) case "resourceusage": settings := resourceusage.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = resourceusage.NewWidget(tviewApp, redrawChan, settings) case "rollbar": settings := rollbar.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = rollbar.NewWidget(tviewApp, redrawChan, pages, settings) case "security": settings := security.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = security.NewWidget(tviewApp, redrawChan, settings) case "spacex": settings := spacex.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = spacex.NewWidget(tviewApp, redrawChan, settings) case "spotify": settings := spotify.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = spotify.NewWidget(tviewApp, redrawChan, pages, settings) case "spotifyweb": settings := spotifyweb.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = spotifyweb.NewWidget(tviewApp, redrawChan, pages, settings) case "status": settings := status.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = status.NewWidget(tviewApp, redrawChan, settings) case "steam": settings := steam.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = steam.NewWidget(tviewApp, redrawChan, pages, settings) case "subreddit": settings := subreddit.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = subreddit.NewWidget(tviewApp, redrawChan, pages, settings) case "textfile": settings := textfile.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = textfile.NewWidget(tviewApp, redrawChan, pages, settings) case "todo": settings := todo.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = todo.NewWidget(tviewApp, redrawChan, pages, settings) case "todo_plus": settings := todo_plus.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = todo_plus.NewWidget(tviewApp, redrawChan, pages, settings) case "todoist": settings := todo_plus.FromTodoist(moduleName, moduleConfig, config) widget = todo_plus.NewWidget(tviewApp, redrawChan, pages, settings) case "transmission": settings := transmission.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = transmission.NewWidget(tviewApp, redrawChan, pages, settings) case "travisci": settings := travisci.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = travisci.NewWidget(tviewApp, redrawChan, pages, settings) case "trello": settings := todo_plus.FromTrello(moduleName, moduleConfig, config) widget = todo_plus.NewWidget(tviewApp, redrawChan, pages, settings) case "twitch": settings := twitch.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = twitch.NewWidget(tviewApp, redrawChan, pages, settings) case "twitter": settings := twitter.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = twitter.NewWidget(tviewApp, redrawChan, pages, settings) case "twitterstats": settings := twitterstats.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = twitterstats.NewWidget(tviewApp, redrawChan, pages, settings) case "updown": settings := updown.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = updown.NewWidget(tviewApp, redrawChan, pages, settings) case "uptimekuma": settings := uptimekuma.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = uptimekuma.NewWidget(tviewApp, redrawChan, pages, settings) case "uptimerobot": settings := uptimerobot.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = uptimerobot.NewWidget(tviewApp, redrawChan, pages, settings) case "urlcheck": settings := urlcheck.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = urlcheck.NewWidget(tviewApp, redrawChan, settings) case "victorops": settings := victorops.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = victorops.NewWidget(tviewApp, redrawChan, settings) case "weather": settings := weather.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = weather.NewWidget(tviewApp, redrawChan, pages, settings) case "zendesk": settings := zendesk.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = zendesk.NewWidget(tviewApp, redrawChan, pages, settings) case "pivotal": settings := pivotal.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = pivotal.NewWidget(tviewApp, redrawChan, pages, settings) case "finnhub": settings := finnhub.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = finnhub.NewWidget(tviewApp, redrawChan, settings) case "yfinance": settings := yfinance.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = yfinance.NewWidget(tviewApp, redrawChan, settings) default: settings := unknown.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = unknown.NewWidget(tviewApp, redrawChan, settings) } return widget } // MakeWidgets creates and returns a collection of enabled widgets func MakeWidgets(tviewApp *tview.Application, pages *tview.Pages, config *config.Config, redrawChan chan bool) []wtf.Wtfable { var widgets []wtf.Wtfable moduleNames, _ := config.Map("wtf.mods") for moduleName := range moduleNames { widget := MakeWidget(tviewApp, pages, moduleName, config, redrawChan) if widget != nil { widgets = append(widgets, widget) } } return widgets } ================================================ FILE: app/widget_maker_test.go ================================================ package app import ( "testing" "github.com/olebedev/config" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/modules/clocks" "github.com/wtfutil/wtf/wtf" ) const ( disabled = ` wtf: mods: clocks: enabled: false position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 30` enabled = ` wtf: mods: clocks: enabled: true position: top: 0 left: 0 height: 1 width: 1 refreshInterval: 30` ) func Test_MakeWidget(t *testing.T) { tests := []struct { name string moduleName string config *config.Config expected wtf.Wtfable }{ { name: "invalid module", moduleName: "", config: &config.Config{}, expected: nil, }, { name: "valid disabled module", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(disabled) return cfg }(), expected: nil, }, { name: "valid enabled module", moduleName: "clocks", config: func() *config.Config { cfg, _ := config.ParseYaml(enabled) return cfg }(), expected: &clocks.Widget{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := MakeWidget(nil, nil, tt.moduleName, tt.config, make(chan bool)) assert.IsType(t, tt.expected, actual) }) } } ================================================ FILE: app/wtf_app.go ================================================ package app import ( "fmt" "log" "os" "time" _ "github.com/gdamore/tcell/terminfo/extended" "github.com/gdamore/tcell/v2" "github.com/olebedev/config" "github.com/radovskyb/watcher" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/support" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/wtf" ) // WtfApp is the container for a collection of widgets that are all constructed from a single // configuration file and displayed together type WtfApp struct { TViewApp *tview.Application config *config.Config configFilePath string display *Display focusTracker FocusTracker ghUser *support.GitHubUser pages *tview.Pages validator *ModuleValidator widgets []wtf.Wtfable configWatcher *watcher.Watcher // The redrawChan channel is used to allow modules to signal back to the main loop that // the screen needs to be explicitly redrawn, instead of waiting for tcell to redraw // on a user event, because something has visually changed redrawChan chan bool } // NewWtfApp creates and returns an instance of WtfApp func NewWtfApp(tviewApp *tview.Application, config *config.Config, configFilePath string) *WtfApp { wtfApp := &WtfApp{ TViewApp: tviewApp, config: config, configFilePath: configFilePath, pages: tview.NewPages(), redrawChan: make(chan bool, 1), } wtfApp.TViewApp.SetBeforeDrawFunc(func(s tcell.Screen) bool { s.Clear() return false }) wtfApp.widgets = MakeWidgets(wtfApp.TViewApp, wtfApp.pages, wtfApp.config, wtfApp.redrawChan) if len(wtfApp.widgets) == 0 { fmt.Println("No modules were defined. Make sure you have at least one properly defined widget") os.Exit(1) } wtfApp.display = NewDisplay(wtfApp.widgets, wtfApp.config) wtfApp.focusTracker = NewFocusTracker(wtfApp.TViewApp, wtfApp.widgets, wtfApp.config) wtfApp.validator = NewModuleValidator() githubAPIKey := readGitHubAPIKey(wtfApp.config) wtfApp.ghUser = support.NewGitHubUser(githubAPIKey) wtfApp.pages.AddPage("grid", wtfApp.display.Grid, true, true) wtfApp.validator.Validate(wtfApp.widgets) firstWidget := wtfApp.widgets[0] wtfApp.pages.SetBackgroundColor( wtf.ColorFor( firstWidget.CommonSettings().Colors.Background, ), ) wtfApp.TViewApp.SetInputCapture(wtfApp.keyboardIntercept) wtfApp.TViewApp.SetRoot(wtfApp.pages, true) // Create a watcher to handle calls to redraw the screen go handleRedraws(wtfApp.TViewApp, wtfApp.redrawChan) return wtfApp } func handleRedraws(tviewApp *tview.Application, redrawChan chan bool) { if redrawChan == nil { return } for { data, ok := <-redrawChan if !ok { return } if data { tviewApp.Draw() } } } /* -------------------- Exported Functions -------------------- */ // Exit quits the app func (wtfApp *WtfApp) Exit() { wtfApp.Stop() wtfApp.TViewApp.Stop() wtfApp.DisplayExitMessage() os.Exit(0) } // Execute starts the underlying tview app func (wtfApp *WtfApp) Execute() error { if err := wtfApp.TViewApp.Run(); err != nil { return err } return nil } // Start initializes the app func (wtfApp *WtfApp) Start() { go wtfApp.scheduleWidgets() go wtfApp.watchForConfigChanges() // FIXME: This should be moved to the AppManager go func() { _ = wtfApp.ghUser.Load() }() } // Stop kills all the currently-running widgets in this app func (wtfApp *WtfApp) Stop() { wtfApp.stopAllWidgets() if wtfApp.configWatcher != nil { wtfApp.configWatcher.Close() } close(wtfApp.redrawChan) } /* -------------------- Unexported Functions -------------------- */ func (wtfApp *WtfApp) stopAllWidgets() { for _, widget := range wtfApp.widgets { widget.Stop() } } func (wtfApp *WtfApp) keyboardIntercept(event *tcell.EventKey) *tcell.EventKey { // These keys are global keys used by the app. Widgets should not implement these keys switch event.Key() { case tcell.KeyCtrlC: wtfApp.Stop() wtfApp.TViewApp.Stop() wtfApp.DisplayExitMessage() case tcell.KeyCtrlR: wtfApp.refreshAllWidgets() return nil case tcell.KeyCtrlSpace: // FIXME: This can't reside in the app, the app doesn't know about // the AppManager. The AppManager needs to catch this one fmt.Println("Next app") return nil case tcell.KeyTab: wtfApp.focusTracker.Next() case tcell.KeyBacktab: wtfApp.focusTracker.Prev() return nil case tcell.KeyEsc: wtfApp.focusTracker.None() } // Checks to see if any widget has been assigned the pressed key as its focus key if wtfApp.focusTracker.FocusOn(string(event.Rune())) { return nil } // If no specific widget has focus, then allow the key presses to fall through to the app if !wtfApp.focusTracker.IsFocused { switch string(event.Rune()) { case "q": wtfApp.Exit() case "/": return nil default: } } return event } func (wtfApp *WtfApp) refreshAllWidgets() { for _, widget := range wtfApp.widgets { go widget.Refresh() } } func (wtfApp *WtfApp) scheduleWidgets() { for _, widget := range wtfApp.widgets { go Schedule(widget) } } func (wtfApp *WtfApp) watchForConfigChanges() { wtfApp.configWatcher = watcher.New() watch := wtfApp.configWatcher // Notify write events watch.FilterOps(watcher.Write) go func() { for { select { case <-watch.Event: wtfApp.Stop() config := cfg.LoadWtfConfigFile(wtfApp.configFilePath) newApp := NewWtfApp(wtfApp.TViewApp, config, wtfApp.configFilePath) openURLUtil := utils.ToStrs(config.UList("wtf.openUrlUtil", []interface{}{})) utils.Init(config.UString("wtf.openFileUtil", "open"), openURLUtil) newApp.Start() case err := <-watch.Error: if err == watcher.ErrWatchedFileDeleted { // Usually happens because the watcher looks for the file as the OS is updating it continue } log.Fatalln(err) case <-watch.Closed: return } } }() // Watch config file for changes. absPath, _ := utils.ExpandHomeDir(wtfApp.configFilePath) if err := watch.Add(absPath); err != nil { log.Fatalln(err) } // Start the watching process - it'll check for changes every 100ms. if err := watch.Start(time.Millisecond * 100); err != nil { log.Fatalln(err) } } ================================================ FILE: cfg/common_settings.go ================================================ package cfg import ( "fmt" "strings" "time" "github.com/olebedev/config" "golang.org/x/text/language" "golang.org/x/text/message" ) const ( defaultLanguageTag = "en-CA" ) type Module struct { Name string Type string } type Sigils struct { Checkbox struct { Checked string Unchecked string } Paging struct { Normal string Selected string } } // Common defines a set of common configuration settings applicable to all modules type Common struct { Module PositionSettings `help:"Defines where in the grid this module's widget will be displayed."` Sigils Colors ColorTheme Config *config.Config DocPath string Bordered bool `help:"Whether or not the module should be displayed with a border." values:"true, false" optional:"true" default:"true"` Enabled bool `help:"Whether or not this module is executed and if its data displayed onscreen." values:"true, false" optional:"true" default:"false"` Focusable bool `help:"Whether or not this module is focusable." values:"true, false" optional:"true" default:"false"` LanguageTag string `help:"The BCP 47 language tag to localize text to." values:"Any supported BCP 47 language tag." optional:"true" default:"en-CA"` RefreshInterval time.Duration `help:"How often this module will update its data." values:"A positive integer followed by a time unit (ns, us, ms, s, m, h, or nothing which defaults to s)" optional:"true"` Title string `help:"The title string to show when displaying this module" optional:"true"` focusChar int `help:"Define one of the number keys as a short cut key to access the widget." optional:"true"` } // NewCommonSettingsFromModule returns a common settings configuration tailed to the given module func NewCommonSettingsFromModule(name, defaultTitle string, defaultFocusable bool, moduleConfig *config.Config, globalConfig *config.Config) *Common { baseColors := NewDefaultColorTheme() colorsConfig, err := globalConfig.Get("wtf.colors") if err != nil && strings.Contains(err.Error(), "Nonexistent map") { // Create a default colors config to fill in for the missing one // This comes into play when the configuration file does not contain a `colors:` key, i.e: // // wtf: // # colors: <- missing // refreshInterval: 1 // openFileUtil: "open" // colorsConfig, _ = NewDefaultColorConfig() } // And finally create a third instance to be the final default fallback in case there are empty or nil values in // the colors extracted from the config file (aka colorsConfig) defaultColorTheme := NewDefaultColorTheme() baseColors.Focusable = moduleConfig.UString("colors.border.focusable", colorsConfig.UString("border.focusable", defaultColorTheme.Focusable)) baseColors.Focused = moduleConfig.UString("colors.border.focused", colorsConfig.UString("border.focused", defaultColorTheme.Focused)) baseColors.Unfocusable = moduleConfig.UString("colors.border.normal", colorsConfig.UString("border.normal", defaultColorTheme.Unfocusable)) baseColors.Checked = moduleConfig.UString("colors.checked", colorsConfig.UString("checked", defaultColorTheme.Checked)) baseColors.EvenForeground = moduleConfig.UString("colors.rows.even", colorsConfig.UString("rows.even", defaultColorTheme.EvenForeground)) baseColors.OddForeground = moduleConfig.UString("colors.rows.odd", colorsConfig.UString("rows.odd", defaultColorTheme.OddForeground)) baseColors.Label = moduleConfig.UString("colors.label", colorsConfig.UString("label", defaultColorTheme.Label)) baseColors.Subheading = moduleConfig.UString("colors.subheading", colorsConfig.UString("subheading", defaultColorTheme.Subheading)) baseColors.Text = moduleConfig.UString("colors.text", colorsConfig.UString("text", defaultColorTheme.Text)) baseColors.Title = moduleConfig.UString("colors.title", colorsConfig.UString("title", defaultColorTheme.Title)) baseColors.Background = moduleConfig.UString("colors.background", colorsConfig.UString("background", defaultColorTheme.Background)) common := Common{ Colors: baseColors, Module: Module{ Name: name, Type: moduleConfig.UString("type", name), }, PositionSettings: NewPositionSettingsFromYAML(moduleConfig), Bordered: moduleConfig.UBool("border", true), Config: moduleConfig, Enabled: moduleConfig.UBool("enabled", false), Focusable: moduleConfig.UBool("focusable", defaultFocusable), LanguageTag: globalConfig.UString("wtf.language", defaultLanguageTag), RefreshInterval: ParseTimeString(moduleConfig, "refreshInterval", "300s"), Title: moduleConfig.UString("title", defaultTitle), focusChar: moduleConfig.UInt("focusChar", -1), } sigilsPath := "wtf.sigils" common.Checkbox.Checked = globalConfig.UString(sigilsPath+".checkbox.checked", "x") common.Checkbox.Unchecked = globalConfig.UString(sigilsPath+".checkbox.unchecked", " ") common.Paging.Normal = globalConfig.UString(sigilsPath+".paging.normal", globalConfig.UString("wtf.paging.pageSigil", "*")) common.Paging.Selected = globalConfig.UString(sigilsPath+".paging.select", globalConfig.UString("wtf.paging.selectedSigil", "_")) return &common } /* -------------------- Exported Functions -------------------- */ func (common *Common) DefaultFocusedRowColor() string { return fmt.Sprintf( "%s:%s", common.Colors.HighlightedForeground, common.Colors.HighlightedBackground, ) } func (common *Common) DefaultRowColor() string { return fmt.Sprintf( "%s:%s", common.Colors.EvenForeground, common.Colors.EvenBackground, ) } // FocusChar returns the keyboard number assigned to the widget used to give onscreen // focus to this widget, as a string. Focus characters can be a range between 1 and 9 func (common *Common) FocusChar() string { if common.focusChar <= 0 { return "" } if common.focusChar > 9 { return "" } return fmt.Sprint(common.focusChar) } // LocalizedPrinter returns a message.Printer instance localized to the BCP 47 language // configuration value defined in 'wtf.language' config. If none exists, it defaults to // 'en-CA'. Use this to format numbers, etc. func (common *Common) LocalizedPrinter() (*message.Printer, error) { langTag, err := language.Parse(common.LanguageTag) if err != nil { return nil, err } prntr := message.NewPrinter(langTag) return prntr, nil } func (common *Common) RowColor(idx int) string { if idx%2 == 0 { return fmt.Sprintf( "%s:%s", common.Colors.EvenForeground, common.Colors.EvenBackground, ) } return fmt.Sprintf( "%s:%s", common.Colors.OddForeground, common.Colors.OddBackground, ) } func (*Common) RightAlignFormat(width int) string { borderOffset := 2 return fmt.Sprintf("%%%ds", width-borderOffset) } // PaginationMarker generates the pagination indicators that appear in the top-right corner // of multisource widgets func (common *Common) PaginationMarker(length, pos, width int) string { sigils := "" if length > 1 { sigils = strings.Repeat(common.Paging.Normal, pos) sigils += common.Paging.Selected sigils += strings.Repeat(common.Paging.Normal, length-1-pos) sigils = "[lightblue]" + fmt.Sprintf(common.RightAlignFormat(width), sigils) + "[white]" } return sigils } // SetDocumentationPath is used to explicitly set the documentation path that should be opened // when the key to open the documentation is pressed. // Setting this is probably not necessary unless the module documentation is nested inside a // documentation subdirectory in the /wtfutildocs repo, or the module here has a different // name than the module's display name in the documentation (which ideally wouldn't be a thing). func (common *Common) SetDocumentationPath(path string) { common.DocPath = path } // Validations aggregates all the validations from all the sub-sections in Common into a // single array of validations func (common *Common) Validations() []Validatable { var validatables []Validatable for _, validation := range common.PositionSettings.Validations.validations { validatables = append(validatables, validation) } return validatables } ================================================ FILE: cfg/common_settings_test.go ================================================ package cfg import ( "testing" "time" "github.com/olebedev/config" "github.com/stretchr/testify/assert" ) var ( testYaml = ` wtf: colors: ` moduleConfig, _ = config.ParseYaml(testYaml) globalSettings, _ = config.ParseYaml(testYaml) testCfg = NewCommonSettingsFromModule( "test", "Test Config", true, moduleConfig, globalSettings, ) ) func Test_NewCommonSettingsFromModule(t *testing.T) { assert.Equal(t, true, testCfg.Bordered) assert.Equal(t, false, testCfg.Enabled) assert.Equal(t, true, testCfg.Focusable) assert.Equal(t, "test", testCfg.Name) assert.Equal(t, "test", testCfg.Type) assert.Equal(t, "", testCfg.FocusChar()) assert.Equal(t, 300*time.Second, testCfg.RefreshInterval) assert.Equal(t, "Test Config", testCfg.Title) } func Test_DefaultFocusedRowColor(t *testing.T) { assert.Equal(t, "black:green", testCfg.DefaultFocusedRowColor()) } func Test_DefaultRowColor(t *testing.T) { assert.Equal(t, "white:transparent", testCfg.DefaultRowColor()) } func Test_FocusChar(t *testing.T) { tests := []struct { name string before func(testCfg *Common) expectedChar string }{ { name: "with negative focus char", before: func(testCfg *Common) { testCfg.focusChar = -1 }, expectedChar: "", }, { name: "with positive focus char", before: func(testCfg *Common) { testCfg.focusChar = 3 }, expectedChar: "3", }, { name: "with zero focus char", before: func(testCfg *Common) { testCfg.focusChar = 0 }, expectedChar: "", }, { name: "with large focus char", before: func(testCfg *Common) { testCfg.focusChar = 10 }, expectedChar: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.before(testCfg) assert.Equal(t, tt.expectedChar, testCfg.FocusChar()) }) } } func Test_RowColor(t *testing.T) { tests := []struct { name string idx int expectedColor string }{ { name: "odd rows, default", idx: 3, expectedColor: "lightblue:transparent", }, { name: "even rows, default", idx: 8, expectedColor: "white:transparent", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expectedColor, testCfg.RowColor(tt.idx)) }) } } func Test_RightAlignFormat(t *testing.T) { tests := []struct { name string width int expected string }{ { name: "with zero", width: 0, expected: "%-2s", }, { name: "with positive integer", width: 3, expected: "%1s", }, { name: "with negative integer", width: -3, expected: "%-5s", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, testCfg.RightAlignFormat(tt.width)) }) } } func Test_PaginationMarker(t *testing.T) { tests := []struct { name string len int pos int width int expected string }{ { name: "with zero pages", len: 0, pos: 1, width: 5, expected: "", }, { name: "with one page", len: 1, pos: 1, width: 5, expected: "", }, { name: "with multiple pages", len: 3, pos: 1, width: 5, expected: "[lightblue]*_*[white]", }, { name: "with negative pages", len: -3, pos: 1, width: 5, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, testCfg.PaginationMarker(tt.len, tt.pos, tt.width)) }) } } func Test_Validations(t *testing.T) { assert.Equal(t, 4, len(testCfg.Validations())) } ================================================ FILE: cfg/config_files.go ================================================ package cfg import ( "errors" "fmt" "os" "os/user" "path/filepath" "github.com/olebedev/config" ) const ( // XdgConfigDir defines the path to the minimal XDG-compatible configuration directory XdgConfigDir = "~/.config/" // WtfConfigDirV1 defines the path to the first version of configuration. Do not use this WtfConfigDirV1 = "~/.wtf/" // WtfConfigDirV2 defines the path to the second version of the configuration. Use this. WtfConfigDirV2 = "~/.config/wtf/" // WtfConfigFile defines the name of the default config file WtfConfigFile = "config.yml" ) /* -------------------- Exported Functions -------------------- */ // CreateFile creates the named file in the config directory, if it does not already exist. // If the file exists it does not recreate it. // If successful, returns the absolute path to the file // If unsuccessful, returns an error func CreateFile(fileName string) (string, error) { configDir, err := WtfConfigDir() if err != nil { return "", err } filePath := filepath.Join(configDir, fileName) // Check if the file already exists; if it does not, create it _, err = os.Stat(filePath) if err != nil { if os.IsNotExist(err) { _, err = os.Create(filePath) if err != nil { return "", err } } else { return "", err } } return filePath, nil } // Initialize takes care of settings up the initial state of WTF configuration // It ensures necessary directories and files exist func Initialize(hasCustom bool) { if !hasCustom { migrateOldConfig() } // These always get created because this is where modules should write any permanent // data they need to persist between runs (i.e.: log, textfile, etc.) createWtfConfigDir() if !hasCustom { createWtfConfigFile() chmodConfigFile() } } // WtfConfigDir returns the absolute path to the configuration directory func WtfConfigDir() (string, error) { configDir := os.Getenv("XDG_CONFIG_HOME") if configDir == "" { configDir = WtfConfigDirV2 } else { configDir += "/wtf/" } configDir, err := expandHomeDir(configDir) if err != nil { return "", err } return configDir, nil } // LoadWtfConfigFile loads the specified config file func LoadWtfConfigFile(filePath string) *config.Config { absPath, _ := expandHomeDir(filePath) cfg, err := config.ParseYamlFile(absPath) if err != nil { displayWtfConfigFileLoadError(absPath, err) os.Exit(1) } return cfg } /* -------------------- Unexported Functions -------------------- */ // chmodConfigFile sets the mode of the config file to r+w for the owner only func chmodConfigFile() { configDir, _ := WtfConfigDir() relPath := filepath.Join(configDir, WtfConfigFile) absPath, _ := expandHomeDir(relPath) _, err := os.Stat(absPath) if err != nil && os.IsNotExist(err) { return } err = os.Chmod(absPath, 0600) if err != nil { return } } // createWtfConfigDir creates the necessary directories for storing the default config file // If ~/.config/wtf is missing, it will try to create it func createWtfConfigDir() { wtfConfigDir, _ := WtfConfigDir() if _, err := os.Stat(wtfConfigDir); os.IsNotExist(err) { err := os.MkdirAll(wtfConfigDir, os.ModePerm) if err != nil { displayWtfConfigDirCreateError(err) os.Exit(1) } } } // createWtfConfigFile creates a simple config file in the config directory if // one does not already exist func createWtfConfigFile() { filePath, err := CreateFile(WtfConfigFile) if err != nil { displayDefaultConfigCreateError(err) os.Exit(1) } // If the file is empty, write to it file, _ := os.Stat(filePath) if file.Size() == 0 { if os.WriteFile(filePath, []byte(defaultConfigFile), 0600) != nil { displayDefaultConfigWriteError(err) os.Exit(1) } } } // Expand expands the path to include the home directory if the path // is prefixed with `~`. If it isn't prefixed with `~`, the path is // returned as-is. func expandHomeDir(path string) (string, error) { if path == "" { return path, nil } if path[0] != '~' { return path, nil } if len(path) > 1 && path[1] != '/' && path[1] != '\\' { return "", errors.New("cannot expand user-specific home dir") } dir, err := home() if err != nil { return "", err } return filepath.Join(dir, path[1:]), nil } // Dir returns the home directory for the executing user. // An error is returned if a home directory cannot be detected. func home() (string, error) { currentUser, err := user.Current() if err != nil { return "", err } if currentUser.HomeDir == "" { return "", errors.New("cannot find user-specific home dir") } return currentUser.HomeDir, nil } // migrateOldConfig copies any existing configuration from the old location // to the new, XDG-compatible location func migrateOldConfig() { srcDir, _ := expandHomeDir(WtfConfigDirV1) destDir, _ := WtfConfigDir() // If the old config directory doesn't exist, do not move if _, err := os.Stat(srcDir); os.IsNotExist(err) { return } // If the new config directory already exists, do not move if _, err := os.Stat(destDir); err == nil { return } // Time to move err := Copy(srcDir, destDir) if err != nil { panic(err) } // Delete the old directory if the new one exists if _, err := os.Stat(destDir); err == nil { err := os.RemoveAll(srcDir) if err != nil { fmt.Println(err) } } } ================================================ FILE: cfg/copy.go ================================================ // Copied verbatim from: // // https://github.com/otiai10/copy/blob/master/copy.go package cfg import ( "io" "os" "path/filepath" ) // Copy copies src to dest, doesn't matter if src is a directory or a file func Copy(src, dest string) error { info, err := os.Stat(src) if err != nil { return err } return locationCopy(src, dest, info) } // "info" must be given here, NOT nil. func locationCopy(src, dest string, info os.FileInfo) error { if info.IsDir() { return directoryCopy(src, dest, info) } return fileCopy(src, dest, info) } func fileCopy(src, dest string, info os.FileInfo) error { f, err := os.Create(dest) if err != nil { return err } defer func() { _ = f.Close() }() if err = os.Chmod(f.Name(), info.Mode()); err != nil { return err } s, err := os.Open(filepath.Clean(src)) if err != nil { return err } defer func() { _ = f.Close() }() _, err = io.Copy(f, s) return err } func directoryCopy(src, dest string, info os.FileInfo) error { if err := os.MkdirAll(dest, info.Mode()); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { info, err := entry.Info() if err != nil { continue } if err := locationCopy( filepath.Join(src, info.Name()), filepath.Join(dest, info.Name()), info, ); err != nil { return err } } return nil } ================================================ FILE: cfg/default_color_theme.go ================================================ package cfg import ( "github.com/olebedev/config" "gopkg.in/yaml.v2" ) // BorderTheme defines the default color scheme for drawing widget borders type BorderTheme struct { Focusable string Focused string Unfocusable string } // CheckboxTheme defines the default color scheme for drawing checkable rows in widgets type CheckboxTheme struct { Checked string } // RowTheme defines the default color scheme for row text type RowTheme struct { EvenBackground string EvenForeground string OddBackground string OddForeground string HighlightedBackground string HighlightedForeground string } // TextTheme defines the default color scheme for text rendering type TextTheme struct { Label string Subheading string Text string Title string } // WidgetTheme defines the default color scheme for the widget rect itself type WidgetTheme struct { Background string } // ColorTheme is an alamgam of all the default color settings type ColorTheme struct { BorderTheme CheckboxTheme RowTheme TextTheme WidgetTheme } // NewDefaultColorTheme creates and returns an instance of DefaultColorTheme func NewDefaultColorTheme() ColorTheme { defaultTheme := ColorTheme{ BorderTheme: BorderTheme{ Focusable: "blue", Focused: "orange", Unfocusable: "gray", }, CheckboxTheme: CheckboxTheme{ Checked: "gray", }, RowTheme: RowTheme{ EvenBackground: "transparent", EvenForeground: "white", OddBackground: "transparent", OddForeground: "lightblue", HighlightedForeground: "black", HighlightedBackground: "green", }, TextTheme: TextTheme{ Label: "lightblue", Subheading: "red", Text: "white", Title: "green", }, WidgetTheme: WidgetTheme{ Background: "transparent", }, } return defaultTheme } // NewDefaultColorConfig creates and returns a config.Config-compatible configuration struct // using a DefaultColorTheme to pre-populate all the relevant values func NewDefaultColorConfig() (*config.Config, error) { colorTheme := NewDefaultColorTheme() yamlBytes, err := yaml.Marshal(colorTheme) if err != nil { return nil, err } cfg, err := config.ParseYamlBytes(yamlBytes) if err != nil { return nil, err } return cfg, nil } ================================================ FILE: cfg/default_color_theme_test.go ================================================ package cfg import ( "testing" "github.com/stretchr/testify/assert" ) func Test_NewDefaultColorTheme(t *testing.T) { theme := NewDefaultColorTheme() assert.Equal(t, "orange", theme.Focused) assert.Equal(t, "red", theme.Subheading) assert.Equal(t, "transparent", theme.Background) } func Test_NewDefaultColorConfig(t *testing.T) { cfg, err := NewDefaultColorConfig() assert.Nil(t, err) assert.Equal(t, "orange", cfg.UString("bordertheme.focused")) assert.Equal(t, "red", cfg.UString("texttheme.subheading")) assert.Equal(t, "transparent", cfg.UString("widgettheme.background")) assert.Equal(t, "", cfg.UString("widgettheme.missing")) } ================================================ FILE: cfg/default_config_file.go ================================================ package cfg const defaultConfigFile = `wtf: colors: border: focusable: darkslateblue focused: orange normal: gray grid: columns: [32, 32, 32, 32, 90] rows: [10, 10, 10, 4, 4, 90] refreshInterval: 1 mods: clocks_a: colors: rows: even: "lightblue" odd: "white" enabled: true locations: Vancouver: "America/Vancouver" Toronto: "America/Toronto" position: top: 0 left: 1 height: 1 width: 1 refreshInterval: 15 sort: "alphabetical" title: "Clocks A" type: "clocks" clocks_b: colors: rows: even: "lightblue" odd: "white" enabled: true locations: Paris: "Europe/Paris" Barcelona: "Europe/Madrid" Dubai: "Asia/Dubai" position: top: 0 left: 2 height: 1 width: 1 refreshInterval: 15 sort: "alphabetical" title: "Clocks B" type: "clocks" feedreader: enabled: true feeds: - https://feeds.bbci.co.uk/news/rss.xml feedLimit: 10 position: top: 1 left: 1 width: 2 height: 1 refreshInterval: 14400 ipinfo: colors: name: "lightblue" value: "white" enabled: true position: top: 2 left: 1 height: 1 width: 1 refreshInterval: 150 power: enabled: true position: top: 2 left: 2 height: 1 width: 1 refreshInterval: 15 title: "⚡️" textfile: enabled: true filePath: "~/.config/wtf/config.yml" format: true position: top: 0 left: 0 height: 4 width: 1 refreshInterval: 30 wrapText: false uptime: args: [] cmd: "uptime" enabled: true position: top: 3 left: 1 height: 1 width: 2 refreshInterval: 30 type: cmdrunner ` ================================================ FILE: cfg/error_messages.go ================================================ package cfg // This file contains the error messages that get written to the terminal when // something goes wrong with the configuration process. // // As a general rule, if one of these has to be shown the app should then die // via os.Exit(1) import ( "fmt" "github.com/logrusorgru/aurora/v4" ) /* -------------------- Unexported Functions -------------------- */ func displayError(err error) { fmt.Printf("%s %s\n\n", aurora.Red("Error:"), err.Error()) } func displayDefaultConfigCreateError(err error) { fmt.Printf("\n%s Could not create the default configuration file.\n", aurora.Red("ERROR")) fmt.Println() displayError(err) } func displayDefaultConfigWriteError(err error) { fmt.Printf("\n%s Could not write the default configuration file.\n", aurora.Red("ERROR")) fmt.Println() displayError(err) } func displayWtfConfigDirCreateError(err error) { fmt.Printf("\n%s Could not create the '%s' directory.\n", aurora.Red("ERROR"), aurora.Yellow(WtfConfigDirV2)) fmt.Println() displayError(err) } func displayWtfConfigFileLoadError(path string, err error) { fmt.Printf("\n%s Could not load '%s'.\n", aurora.Red("ERROR"), aurora.Yellow(path)) fmt.Println() fmt.Println("This could mean one of two things:") fmt.Println() fmt.Println(" 1. That file doesn't exist.") fmt.Println(" 2. That file has a YAML syntax error. Try running it through http://www.yamllint.com to check for errors.") fmt.Println() displayError(err) } ================================================ FILE: cfg/parsers.go ================================================ package cfg import ( "fmt" "time" "github.com/olebedev/config" ) // ParseAsMapOrList takes a configuration key and attempts to parse it first as a map // and then as a list. Map entries are concatenated as "key/value" func ParseAsMapOrList(ymlConfig *config.Config, configKey string) []string { result := []string{} mapItems, err := ymlConfig.Map(configKey) if err == nil { for key, value := range mapItems { result = append(result, fmt.Sprintf("%s/%s", value, key)) } return result } listItems := ymlConfig.UList(configKey) for _, listItem := range listItems { result = append(result, listItem.(string)) } return result } // ParseTimeString takes a configuration key and attempts to parse it first as an int // and then as a duration (int + time unit) func ParseTimeString(cfg *config.Config, configKey string, defaultValue string) time.Duration { i, err := cfg.Int(configKey) if err == nil { return time.Duration(i) * time.Second } str := cfg.UString(configKey, defaultValue) d, err := time.ParseDuration(str) if err == nil { return d } return time.Second } ================================================ FILE: cfg/parsers_test.go ================================================ package cfg import ( "testing" "time" "github.com/olebedev/config" ) func Test_ParseAsMapOrList(t *testing.T) { tests := []struct { name string configKey string yaml string expectedCount int }{ { name: "as empty set", configKey: "data", yaml: "", expectedCount: 0, }, { name: "as map", configKey: "data", yaml: "data:\n a: cat\n b: dog", expectedCount: 2, }, { name: "as list", configKey: "data", yaml: "data:\n - cat\n - dog\n - rat\n", expectedCount: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ymlConfig, err := config.ParseYaml(tt.yaml) if err != nil { t.Errorf("\nexpected: no error\n got: %v", err) } actual := ParseAsMapOrList(ymlConfig, tt.configKey) if tt.expectedCount != len(actual) { t.Errorf("\nexpected: %d\n got: %d", tt.expectedCount, len(actual)) } }) } } func Test_ParseTimeString(t *testing.T) { tests := []struct { name string configKey string yaml string expectedCount time.Duration }{ { name: "normal integer", configKey: "refreshInterval", yaml: "refreshInterval: 3", expectedCount: 3 * time.Second, }, { name: "microseconds", configKey: "refreshInterval", yaml: "refreshInterval: 5µs", expectedCount: 5 * time.Microsecond, }, { name: "microseconds different notation", configKey: "refreshInterval", yaml: "refreshInterval: 5us", expectedCount: 5 * time.Microsecond, }, { name: "mixed duration", configKey: "refreshInterval", yaml: "refreshInterval: 2h45m", expectedCount: 2*time.Hour + 45*time.Minute, }, { name: "default", configKey: "refreshInterval", yaml: "", expectedCount: 60 * time.Second, }, { name: "bad input", configKey: "refreshInterval", yaml: "refreshInterval: abc", expectedCount: 1 * time.Second, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ymlConfig, err := config.ParseYaml(tt.yaml) if err != nil { t.Errorf("\nexpected: no error\n got: %v", err) } actual := ParseTimeString(ymlConfig, tt.configKey, "60s") if tt.expectedCount != actual { t.Errorf("\nexpected: %d\n got: %v", tt.expectedCount, actual) } }) } } ================================================ FILE: cfg/position_settings.go ================================================ package cfg import ( "github.com/olebedev/config" ) const ( positionPath = "position" ) // PositionSettings represents the onscreen location of a widget type PositionSettings struct { Validations *Validations Height int Left int Top int Width int } // NewPositionSettingsFromYAML creates and returns a new instance of cfg.Position func NewPositionSettingsFromYAML(moduleConfig *config.Config) PositionSettings { var currVal int var err error validations := NewValidations() // Parse the positional data from the config data currVal, err = moduleConfig.Int(positionPath + ".top") validations.append("top", newPositionValidation("top", currVal, err)) currVal, err = moduleConfig.Int(positionPath + ".left") validations.append("left", newPositionValidation("left", currVal, err)) currVal, err = moduleConfig.Int(positionPath + ".width") validations.append("width", newPositionValidation("width", currVal, err)) currVal, err = moduleConfig.Int(positionPath + ".height") validations.append("height", newPositionValidation("height", currVal, err)) pos := PositionSettings{ Validations: validations, Top: validations.intValueFor("top"), Left: validations.intValueFor("left"), Width: validations.intValueFor("width"), Height: validations.intValueFor("height"), } return pos } ================================================ FILE: cfg/position_validation.go ================================================ package cfg import ( "fmt" "github.com/logrusorgru/aurora/v4" ) // Common examples of invalid position configuration are: // // position: // top: -3 // left: 2 // width: 0 // height: 1 // // position: // top: 3 // width: 2 // height: 1 // // position: // top: 3 // # left: 2 // width: 2 // height: 1 // // position: // top: 3 // left: 2 // width: 2 // height: 1 type positionValidation struct { err error name string intVal int } func (posVal *positionValidation) Error() error { return posVal.err } func (posVal *positionValidation) HasError() bool { return posVal.err != nil } func (posVal *positionValidation) IntValue() int { return posVal.intVal } // String returns the Stringer representation of the positionValidation func (posVal *positionValidation) String() string { return fmt.Sprintf("Invalid value for %s:\t%d", aurora.Yellow(posVal.name), posVal.intVal) } /* -------------------- Unexported Functions -------------------- */ func newPositionValidation(name string, intVal int, err error) *positionValidation { posVal := &positionValidation{ err: err, name: name, intVal: intVal, } return posVal } ================================================ FILE: cfg/position_validation_test.go ================================================ package cfg import ( "errors" "testing" "github.com/stretchr/testify/assert" ) var ( posVal = &positionValidation{ err: errors.New("Busted"), name: "top", intVal: -3, } ) func Test_Attributes(t *testing.T) { assert.EqualError(t, posVal.Error(), "Busted") assert.Equal(t, true, posVal.HasError()) assert.Equal(t, -3, posVal.IntValue()) assert.Contains(t, posVal.String(), "Invalid") assert.Contains(t, posVal.String(), "top") assert.Contains(t, posVal.String(), "-3") } ================================================ FILE: cfg/secrets.go ================================================ package cfg import ( "errors" "fmt" "runtime" "github.com/docker/docker-credential-helpers/client" "github.com/docker/docker-credential-helpers/credentials" "github.com/olebedev/config" "github.com/wtfutil/wtf/logger" ) type SecretLoadParams struct { name string globalConfig *config.Config service string secret *string } // Load module secrets. // // The credential helpers impose this structure: // // SERVICE is mapped to a SECRET and USERNAME // // Only SECRET is secret, SERVICE and USERNAME are not, so this // API doesn't expose USERNAME. // // SERVICE was intended to be the URL of an API server, but // for hosted services that do not have or need a configurable // API server, its easier to just use the module name as the // SERVICE: // // cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() // // The user will use the module name as the service, and the API key as // the secret, for example: // // % wtfutil save-secret circleci // Secret: ... // // If a module (such as pihole, jenkins, or github) might have multiple // instantiations each using a different API service (with its own unique // API key), then the module should use the API URL to lookup the secret. // For example, for github: // // cfg.ModuleSecret(name, globalConfig, &settings.apiKey). // Service(settings.baseURL). // Load() // // The user will use the API URL as the service, and the API key as the // secret, for example, with github configured as: // // -- config.yml // mods: // github: // baseURL: "https://github.mycompany.com/api/v3" // ... // // the secret must be saved as: // // % wtfutil save-secret https://github.mycompany.com/api/v3 // Secret: ... // // If baseURL is not set in the configuration it will be the modules // default, and the SERVICE will default to the module name, "github", // and the user must save the secret as: // // % wtfutil save-secret github // Secret: ... // // Ideally, the individual module documentation would describe the // SERVICE name to use to save the secret. func ModuleSecret(name string, globalConfig *config.Config, secret *string) *SecretLoadParams { return &SecretLoadParams{ name: name, globalConfig: globalConfig, secret: secret, service: name, // Default the service to the module name } } func (slp *SecretLoadParams) Service(service string) *SecretLoadParams { if service != "" { slp.service = service } return slp } func (slp *SecretLoadParams) Load() { configureSecret( slp.globalConfig, slp.service, slp.secret, ) } type Secret struct { Service string Secret string Username string Store string } func configureSecret( globalConfig *config.Config, service string, secret *string, ) { if service == "" { return } if secret == nil { return } // Don't overwrite the secret if it was configured with yaml if *secret != "" { return } cred, err := FetchSecret(globalConfig, service) if err != nil { logger.Log(fmt.Sprintf("Loading secret failed: %s", err.Error())) return } if cred == nil { // No secret store configued. return } if secret != nil && *secret == "" { *secret = cred.Secret } } // Fetch secret for `service`. Service is customarily a URL, but can be any // identifier uniquely used by wtf to identify the service, such as the name // of the module. nil is returned if the secretStore global property is not // present or the secret is not found in that store. func FetchSecret(globalConfig *config.Config, service string) (*Secret, error) { prog := newProgram(globalConfig) if prog == nil { // No secret store configured. return nil, nil } cred, err := client.Get(prog.runner, service) if err != nil { return nil, fmt.Errorf("get %v from %v: %w", service, prog.store, err) } return &Secret{ Service: cred.ServerURL, Secret: cred.Secret, Username: cred.Username, Store: prog.store, }, nil } func StoreSecret(globalConfig *config.Config, secret *Secret) error { prog := newProgram(globalConfig) if prog == nil { return errors.New("cannot store secrets: wtf.secretStore is not configured") } cred := &credentials.Credentials{ ServerURL: secret.Service, Username: secret.Username, Secret: secret.Secret, } // docker-credential requires a username, but it isn't necessary for // all services. Use a default if a username was not set. if cred.Username == "" { cred.Username = "default" } err := client.Store(prog.runner, cred) if err != nil { return fmt.Errorf("store %v: %w", prog.store, err) } return nil } type program struct { store string runner client.ProgramFunc } func newProgram(globalConfig *config.Config) *program { secretStore := globalConfig.UString("wtf.secretStore", "(none)") if secretStore == "(none)" { return nil } if secretStore == "" { switch runtime.GOOS { case "windows": secretStore = "winrt" case "darwin": secretStore = "osxkeychain" default: secretStore = "secretservice" } } return &program{ secretStore, client.NewShellProgramFunc("docker-credential-" + secretStore), } } ================================================ FILE: cfg/validatable.go ================================================ package cfg // Validatable is implemented by any value that validates a configuration setting type Validatable interface { Error() error HasError() bool String() string IntValue() int } ================================================ FILE: cfg/validations.go ================================================ package cfg // Validations represent a collection of config setting validations type Validations struct { validations map[string]Validatable } // NewValidations creates and returns an instance of Validations func NewValidations() *Validations { vals := &Validations{ validations: make(map[string]Validatable), } return vals } func (vals *Validations) append(key string, posVal Validatable) { vals.validations[key] = posVal } func (vals *Validations) intValueFor(key string) int { val := vals.validations[key] if val != nil { return val.IntValue() } return 0 } ================================================ FILE: cfg/validations_test.go ================================================ package cfg import ( "testing" "github.com/stretchr/testify/assert" ) var ( vals = NewValidations() ) func Test_intValueFor(t *testing.T) { vals.append("left", newPositionValidation("left", 3, nil)) tests := []struct { name string key string expected int }{ { name: "with valid key", key: "left", expected: 3, }, { name: "with invalid key", key: "cat", expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, vals.intValueFor(tt.key)) }) } } ================================================ FILE: checklist/checklist.go ================================================ package checklist import ( "time" ) // Checklist is a module for creating generic checklist implementations // See 'Todo' for an implementation example type Checklist struct { Items []*ChecklistItem checkedIcon string selected int uncheckedIcon string } func NewChecklist(checkedIcon, uncheckedIcon string) Checklist { list := Checklist{ checkedIcon: checkedIcon, selected: -1, uncheckedIcon: uncheckedIcon, } return list } /* -------------------- Exported Functions -------------------- */ // Add creates a new checklist item and adds it to the list // The new one is at the start or end of the list, based on newPos func (list *Checklist) Add(checked bool, date *time.Time, tags []string, text string, newPos ...string) { item := NewChecklistItem( checked, date, tags, text, list.checkedIcon, list.uncheckedIcon, ) if len(newPos) == 0 || newPos[0] == "first" { list.Items = append([]*ChecklistItem{item}, list.Items...) } else if newPos[0] == "last" { list.Items = append(list.Items, []*ChecklistItem{item}...) } } // CheckedItems returns a slice of all the checked items func (list *Checklist) CheckedItems() []*ChecklistItem { items := []*ChecklistItem{} for _, item := range list.Items { if item.Checked { items = append(items, item) } } return items } // Delete removes the selected item from the checklist func (list *Checklist) Delete(selectedIndex int) { if selectedIndex >= 0 && selectedIndex < len(list.Items) { list.Items = append(list.Items[:selectedIndex], list.Items[selectedIndex+1:]...) } } // IsSelectable returns true if the checklist has selectable items, false if it does not func (list *Checklist) IsSelectable() bool { return list.selected >= 0 && list.selected < len(list.Items) } // IsUnselectable returns true if the checklist has no selectable items, false if it does func (list *Checklist) IsUnselectable() bool { return !list.IsSelectable() } // LongestLine returns the length of the longest checklist item's text func (list *Checklist) LongestLine() int { maxLen := 0 for _, item := range list.Items { if len(item.Text) > maxLen { maxLen = len(item.Text) } } return maxLen } // IndexByItem returns the index of a giving item if found, otherwise returns 0 with ok set to false func (list *Checklist) IndexByItem(selectableItem *ChecklistItem) (index int, ok bool) { for idx, item := range list.Items { if item == selectableItem { return idx, true } } return 0, false } // UncheckedItems returns a slice of all the unchecked items func (list *Checklist) UncheckedItems() []*ChecklistItem { items := []*ChecklistItem{} for _, item := range list.Items { if !item.Checked { items = append(items, item) } } return items } // Unselect removes the current select such that no item is selected func (list *Checklist) Unselect() { list.selected = -1 } /* -------------------- Sort Interface -------------------- */ func (list *Checklist) Len() int { return len(list.Items) } func (list *Checklist) Less(i, j int) bool { return list.Items[i].Text < list.Items[j].Text } func (list *Checklist) Swap(i, j int) { list.Items[i], list.Items[j] = list.Items[j], list.Items[i] } ================================================ FILE: checklist/checklist_item.go ================================================ package checklist import ( "fmt" "time" ) // ChecklistItem is a module for creating generic checklist implementations // See 'Todo' for an implementation example type ChecklistItem struct { Checked bool CheckedIcon string `yaml:"-"` Date *time.Time Tags []string Text string UncheckedIcon string `yaml:"-"` } func NewChecklistItem(checked bool, date *time.Time, tags []string, text string, checkedIcon, uncheckedIcon string) *ChecklistItem { item := &ChecklistItem{ Checked: checked, CheckedIcon: checkedIcon, Date: date, Tags: tags, Text: text, UncheckedIcon: uncheckedIcon, } return item } // CheckMark returns the string used to indicate a ChecklistItem is checked or unchecked func (item *ChecklistItem) CheckMark() string { item.ensureItemIcons() if item.Checked { return item.CheckedIcon } return item.UncheckedIcon } // EditText returns the content of the edit todo form, so includes formatted date and tags func (item *ChecklistItem) EditText() string { datePrefix := "" if item.Date != nil { datePrefix = fmt.Sprintf("%d-%02d-%02d", item.Date.Year(), item.Date.Month(), item.Date.Day()) + " " } tagsPrefix := item.TagString() return datePrefix + tagsPrefix + item.Text } func (item *ChecklistItem) TagString() string { if len(item.Tags) == 0 { return "" } s := "" for _, tag := range item.Tags { s += "#" + tag + " " } return s } // Toggle changes the checked state of the ChecklistItem // If checked, it is unchecked. If unchecked, it is checked func (item *ChecklistItem) Toggle() { item.Checked = !item.Checked } /* -------------------- Unexported Functions -------------------- */ func (item *ChecklistItem) ensureItemIcons() { if item.CheckedIcon == "" { item.CheckedIcon = "x" } if item.UncheckedIcon == "" { item.UncheckedIcon = " " } } ================================================ FILE: checklist/checklist_item_test.go ================================================ package checklist import ( "testing" "github.com/stretchr/testify/assert" ) func testChecklistItem() *ChecklistItem { item := NewChecklistItem( false, nil, make([]string, 0), "test", "", "", ) return item } func Test_CheckMark(t *testing.T) { item := testChecklistItem() assert.Equal(t, " ", item.CheckMark()) item.Toggle() assert.Equal(t, "x", item.CheckMark()) } func Test_Toggle(t *testing.T) { item := testChecklistItem() assert.Equal(t, false, item.Checked) item.Toggle() assert.Equal(t, true, item.Checked) item.Toggle() assert.Equal(t, false, item.Checked) } ================================================ FILE: checklist/checklist_test.go ================================================ package checklist import ( "testing" "github.com/stretchr/testify/assert" ) func Test_NewCheckist(t *testing.T) { cl := NewChecklist("o", "-") assert.IsType(t, Checklist{}, cl) assert.Equal(t, "o", cl.checkedIcon) assert.Equal(t, -1, cl.selected) assert.Equal(t, "-", cl.uncheckedIcon) assert.Equal(t, 0, len(cl.Items)) } func Test_Add(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(true, nil, make([]string, 0), "test item") assert.Equal(t, 1, len(cl.Items)) } func Test_CheckedItems(t *testing.T) { tests := []struct { name string expectedLen int checkedLen int before func(cl *Checklist) }{ { name: "with no items", expectedLen: 0, checkedLen: 0, before: func(cl *Checklist) {}, }, { name: "with no checked items", expectedLen: 1, checkedLen: 0, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") }, }, { name: "with one checked item", expectedLen: 2, checkedLen: 1, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item") }, }, { name: "with multiple checked items", expectedLen: 3, checkedLen: 2, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item 11") cl.Add(true, nil, make([]string, 0), "checked item 2") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") tt.before(&cl) assert.Equal(t, tt.expectedLen, len(cl.Items)) assert.Equal(t, tt.checkedLen, len(cl.CheckedItems())) }) } } func Test_Delete(t *testing.T) { tests := []struct { name string idx int expectedLen int }{ { name: "with valid index", idx: 0, expectedLen: 0, }, { name: "with invalid index", idx: 2, expectedLen: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(true, nil, make([]string, 0), "test item") cl.Delete(tt.idx) assert.Equal(t, tt.expectedLen, len(cl.Items)) }) } } func Test_IsSelectable(t *testing.T) { tests := []struct { name string selected int expected bool }{ { name: "nothing selected", selected: -1, expected: false, }, { name: "valid selection", selected: 1, expected: true, }, { name: "invalid selection", selected: 3, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(true, nil, make([]string, 0), "test item 1") cl.Add(false, nil, make([]string, 0), "test item 2") cl.selected = tt.selected assert.Equal(t, tt.expected, cl.IsSelectable()) }) } } func Test_IsUnselectable(t *testing.T) { tests := []struct { name string selected int expected bool }{ { name: "nothing selected", selected: -1, expected: true, }, { name: "valid selection", selected: 1, expected: false, }, { name: "invalid selection", selected: 3, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(true, nil, make([]string, 0), "test item 1") cl.Add(false, nil, make([]string, 0), "test item 2") cl.selected = tt.selected assert.Equal(t, tt.expected, cl.IsUnselectable()) }) } } func Test_LongestLine(t *testing.T) { tests := []struct { name string expectedLen int before func(cl *Checklist) }{ { name: "with no items", expectedLen: 0, before: func(cl *Checklist) {}, }, { name: "with different-length items", expectedLen: 12, before: func(cl *Checklist) { cl.Add(true, nil, make([]string, 0), "test item 1") cl.Add(false, nil, make([]string, 0), "test item 22") }, }, { name: "with same-length items", expectedLen: 11, before: func(cl *Checklist) { cl.Add(true, nil, make([]string, 0), "test item 1") cl.Add(false, nil, make([]string, 0), "test item 2") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") tt.before(&cl) assert.Equal(t, tt.expectedLen, cl.LongestLine()) }) } } func Test_IndexByItem(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item") tests := []struct { name string item *ChecklistItem expectedIdx int expectedOk bool }{ { name: "with nil", item: nil, expectedIdx: 0, expectedOk: false, }, { name: "with valid item", item: cl.Items[1], expectedIdx: 1, expectedOk: true, }, { name: "with valid item", item: NewChecklistItem(false, nil, make([]string, 0), "invalid", "x", " "), expectedIdx: 0, expectedOk: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { idx, ok := cl.IndexByItem(tt.item) assert.Equal(t, tt.expectedIdx, idx) assert.Equal(t, tt.expectedOk, ok) }) } } func Test_UncheckedItems(t *testing.T) { tests := []struct { name string expectedLen int checkedLen int before func(cl *Checklist) }{ { name: "with no items", expectedLen: 0, checkedLen: 0, before: func(cl *Checklist) {}, }, { name: "with no unchecked items", expectedLen: 1, checkedLen: 0, before: func(cl *Checklist) { cl.Add(true, nil, make([]string, 0), "unchecked item") }, }, { name: "with one unchecked item", expectedLen: 2, checkedLen: 1, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item") }, }, { name: "with multiple unchecked items", expectedLen: 3, checkedLen: 2, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item 11") cl.Add(false, nil, make([]string, 0), "checked item 2") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") tt.before(&cl) assert.Equal(t, tt.expectedLen, len(cl.Items)) assert.Equal(t, tt.checkedLen, len(cl.UncheckedItems())) }) } } func Test_Unselect(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(false, nil, make([]string, 0), "unchecked item") cl.selected = 0 assert.Equal(t, 0, cl.selected) cl.Unselect() assert.Equal(t, -1, cl.selected) } /* -------------------- Sort Interface -------------------- */ func Test_Len(t *testing.T) { tests := []struct { name string expectedLen int before func(cl *Checklist) }{ { name: "with no items", expectedLen: 0, before: func(cl *Checklist) {}, }, { name: "with one item", expectedLen: 1, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") }, }, { name: "with multiple items", expectedLen: 3, before: func(cl *Checklist) { cl.Add(false, nil, make([]string, 0), "unchecked item") cl.Add(true, nil, make([]string, 0), "checked item 1") cl.Add(false, nil, make([]string, 0), "checked item 2") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") tt.before(&cl) assert.Equal(t, tt.expectedLen, cl.Len()) }) } } func Test_Less(t *testing.T) { tests := []struct { name string first string second string expected bool }{ { name: "same", first: "", second: "", expected: false, }, { name: "last less", first: "beta", second: "alpha", expected: true, }, { name: "first less", first: "alpha", second: "beta", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(false, nil, make([]string, 0), tt.first) cl.Add(false, nil, make([]string, 0), tt.second) assert.Equal(t, tt.expected, cl.Less(0, 1)) }) } } func Test_Swap(t *testing.T) { tests := []struct { name string first string second string expected bool }{ { name: "same", first: "", second: "", }, { name: "last less", first: "alpha", second: "beta", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cl := NewChecklist("o", "-") cl.Add(false, nil, make([]string, 0), tt.first) cl.Add(false, nil, make([]string, 0), tt.second) cl.Swap(0, 1) assert.Equal(t, tt.expected, cl.Items[0].Text == "beta") assert.Equal(t, tt.expected, cl.Items[1].Text == "alpha") }) } } ================================================ FILE: flags/flags.go ================================================ package flags import ( "fmt" "os" "path/filepath" "runtime/debug" "strings" "github.com/chzyer/readline" goFlags "github.com/jessevdk/go-flags" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/help" ) // Flags is the container for command line flag data type Flags struct { Config string `short:"c" long:"config" optional:"no" description:"Path to config file"` Module string `short:"m" long:"module" optional:"no" description:"Display info about a specific module, i.e.: 'wtfutil -m=todo'"` Profile bool `short:"p" long:"profile" optional:"yes" description:"Profile application memory usage"` Version bool `short:"v" long:"version" description:"Show version info"` // Work-around go-flags misfeatures. If any sub-command is defined // then `wtf` (no sub-commands, the common usage), is warned about. Opt struct { Cmd string `positional-arg-name:"command"` Args []string `positional-arg-name:"args"` } `positional-args:"yes"` hasCustom bool } var EXTRA = ` Commands: save-secret service Service URL or module name of secret. Save a secret into the secret store. The secret will be prompted for. Requires wtf.secretStore to be configured. See individual modules for information on what service and secret means for their configuration, not all modules use secrets. ` // NewFlags creates an instance of Flags func NewFlags() *Flags { flags := Flags{} return &flags } /* -------------------- Exported Functions -------------------- */ // ConfigFilePath returns the path to the currently-loaded config file func (flags *Flags) ConfigFilePath() string { return flags.Config } // RenderIf displays special-case information based on the flags passed // in, if any flags were passed in func (flags *Flags) RenderIf(config *config.Config) { if flags.HasModule() { help.Display(flags.Module, config) os.Exit(0) } if flags.HasVersion() { info, ok := debug.ReadBuildInfo() if !ok { os.Exit(1) return } var official bool var revision string version := info.Main.Version date := "unknown" // Check if this binary was built with git. If so, extract details. for _, setting := range info.Settings { switch setting.Key { case "vcs.revision": revision = setting.Value[0:12] // only need the 12 char hash case "vcs.time": date = setting.Value } } // if we're built with git... if revision != "" { if !strings.Contains(version, revision) { official = true } } if official { fmt.Printf("WTF %s (built: %s)\n", version, date) } else { fmt.Printf("WTF %s\nNote: This is an unofficial release.\n", version) } os.Exit(0) } if flags.Opt.Cmd == "" { return } switch cmd := flags.Opt.Cmd; cmd { case "save-secret": var service, secret string args := flags.Opt.Args if len(args) < 1 || args[0] == "" { fmt.Fprintf(os.Stderr, "save-secret: service required, see `%s --help`\n", os.Args[0]) os.Exit(1) } service = args[0] if len(args) > 1 { fmt.Fprintf(os.Stderr, "save-secret: too many arguments, see `%s --help`\n", os.Args[0]) os.Exit(1) } b, err := readline.Password("Secret: ") if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } secret = string(b) secret = strings.TrimSpace(secret) if secret == "" { fmt.Fprintf(os.Stderr, "save-secret: secret required, see `%s --help`\n", os.Args[0]) os.Exit(1) } err = cfg.StoreSecret(config, &cfg.Secret{ Service: service, Secret: secret, Username: "default", }) if err != nil { fmt.Fprintf(os.Stderr, "Saving secret for service %q: %s\n", service, err.Error()) os.Exit(1) } fmt.Printf("Saved secret for service %q\n", service) os.Exit(0) default: fmt.Fprintf(os.Stderr, "Command `%s` is not supported, try `%s --help`\n", cmd, os.Args[0]) os.Exit(1) } } // HasCustomConfig returns TRUE if a config path was passed in, FALSE if one was not func (flags *Flags) HasCustomConfig() bool { return flags.hasCustom } // HasModule returns TRUE if a module name was passed in, FALSE if one was not func (flags *Flags) HasModule() bool { return len(flags.Module) > 0 } // HasVersion returns TRUE if the version flag was passed in, FALSE if it was not func (flags *Flags) HasVersion() bool { return flags.Version } // Parse parses the incoming flags func (flags *Flags) Parse() { parser := goFlags.NewParser(flags, goFlags.Default) if _, err := parser.Parse(); err != nil { if flagsErr, ok := err.(*goFlags.Error); ok && flagsErr.Type == goFlags.ErrHelp { fmt.Println(EXTRA) os.Exit(0) } } // If we have a custom config, then we're done parsing parameters, we don't need to // generate the default value flags.hasCustom = (len(flags.Config) > 0) if flags.hasCustom { return } // If no config file is explicitly passed in as a param // then try the `WTF_CONFIG` environment variable // then fallback to the default config file to define the default flag value configDir, err := cfg.WtfConfigDir() if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } envCfg := os.Getenv("WTF_CONFIG") if envCfg == "" { flags.Config = filepath.Join(configDir, "config.yml") } else { flags.Config = envCfg } } ================================================ FILE: generator/settings.tpl ================================================ package {{(Lower .Name)}} import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "{{(.Name)}}" ) // Settings defines the configuration properties for this module type Settings struct { common *cfg.Common // Define your settings attributes here } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), // Configure your settings attributes here. See http://github.com/olebedev/config for type details } return &settings } ================================================ FILE: generator/textwidget.go ================================================ //go:build ignore // This package takes care of generates for empty widgets. Each generator is named after the // type of widget it generate, so textwidget.go will generate the skeleton for a new TextWidget // using the textwidget.tpl template. // The TextWidget generator needs one environment variable, called WTF_WIDGET_NAME, which will // be the name of the TextWidget it generates. If the variable hasn't been set, the generator // will use "NewTextWidget". On Linux and macOS the command can be run as // 'WTF_WIDGET_NAME=MyNewWidget go generate -run=text'. package main import ( "fmt" "os" "strings" "text/template" ) const ( defaultWidgetName = "NewTextWidget" widgetMaker = "app/widget_maker.go" ) func main() { widgetName, present := os.LookupEnv("WTF_WIDGET_NAME") if !present { widgetName = defaultWidgetName } data := struct { Name string }{ widgetName, } createModuleDirectory(data) generateWidgetFile(data) generateSettingsFile(data) fmt.Println("Don't forget to register your module in file", widgetMaker) } /* -------------------- Unexported Functions -------------------- */ func createModuleDirectory(data struct{ Name string }) { err := os.MkdirAll(strings.ToLower(fmt.Sprintf("modules/%s", data.Name)), os.ModePerm) if err != nil { fmt.Println(err.Error()) } } func generateWidgetFile(data struct{ Name string }) { tpl, _ := template.New("textwidget.tpl").Funcs(template.FuncMap{ "Lower": strings.ToLower, }).ParseFiles("generator/textwidget.tpl") out, err := os.Create(fmt.Sprintf("modules/%s/widget.go", strings.ToLower(data.Name))) if err != nil { fmt.Println(err.Error()) } defer out.Close() tpl.Execute(out, data) } func generateSettingsFile(data struct{ Name string }) { tpl, _ := template.New("settings.tpl").Funcs(template.FuncMap{ "Lower": strings.ToLower, }).ParseFiles("generator/settings.tpl") out, err := os.Create(fmt.Sprintf("modules/%s/settings.go", strings.ToLower(data.Name))) if err != nil { fmt.Println(err.Error()) } defer out.Close() tpl.Execute(out, data) } ================================================ FILE: generator/textwidget.tpl ================================================ package {{(Lower .Name)}} import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { return "This is my widget" } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } ================================================ FILE: go.mod ================================================ module github.com/wtfutil/wtf go 1.26.1 require ( bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a code.cloudfoundry.org/bytefmt v0.66.0 github.com/Microsoft/go-winio v0.6.2 // indirect github.com/PagerDuty/go-pagerduty v1.8.0 github.com/VictorAvelar/devto-api-go v1.0.0 github.com/adlio/trello v1.12.0 github.com/alecthomas/chroma v0.10.0 github.com/andygrunwald/go-gerrit v1.1.1 github.com/briandowns/openweathermap v0.21.1 github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/chzyer/readline v1.5.1 github.com/creack/pty v1.1.24 github.com/digitalocean/godo v1.175.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/docker-credential-helpers v0.9.5 github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 github.com/gdamore/tcell v1.4.1 github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus v4.1.0+incompatible // indirect github.com/google/go-github/v32 v32.1.0 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/jessevdk/go-flags v1.6.1 github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 github.com/mmcdole/gofeed v1.3.0 github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 github.com/olekukonko/tablewriter v0.0.5 github.com/ovh/cds v0.55.1 github.com/pborman/uuid v1.2.0 // indirect github.com/piquette/finance-go v1.1.0 github.com/pkg/errors v0.9.1 github.com/pkg/profile v1.7.0 github.com/radovskyb/watcher v1.0.7 github.com/rivo/tview v0.42.0 github.com/shirou/gopsutil v2.21.11+incompatible github.com/shopspring/decimal v1.3.1 // indirect github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/stretchr/testify v1.11.1 github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a github.com/zmb3/spotify v1.3.0 github.com/zorkian/go-datadog-api v2.30.0+incompatible golang.org/x/oauth2 v0.35.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 google.golang.org/api v0.266.0 gopkg.in/yaml.v2 v2.4.0 gotest.tools v2.2.0+incompatible jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 k8s.io/apimachinery v0.35.2 k8s.io/client-go v0.35.2 ) require github.com/nicklaw5/helix/v2 v2.32.0 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery v1.2.0 github.com/charmbracelet/bubbles v0.21.1 github.com/gdamore/tcell/v2 v2.13.8 github.com/gopherlibs/todoist v0.1.0 github.com/hekmon/transmissionrpc/v2 v2.0.1 github.com/logrusorgru/aurora/v4 v4.0.0 github.com/muesli/reflow v0.3.0 github.com/prometheus-community/pro-bing v0.8.0 gitlab.com/gitlab-org/api/client-go v0.160.1 gopkg.in/yaml.v3 v3.0.1 ) require ( cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect contrib.go.opencensus.io/exporter/jaeger v0.2.1 // indirect contrib.go.opencensus.io/exporter/prometheus v0.4.2 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/CycloneDX/cyclonedx-go v0.8.0 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/PuerkitoBio/goquery v1.8.0 // indirect github.com/RackSec/srslog v0.0.0-20180709174129-a4725f04ec91 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230321174746-8dcc6526cfb1 // indirect github.com/aokoli/goutils v1.1.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.5 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/eapache/go-resiliency v1.3.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.16.0 // indirect github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/forPelevin/gomoji v1.1.8 // indirect github.com/fsamin/go-dump v1.0.9 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.5 // indirect github.com/go-gorp/gorp v2.0.0+incompatible // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-logfmt/logfmt v0.5.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/gorhill/cronexpr v0.0.0-20161205141322-d520615e531a // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hekmon/cunits/v2 v2.1.0 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/iancoleman/orderedmap v0.3.0 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jfrog/archiver/v3 v3.6.0 // indirect github.com/jfrog/build-info-go v1.9.23 // indirect github.com/jfrog/gofrog v1.6.0 // indirect github.com/jfrog/jfrog-client-go v1.37.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/maruel/panicparse/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/hashstructure v0.0.0-20170609045927-2bca23e0e452 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/onsi/ginkgo v1.16.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc6 // indirect github.com/ovh/cds/sdk/interpolate v0.0.0-20190319104452-71125b036b25 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect github.com/prometheus/statsd_exporter v0.22.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rockbears/log v0.11.2 // indirect github.com/rockbears/yaml v0.4.0 // indirect github.com/rs/xid v1.2.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sguiheux/go-coverage v0.0.0-20190710153556-287b082a7197 // indirect github.com/sguiheux/jsonschema v0.0.0-20240314085137-97ecc280683c // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/numcpus v0.4.0 // indirect github.com/uber/jaeger-client-go v2.25.0+incompatible // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/AlecAivazis/survey.v1 v1.7.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.3.0 // indirect k8s.io/api v0.35.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) ================================================ FILE: go.sum ================================================ bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a h1:qH51iOpTres3x2kNb0f2R3ggMpbYCyCvaRrsvdndhvY= bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a/go.mod h1:HcP4iCG6i6uVAyX2X7yKOsjbzLFiTfX0EMT20CYn5Ig= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= code.cloudfoundry.org/bytefmt v0.66.0 h1:tSK2uf1Shxb5SIc7W9RE+FSKnwuFwzoEqkpwwVQx0SM= code.cloudfoundry.org/bytefmt v0.66.0/go.mod h1:JrBuqsb9cQeqrVSdWvcDLjnxD6RXfAev2s8nwjskhhs= contrib.go.opencensus.io/exporter/jaeger v0.2.1 h1:yGBYzYMewVL0yO9qqJv3Z5+IRhPdU7e9o/2oKpX4YvI= contrib.go.opencensus.io/exporter/jaeger v0.2.1/go.mod h1:Y8IsLgdxqh1QxYxPC5IgXVmBaeLUeQFfBeBi9PbeZd0= contrib.go.opencensus.io/exporter/prometheus v0.4.2 h1:sqfsYl5GIY/L570iT+l93ehxaWJs2/OwXtiWwew3oAg= contrib.go.opencensus.io/exporter/prometheus v0.4.2/go.mod h1:dvEHbiKmgvbr5pjaF9fpw1KeYcjrnC1J8B+JKjsZyRQ= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery v1.2.0 h1:s0SaQtHigowP0n3Kx4ieV94pNZAHlHhS+xjZyLCSVCQ= github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery v1.2.0/go.mod h1:oI5SPI1vpNJYfP9MPWXthq7jDfh9xTAuQVBKPOu7DPo= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CycloneDX/cyclonedx-go v0.8.0 h1:FyWVj6x6hoJrui5uRQdYZcSievw3Z32Z88uYzG/0D6M= github.com/CycloneDX/cyclonedx-go v0.8.0/go.mod h1:K2bA+324+Og0X84fA8HhN2X066K7Bxz4rpMQ4ZhjtSk= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Netflix/go-expect v0.0.0-20180928190340-9d1f4485533b h1:sSQK05nvxs4UkgCJaxihteu+r+6ela3dNMm7NVmsS3c= github.com/Netflix/go-expect v0.0.0-20180928190340-9d1f4485533b/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/PagerDuty/go-pagerduty v1.8.0 h1:MTFqTffIcAervB83U7Bx6HERzLbyaSPL/+oxH3zyluI= github.com/PagerDuty/go-pagerduty v1.8.0/go.mod h1:nzIeAqyFSJAFkjWKvMzug0JtwDg+V+UoCWjFrfFH5mI= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/RackSec/srslog v0.0.0-20180709174129-a4725f04ec91 h1:vX+gnvBc56EbWYrmlhYbFYRaeikAke1GL84N4BEYOFE= github.com/RackSec/srslog v0.0.0-20180709174129-a4725f04ec91/go.mod h1:cDLGBht23g0XQdLjzn6xOGXDkLK182YfINAaZEQLCHQ= github.com/VictorAvelar/devto-api-go v1.0.0 h1:oXmzye3xYvlgBX18vX4+v6LVbjoihgIokpeOpzeJzqU= github.com/VictorAvelar/devto-api-go v1.0.0/go.mod h1:gX13cqzMdpo49qP8VtBR2uCnzW7d76LFrAVSX2eLifY= github.com/adlio/trello v1.12.0 h1:JqOE2GFHQ9YtEviRRRSnicSxPbt4WFOxhqXzjMOw8lw= github.com/adlio/trello v1.12.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/andygrunwald/go-gerrit v1.1.1 h1:U1Aw5WrwWIVc8PK+jMFmMGzGTYZFI8re8JS6DEGNYfA= github.com/andygrunwald/go-gerrit v1.1.1/go.mod h1:SeP12EkHZxEVjuJ2HZET304NBtHGG2X6w2Gzd0QXAZw= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230321174746-8dcc6526cfb1 h1:X8MJ0fnN5FPdcGF5Ij2/OW+HgiJrRg3AfHAx1PJtIzM= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230321174746-8dcc6526cfb1/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/aokoli/goutils v1.1.0/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/aokoli/goutils v1.1.1 h1:/hA+Ywo3AxoDZY5ZMnkiEkUvkK4BPp927ax110KCqqg= github.com/aokoli/goutils v1.1.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/briandowns/openweathermap v0.21.1 h1:TPbuixuF+aGJP1mpgTNny6eUkdbvj7gqODGXkwhss48= github.com/briandowns/openweathermap v0.21.1/go.mod h1:0GLnknqicWxXnGi1IqoOaZIw+kIe5hkt+YM5WY3j8+0= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXsI7oU= github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.3.0 h1:RRL0nge+cWGlxXbUzJ7yMcq6w2XBEr19dCN6HECGaT0= github.com/eapache/go-resiliency v1.3.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/forPelevin/gomoji v1.1.8 h1:JElzDdt0TyiUlecy6PfITDL6eGvIaxqYH1V52zrd0qQ= github.com/forPelevin/gomoji v1.1.8/go.mod h1:8+Z3KNGkdslmeGZBC3tCrwMrcPy5GRzAD+gL9NAwMXg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsamin/go-dump v1.0.9 h1:3MAneAJLnGfKTJtFEAdgrD+QqqK2Hwj7EJUQMQZcDls= github.com/fsamin/go-dump v1.0.9/go.mod h1:ZgKd2aOXAFFbbFuUgvQhu7mwTlI3d3qnTICMWdvAa9o= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell v1.4.1 h1:6T2+7Zl5U44SU3ensYi/w4SX5hpzbK6NDUDYmgCP3eQ= github.com/gdamore/tcell v1.4.1/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gorp/gorp v2.0.0+incompatible h1:dIQPsBtl6/H1MjVseWuWPXa7ET4p6Dve4j3Hg+UjqYw= github.com/go-gorp/gorp v2.0.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gopherlibs/todoist v0.1.0 h1:9a/fs7RWhiMG9HsEXgqsht3ItAIT+FXQs01PvZ+QlIU= github.com/gopherlibs/todoist v0.1.0/go.mod h1:SLnIuCt7Z0Vn9msbmwX8KeksWJKIpjsNSD8LWOflceU= github.com/gorhill/cronexpr v0.0.0-20161205141322-d520615e531a h1:yNuTIQkXLNAevCwQJ7ur3ZPoZPhbvAi6QXhJ/ylX6+8= github.com/gorhill/cronexpr v0.0.0-20161205141322-d520615e531a/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hekmon/cunits/v2 v2.1.0 h1:k6wIjc4PlacNOHwKEMBgWV2/c8jyD4eRMs5mR1BBhI0= github.com/hekmon/cunits/v2 v2.1.0/go.mod h1:9r1TycXYXaTmEWlAIfFV8JT+Xo59U96yUJAYHxzii2M= github.com/hekmon/transmissionrpc/v2 v2.0.1 h1:WkILCEdbNy3n/N/w7mi449waMPdH2AA1THyw7TfnN/w= github.com/hekmon/transmissionrpc/v2 v2.0.1/go.mod h1:+s96Pkg7dIP3h2PT3fzhXPvNb3OdLryh5J8PIvQg3aA= github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c h1:kp3AxgXgDOmIJFR7bIwqFhwJ2qWar8tEQSE5XXhCfVk= github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jfrog/archiver/v3 v3.6.0 h1:OVZ50vudkIQmKMgA8mmFF9S0gA47lcag22N13iV3F1w= github.com/jfrog/archiver/v3 v3.6.0/go.mod h1:fCAof46C3rAXgZurS8kNRNdSVMKBbZs+bNNhPYxLldI= github.com/jfrog/build-info-go v1.9.23 h1:+TwUIBEJwRvz9skR8xBfY5ti8Vl4Z6iMCkFbkclnEN0= github.com/jfrog/build-info-go v1.9.23/go.mod h1:QHcKuesY4MrBVBuEwwBz4uIsX6mwYuMEDV09ng4AvAU= github.com/jfrog/gofrog v1.6.0 h1:jOwb37nHY2PnxePNFJ6e6279Pgkr3di05SbQQw47Mq8= github.com/jfrog/gofrog v1.6.0/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/jfrog-client-go v1.37.1 h1:BqIWGPajC5vhUo5dcQ9KEJr0EVANr/O4cfEqRYvzvRg= github.com/jfrog/jfrog-client-go v1.37.1/go.mod h1:y+zeO0LeT2uHoHs4/fXHrm5dfF02bg6Dw3cNJxgJ5LY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/panicparse/v2 v2.2.2 h1:4Gu/Z5oLpJCE/0/NwxrUkyn7alpqOQdJAUuchB2OoJU= github.com/maruel/panicparse/v2 v2.2.2/go.mod h1:WizmeHJfpyKYYKGInKv8ax8jh7DJnQE5yFDuzFfHzIU= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s= github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/hashstructure v0.0.0-20170609045927-2bca23e0e452 h1:hOY53G+kBFhbYFpRVxHl5eS7laP6B1+Cq+Z9Dry1iMU= github.com/mitchellh/hashstructure v0.0.0-20170609045927-2bca23e0e452/go.mod h1:QjSHrPWS+BGUVBYkbTZWEnOh3G1DutKwClXU/ABz6AQ= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nicklaw5/helix/v2 v2.32.0 h1:ZRPt+wRUMQqpny6yZKVY9rUGNwv+ZmIh75fSiopMXuY= github.com/nicklaw5/helix/v2 v2.32.0/go.mod h1:KaXa2mb2kBzsDana9RbXevTgnfU95DMoSORWo2hqlWA= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 h1:JnVsYEQzhEcOspy6ngIYNF2u0h2mjkXZptzX0IzZQ4g= github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4/go.mod h1:RL5+WRxWTAXqqCi9i+eZlHrUtO7AQujUqWi+xMohmc4= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc6 h1:XDqvyKsJEbRtATzkgItUqBA7QHk58yxX1Ov9HERHNqU= github.com/opencontainers/image-spec v1.1.0-rc6/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/ovh/cds v0.55.1 h1:HswwKxa3cWmrA0ldbJQPo8NlbuaM7XWbWLnmB1QDOQ8= github.com/ovh/cds v0.55.1/go.mod h1:o0/2LYGTbcZ1Ozo3Hi4O2CBPoJ3E5F7yKDtU8zYYw5o= github.com/ovh/cds/sdk/interpolate v0.0.0-20190319104452-71125b036b25 h1:pV462fyYKs6YRXCrj5OI0OgA6wVWSiQmtG8SAJdT1WQ= github.com/ovh/cds/sdk/interpolate v0.0.0-20190319104452-71125b036b25/go.mod h1:qrsz1nc0EPZnhuTLZpKK4Y7awUQdeKmkY5M6DbKi6Ws= github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/piquette/finance-go v1.1.0 h1:3J5VBP6aPhvrj9Eg6Eus8eM6QJlX4l/wCfrJhONjS3k= github.com/piquette/finance-go v1.1.0/go.mod h1:jaHaD5JJEWpl5mW712M8gRboc2xvhjshF3lqw/ke7AA= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.35.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/prometheus/statsd_exporter v0.22.7 h1:7Pji/i2GuhK6Lu7DHrtTkFmNBCudCPT1pX2CziuyQR0= github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rockbears/log v0.11.2 h1:YjM+lAyXv4UA5/23trG1VXW3UveHqU7Vcav+PJN8cNw= github.com/rockbears/log v0.11.2/go.mod h1:cRirhSHaq6iYYTy3Sf6moRdIEE5+hZOjqNMoi9XuFJw= github.com/rockbears/yaml v0.4.0 h1:Mvxo/KXPdZ2x3XOMM+xj0Vvm3sb6E2uh4jeoCtdHab4= github.com/rockbears/yaml v0.4.0/go.mod h1:8cDJx2PWQJMtfGgsRCvHVbIB61SV3dvy8o6EGv2cIpg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sguiheux/go-coverage v0.0.0-20190710153556-287b082a7197 h1:qu90yDtRE5WEfRT5mn9v0Xz9RaopLguhbPwZKx4dHq8= github.com/sguiheux/go-coverage v0.0.0-20190710153556-287b082a7197/go.mod h1:0hhKrsUsoT7yvxwNGKa+TSYNA26DNWMqReeZEQq/9FI= github.com/sguiheux/jsonschema v0.0.0-20240314085137-97ecc280683c h1:hQYbZuIznuaJ8YLitOm0exsrP3qO9thLGG5eDGRpmEw= github.com/sguiheux/jsonschema v0.0.0-20240314085137-97ecc280683c/go.mod h1:9NwRfsAcwe0ZCLkSCziM+PtKQYJAzjydh4d8dMxggT0= github.com/shirou/gopsutil v2.21.11+incompatible h1:lOGOyCG67a5dv2hq5Z1BLDUqqKp3HkbjPcz5j6XMS0U= github.com/shirou/gopsutil v2.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 h1:CA6Mjshr+g5YHENwllpQNR0UaYO7VGKo6TzJLM64WJQ= github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uRtcvYAPLCF15qguo= github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= github.com/uber/jaeger-client-go v2.25.0+incompatible h1:IxcNZ7WRY1Y3G4poYlx24szfsn/3LvK9QHCq9oQw8+U= github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a h1:2eyMT9EpTPS4PiVfvXvqA8PKB5FoSl6gGjgb3CQ0cug= github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a/go.mod h1:AlO4kKlF1zyOHTq2pBzxEERdBDStJev0VZNukFEqz/E= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= github.com/zmb3/spotify v1.3.0 h1:6Z2F1IMx0Hviq/dpf8nFwvKPppFEMXn8yfReSBVi16k= github.com/zmb3/spotify v1.3.0/go.mod h1:GD7AAEMUJVYc2Z7p2a2S0E3/5f/KxM/vOnErNr4j+Tw= github.com/zorkian/go-datadog-api v2.30.0+incompatible h1:R4ryGocppDqZZbnNc5EDR8xGWF/z/MxzWnqTUijDQes= github.com/zorkian/go-datadog-api v2.30.0+incompatible/go.mod h1:PkXwHX9CUQa/FpB9ZwAD45N1uhCW4MT/Wj7m36PbKss= gitlab.com/gitlab-org/api/client-go v0.160.1 h1:7kEgo1yQ3ZMRps/2JbXzqbRb4Rs8n2ECkAv+6MadJw8= gitlab.com/gitlab-org/api/client-go v0.160.1/go.mod h1:YqKcnxyV9OPAL5U99mpwBVEgBPz1PK/3qwqq/3h6bao= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/AlecAivazis/survey.v1 v1.7.1 h1:mzQIVyOPSXJaQWi1m6AFCjrCEPIwQBSOn48Ri8ZpzAg= gopkg.in/AlecAivazis/survey.v1 v1.7.1/go.mod h1:2Ehl7OqkBl3Xb8VmC4oFW2bItAhnUfzIjrOzwRxCrOU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:mub0MmFLOn8XLikZOAhgLD1kXJq8jgftSrrv7m00xFo= jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4= k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: help/help.go ================================================ package help import ( "fmt" "github.com/olebedev/config" "github.com/wtfutil/wtf/app" "github.com/wtfutil/wtf/utils" ) // Display displays the output of the --help argument func Display(moduleName string, cfg *config.Config) { if moduleName == "" { fmt.Println("\n --module takes a module name as an argument, i.e: '--module=github'") } else { fmt.Printf("%s\n", helpFor(moduleName, cfg)) } } func helpFor(moduleName string, cfg *config.Config) string { err := cfg.Set("wtf.mods."+moduleName+".enabled", true) if err != nil { return "" } widget := app.MakeWidget(nil, nil, moduleName, cfg, nil) // Since we are forcing enabled config, if no module // exists, we will get the unknown one if widget.CommonSettings().Title == "Unknown" { return "Unable to find module " + moduleName } result := "" result += utils.StripColorTags(widget.HelpText()) result += "\n" result += "Configuration Attributes" result += widget.ConfigText() return result } ================================================ FILE: logger/log.go ================================================ package logger import ( "log" "os" "path/filepath" ) /* -------------------- Exported Functions -------------------- */ func Log(msg string) { if LogFileMissing() { return } f, err := os.OpenFile(LogFilePath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) if err != nil { log.Fatalf("error opening file: %v", err) } defer func() { _ = f.Close() }() log.SetOutput(f) log.Println(msg) } func LogFileMissing() bool { return LogFilePath() == "" } func LogFilePath() string { dir, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(dir, ".config", "wtf", "log.txt") } ================================================ FILE: main.go ================================================ package main import ( "fmt" "log" "os" // Blank import of tzdata embeds the timezone database to allow Windows hosts to find timezone // information even if the timezone database is not available on the local system. See release // notes at https://golang.org/doc/go1.15#time/tzdata for details. This prevents "no timezone // data available" errors in clocks module. _ "time/tzdata" "github.com/logrusorgru/aurora/v4" "github.com/pkg/profile" "github.com/wtfutil/wtf/app" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/flags" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/wtf" ) /* -------------------- Main -------------------- */ func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) // Parse and handle flags flags := flags.NewFlags() flags.Parse() // Load the configuration file cfg.Initialize(flags.HasCustomConfig()) config := cfg.LoadWtfConfigFile(flags.ConfigFilePath()) wtf.SetTerminal(config) flags.RenderIf(config) if flags.Profile { defer profile.Start(profile.MemProfile).Stop() } openFileUtil := config.UString("wtf.openFileUtil", "open") openURLUtil := utils.ToStrs(config.UList("wtf.openUrlUtil", []interface{}{})) utils.Init(openFileUtil, openURLUtil) /* Initialize the App Manager */ appMan := app.NewAppManager() appMan.MakeNewWtfApp(config, flags.Config) currentApp, err := appMan.Current() if err != nil { fmt.Printf("\n%s %v\n", aurora.Red("ERROR"), err) os.Exit(1) } err = currentApp.Execute() if err != nil { fmt.Printf("\n%s %v\n", aurora.Red("ERROR"), err) os.Exit(1) } } ================================================ FILE: modules/airbrake/client.go ================================================ package airbrake import ( "fmt" "net/http" "github.com/wtfutil/wtf/utils" ) func project(projectID int, authToken string) (*Project, error) { url := fmt.Sprintf( "https://api.airbrake.io/api/v4/projects/%d?key=%s", projectID, authToken) req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } p := &ProjectJSON{} err = utils.ParseJSON(p, resp.Body) if err != nil { return nil, err } return &p.Project, nil } func groups(projectID int, authToken string) ([]Group, error) { url := fmt.Sprintf( "https://api.airbrake.io/api/v4/projects/%d/groups?key=%s&limit=10&order=last_notice&resolved=false", projectID, authToken) req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() j := &GroupJSON{} err = utils.ParseJSON(j, resp.Body) if err != nil { return nil, err } return j.Groups, nil } func resolveGroup(projectID int64, groupID, authToken string) error { url := fmt.Sprintf( "https://airbrake.io/api/v4/projects/%d/groups/%s/resolved?key=%s", projectID, groupID, authToken) req, err := http.NewRequest("PUT", url, http.NoBody) if err != nil { return err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } func muteGroup(projectID int64, groupID, authToken string) error { url := fmt.Sprintf( "https://airbrake.io/api/v4/projects/%d/groups/%s/muted?key=%s", projectID, groupID, authToken) req, err := http.NewRequest("PUT", url, http.NoBody) if err != nil { return err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } func unmuteGroup(projectID int64, groupID, authToken string) error { url := fmt.Sprintf( "https://airbrake.io/api/v4/projects/%d/groups/%s/unmuted?key=%s", projectID, groupID, authToken) req, err := http.NewRequest("PUT", url, http.NoBody) if err != nil { return err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } ================================================ FILE: modules/airbrake/group_info_table.go ================================================ package airbrake import ( "fmt" "strconv" "github.com/wtfutil/wtf/view" ) type groupInfoTable struct { group *Group propertyMap map[string]string colWidth0 int colWidth1 int tableHeight int } func newGroupInfoTable(g *Group) *groupInfoTable { propTable := &groupInfoTable{ group: g, colWidth0: 20, colWidth1: 51, tableHeight: 15, } propTable.propertyMap = propTable.buildPropertyMap() return propTable } func (propTable *groupInfoTable) buildPropertyMap() map[string]string { propMap := map[string]string{} g := propTable.group if g == nil { return propMap } propMap["1. First Seen"] = g.CreatedAt propMap["2. Last Seen"] = g.LastNoticeAt propMap["3. Occurrences"] = strconv.Itoa(int(g.NoticeCount)) propMap["4. Environment"] = g.Context.Environment propMap["5. Severity"] = g.Context.Severity propMap["6. Muted"] = fmt.Sprintf("%v", g.Muted) propMap["7. File"] = g.File() return propMap } func (propTable *groupInfoTable) render() string { tbl := view.NewInfoTable( []string{"Property", "Value"}, propTable.propertyMap, propTable.colWidth0, propTable.colWidth1, propTable.tableHeight, ) return tbl.Render() } ================================================ FILE: modules/airbrake/keyboard.go ================================================ package airbrake import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("o", widget.openGroup, "Open group in browser") widget.SetKeyboardChar("s", widget.resolveGroup, "Resolve group") widget.SetKeyboardChar("m", widget.muteGroup, "Mute group") widget.SetKeyboardChar("u", widget.unmuteGroup, "Unmute group") widget.SetKeyboardChar("t", widget.toggleDisplayText, "Toggle between title and compare views") widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyEnter, widget.viewGroup, "View group") } ================================================ FILE: modules/airbrake/result_table.go ================================================ package airbrake import ( "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type resultTable struct { propertyMap map[string]string colWidth0 int colWidth1 int tableHeight int } func newResultTable(result, message string) *resultTable { propTable := &resultTable{ colWidth0: 20, colWidth1: 51, tableHeight: 15, } propTable.propertyMap = map[string]string{result: message} return propTable } func (propTable *resultTable) render() string { tbl := view.NewInfoTable( []string{"Result", "Message"}, propTable.propertyMap, propTable.colWidth0, propTable.colWidth1, propTable.tableHeight, ) return tbl.Render() + utils.CenterText("Esc to close", 80) } ================================================ FILE: modules/airbrake/settings.go ================================================ package airbrake import ( "os" "strconv" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Airbrake" ) type Settings struct { *cfg.Common projectID int `help:"The id of your Airbrake project."` authToken string `help:"The token that allows accessing Airbrake API"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), projectID: ymlConfig.UInt("projectID", getProjectID()), authToken: ymlConfig.UString("authToken", os.Getenv("AIRBRAKE_USER_KEY")), } cfg.ModuleSecret(name, globalConfig, &settings.authToken).Load() return &settings } func getProjectID() int { projectID, err := strconv.ParseInt(os.Getenv("AIRBRAKE_PROJECT_ID"), 10, 32) if err != nil { return 0 } return int(projectID) } ================================================ FILE: modules/airbrake/util.go ================================================ package airbrake func reverseString(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) } ================================================ FILE: modules/airbrake/widget.go ================================================ package airbrake import ( "bytes" "fmt" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const module = "Airbrake" var emojis = map[string]string{ "bug": "🐛", "bell with slash": "🔕", } type ShowType int const ( SHOW_TITLE ShowType = iota SHOW_COMPARE ) type Widget struct { view.ScrollableWidget settings *Settings app *tview.Application pages *tview.Pages groups []Group project *Project showType ShowType err error } type GroupJSON struct { Groups []Group `json:"groups"` } type Group struct { ID string `json:"id"` ProjectID int64 `json:"projectId"` Errors []Error NoticeCount int64 `json:"noticeCount"` CreatedAt string `json:"createdAt"` LastNoticeAt string `json:"lastNoticeAt"` Context GroupContext Muted bool `json:"muted"` CommentCount int64 `json:"commentCount"` } type GroupContext struct { Environment string `json:"environment"` Severity string `json:"severity"` } func (g *Group) Link() string { return fmt.Sprintf("https://airbrake.io/projects/%d/groups/%s", g.ProjectID, g.ID) } func (g *Group) Title() string { return fmt.Sprintf("%s: %s", g.Type(), g.Message()) } func (g *Group) Type() string { return g.Errors[0].Type } func (g *Group) Message() string { err := g.Errors[0] return strings.ReplaceAll(err.Message, "\n", ". ") } func (g *Group) File() string { s := fmt.Sprintf("%s:%d", g.Errors[0].Backtrace[0].File, g.Errors[0].Backtrace[0].Line) return reverseString(utils.Truncate(reverseString(s), 51, true)) } type Error struct { Type string `json:"type"` Message string `json:"message"` Backtrace []StackFrame `json:"backtrace"` } type StackFrame struct { File string `json:"file"` Function string `json:"function"` Line int64 `json:"line"` } type ProjectJSON struct { Project Project `json:"project"` } type Project struct { Name string `json:"name"` } func rotateShowType(showtype ShowType) ShowType { returnValue := SHOW_TITLE switch showtype { case SHOW_TITLE: returnValue = SHOW_COMPARE case SHOW_COMPARE: returnValue = SHOW_TITLE } return returnValue } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), app: tviewApp, settings: settings, pages: pages, showType: SHOW_TITLE, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } groups, err := groups( widget.settings.projectID, widget.settings.authToken) if err != nil { widget.err = err widget.groups = nil widget.SetItemCount(0) } else { widget.err = nil widget.groups = groups widget.SetItemCount(len(groups)) } project, err := project( widget.settings.projectID, widget.settings.authToken) if err != nil { widget.err = err widget.project = nil } else { widget.err = nil widget.project = project } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { if widget.err != nil { return module, widget.err.Error(), true } project := widget.project if project != nil && project.Name == "" { return module, "No project found", true } title := fmt.Sprintf("%s %s - %s's recent errors", emojis["bug"], module, project.Name) result := widget.groups if result == nil || len(widget.groups) == 0 { return title, "All your errors are resolved!", false } var str string for idx, g := range widget.groups { rowColor := widget.RowColor(idx) var row string if widget.showType == SHOW_TITLE { var buf bytes.Buffer if g.Muted { buf.WriteString(emojis["bell with slash"]) } else { buf.WriteString(" ") } buf.WriteString(" " + g.Title()) row = fmt.Sprintf("[%s]%2d. %s[white]", rowColor, idx+1, buf.String()) } else { row = fmt.Sprintf( "[%s]%2d. %-31s %-11s %-10s count: %-9d comments: %-2d[white]", rowColor, idx+1, utils.Truncate(g.Type(), 30, true), g.Context.Environment, g.Context.Severity, g.NoticeCount, g.CommentCount) } str += utils.HighlightableHelper(widget.View, row, idx, len(g.Type())) } return title, str, false } func (widget *Widget) openGroup() { sel := widget.GetSelected() if sel >= 0 && widget.groups != nil && sel < len(widget.groups) { group := widget.groups[sel] utils.OpenFile(group.Link()) } } func (widget *Widget) viewGroup() { sel := widget.GetSelected() if sel >= 0 && widget.groups != nil && sel < len(widget.groups) { group := widget.groups[sel] closeFunc := func() { widget.pages.RemovePage("group info") widget.app.SetFocus(widget.View) } table := newGroupInfoTable(&group).render() table += utils.CenterText("Esc to close", 80) modal := view.NewBillboardModal(table, closeFunc) modal.SetTitle(fmt.Sprintf(" %s ", group.Title())) widget.pages.AddPage("group info", modal, false, true) widget.app.SetFocus(modal) widget.app.QueueUpdateDraw(func() { widget.app.Draw() }) } } func (widget *Widget) resolveGroup() { sel := widget.GetSelected() if sel >= 0 && widget.groups != nil && sel < len(widget.groups) { group := widget.groups[sel] closeFunc := func() { widget.pages.RemovePage("resolve") widget.app.SetFocus(widget.View) } var tbl *resultTable err := resolveGroup(group.ProjectID, group.ID, widget.settings.authToken) if err == nil { tbl = newResultTable("Success", "Error Resolved") widget.Refresh() } else { tbl = newResultTable("Error", err.Error()) } modal := view.NewBillboardModal(tbl.render(), closeFunc) modal.SetTitle(fmt.Sprintf(" %s ", group.Title())) widget.pages.AddPage("resolve", modal, false, true) widget.app.SetFocus(modal) widget.app.QueueUpdateDraw(func() { widget.app.Draw() }) } } func (widget *Widget) muteGroup() { if widget.showType != SHOW_TITLE { return } sel := widget.GetSelected() if sel >= 0 && widget.groups != nil && sel < len(widget.groups) { group := widget.groups[sel] if !group.Muted { widget.err = muteGroup(group.ProjectID, group.ID, widget.settings.authToken) widget.Refresh() } } } func (widget *Widget) unmuteGroup() { if widget.showType != SHOW_TITLE { return } sel := widget.GetSelected() if sel >= 0 && widget.groups != nil && sel < len(widget.groups) { group := widget.groups[sel] if group.Muted { widget.err = unmuteGroup(group.ProjectID, group.ID, widget.settings.authToken) widget.Refresh() } } } func (widget *Widget) toggleDisplayText() { widget.showType = rotateShowType(widget.showType) widget.Render() } ================================================ FILE: modules/asana/client.go ================================================ package asana import ( "fmt" "strings" "time" asana "bitbucket.org/mikehouston/asana-go" ) func fetchTasksFromProject(token, projectId, mode string) ([]*TaskItem, error) { taskItems := []*TaskItem{} uidToName := make(map[string]string) client := asana.NewClientWithAccessToken(token) uid, err := getCurrentUserId(client, mode) if err != nil { return nil, err } q := &asana.TaskQuery{ Project: projectId, } fetchedTasks, _, err := getTasksFromAsana(client, q) if err != nil { return nil, fmt.Errorf("error fetching tasks: %s", err) } processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid) return taskItems, nil } func fetchTasksFromProjectSections(token, projectId string, sections []string, mode string) ([]*TaskItem, error) { taskItems := []*TaskItem{} uidToName := make(map[string]string) client := asana.NewClientWithAccessToken(token) uid, err := getCurrentUserId(client, mode) if err != nil { return nil, err } p := &asana.Project{ ID: projectId, } for _, section := range sections { sectionId, err := findSection(client, p, section) if err != nil { return nil, fmt.Errorf("error fetching tasks: %s", err) } q := &asana.TaskQuery{ Section: sectionId, } fetchedTasks, _, err := getTasksFromAsana(client, q) if err != nil { return nil, fmt.Errorf("error fetching tasks: %s", err) } if len(fetchedTasks) > 0 { taskItem := &TaskItem{ name: section, taskType: TASK_SECTION, } taskItems = append(taskItems, taskItem) } processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid) } return taskItems, nil } func fetchTasksFromWorkspace(token, workspaceId, mode string) ([]*TaskItem, error) { taskItems := []*TaskItem{} uidToName := make(map[string]string) client := asana.NewClientWithAccessToken(token) uid, err := getCurrentUserId(client, mode) if err != nil { return nil, err } q := &asana.TaskQuery{ Workspace: workspaceId, Assignee: "me", } fetchedTasks, _, err := getTasksFromAsana(client, q) if err != nil { return nil, fmt.Errorf("error fetching tasks: %s", err) } processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, workspaceId, uid) return taskItems, nil } func toggleTaskCompletionById(token, taskId string) error { client := asana.NewClientWithAccessToken(token) t := &asana.Task{ ID: taskId, } err := t.Fetch(client) if err != nil { return fmt.Errorf("error fetching task: %s", err) } updateReq := &asana.UpdateTaskRequest{} if *t.Completed { f := false updateReq.Completed = &f } else { t := true updateReq.Completed = &t } err = t.Update(client, updateReq) if err != nil { return fmt.Errorf("error updating task: %s", err) } return nil } func processFetchedTasks(client *asana.Client, fetchedTasks *[]*asana.Task, taskItems *[]*TaskItem, uidToName *map[string]string, mode, projectId, uid string) { for _, task := range *fetchedTasks { switch { case strings.HasSuffix(mode, "_all"): if task.Assignee != nil { // Check if we have already looked up this user if assigneeName, ok := (*uidToName)[task.Assignee.ID]; ok { task.Assignee.Name = assigneeName } else { // We haven't looked up this user before, perform the lookup now assigneeName, err := getOtherUserEmail(client, task.Assignee.ID) if err != nil { task.Assignee.Name = "Error" } (*uidToName)[task.Assignee.ID] = assigneeName task.Assignee.Name = assigneeName } } else { task.Assignee = &asana.User{ Name: "Unassigned", } } taskItem := buildTaskItem(task, projectId) (*taskItems) = append((*taskItems), taskItem) case !strings.HasSuffix(mode, "_all") && task.Assignee != nil && task.Assignee.ID == uid: taskItem := buildTaskItem(task, projectId) (*taskItems) = append((*taskItems), taskItem) } } } func buildTaskItem(task *asana.Task, projectId string) *TaskItem { dueOnString := "" if task.DueOn != nil { dueOn := time.Time(*task.DueOn) currentYear, _, _ := time.Now().Date() if currentYear != dueOn.Year() { dueOnString = dueOn.Format("Jan 2 2006") } else { dueOnString = dueOn.Format("Jan 2") } } assignString := "" if task.Assignee != nil { assignString = task.Assignee.Name } taskItem := &TaskItem{ name: task.Name, id: task.ID, numSubtasks: task.NumSubtasks, dueOn: dueOnString, url: fmt.Sprintf("https://app.asana.com/0/%s/%s/f", projectId, task.ID), taskType: TASK_TYPE, completed: *task.Completed, assignee: assignString, } return taskItem } func getOtherUserEmail(client *asana.Client, uid string) (string, error) { if uid == "" { return "", fmt.Errorf("missing uid") } u := &asana.User{ ID: uid, } err := u.Fetch(client, nil) if err != nil { return "", fmt.Errorf("error fetching user: %s", err) } return u.Email, nil } func getCurrentUserId(client *asana.Client, mode string) (string, error) { if strings.HasSuffix(mode, "_all") { return "", nil } u, err := client.CurrentUser() if err != nil { return "", fmt.Errorf("error getting current user: %s", err) } return u.ID, nil } func findSection(client *asana.Client, project *asana.Project, sectionName string) (string, error) { sectionId := "" sections, _, err := project.Sections(client, &asana.Options{ Limit: 100, }) if err != nil { return "", fmt.Errorf("error getting sections: %s", err) } for _, section := range sections { if section.Name == sectionName { sectionId = section.ID break } } if sectionId == "" { return "", fmt.Errorf("we didn't find the section %s", sectionName) } return sectionId, nil } func getTasksFromAsana(client *asana.Client, q *asana.TaskQuery) ([]*asana.Task, bool, error) { moreTasks := false tasks, np, err := client.QueryTasks(q, &asana.Options{ Limit: 100, Fields: []string{ "assignee", "name", "num_subtasks", "due_on", "completed", }, }) if err != nil { return nil, false, fmt.Errorf("error querying tasks: %s", err) } if np != nil { moreTasks = true } return tasks, moreTasks, nil } ================================================ FILE: modules/asana/keyboard.go ================================================ package asana import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next task") widget.SetKeyboardChar("k", widget.Prev, "Select previous task") widget.SetKeyboardChar("q", widget.Unselect, "Unselect task") widget.SetKeyboardChar("o", widget.openTask, "Open task in browser") widget.SetKeyboardChar("x", widget.toggleTaskCompletion, "Toggles the task's completion state") widget.SetKeyboardChar("?", widget.ShowHelp, "Shows help") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next task") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous task") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Unselect task") widget.SetKeyboardKey(tcell.KeyEnter, widget.openTask, "Open task in browser") } ================================================ FILE: modules/asana/settings.go ================================================ package asana import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "Asana" ) type Settings struct { *cfg.Common projectId string `help:"The Asana Project ID. If the mode is 'project' or 'project_sections' this is required to known which Asana Project to pull your tasks from" values:"A valid Asana Project ID string" optional:"true"` workspaceId string `help:"The Asana Workspace ID. If mode is 'workspace' this is required" values:"A valid Asana Workspace ID string" optional:"true"` sections []string `help:"The Asana Section Labels to fetch from the Project. Required if the mode is 'project_sections'" values:"An array of Asana Section Label strings" optional:"true"` allUsers bool `help:"Fetch tasks for all users, defaults to false" values:"bool" optional:"true"` mode string `help:"What mode to query Asana, 'project', 'project_sections', 'workspace'" values:"A string with either 'project', 'project_sections' or 'workspace'"` hideComplete bool `help:"Hide completed tasks, defaults to false" values:"bool" optional:"true"` apiKey string `help:"Your Asana Personal Access Token. Leave this blank to use the WTF_ASANA_TOKEN environment variable." values:"Your Asana Personal Access Token as a string" optional:"true"` token string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), projectId: ymlConfig.UString("projectId", ""), apiKey: ymlConfig.UString("apiKey", ""), workspaceId: ymlConfig.UString("workspaceId", ""), sections: utils.ToStrs(ymlConfig.UList("sections")), allUsers: ymlConfig.UBool("allUsers", false), mode: ymlConfig.UString("mode", ""), hideComplete: ymlConfig.UBool("hideComplete", false), } return &settings } ================================================ FILE: modules/asana/widget.go ================================================ package asana import ( "fmt" "os" "strings" "sync" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type TaskType int const ( TASK_TYPE TaskType = iota TASK_SECTION TASK_BREAK ) type TaskItem struct { name string numSubtasks int32 dueOn string id string url string taskType TaskType completed bool assignee string } type Widget struct { view.ScrollableWidget tasks []*TaskItem mu sync.Mutex err error settings *Settings tviewApp *tview.Application } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), tviewApp: tviewApp, settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.tasks = nil widget.err = nil widget.SetItemCount(0) widget.mu.Lock() defer widget.mu.Unlock() tasks, err := widget.Fetch( widget.settings.workspaceId, widget.settings.projectId, widget.settings.mode, widget.settings.sections, widget.settings.allUsers, ) if err != nil { widget.err = err } else { widget.tasks = tasks widget.SetItemCount(len(tasks)) } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) Fetch(workspaceId, projectId, mode string, sections []string, allUsers bool) ([]*TaskItem, error) { availableModes := map[string]interface{}{ "project": nil, "project_sections": nil, "workspace": nil, } if _, ok := availableModes[mode]; !ok { return nil, fmt.Errorf("missing mode, or mode is invalid - please set to project, project_sections or workspace") } if widget.settings.apiKey != "" { widget.settings.token = widget.settings.apiKey } else { widget.settings.token = os.Getenv("WTF_ASANA_TOKEN") } if widget.settings.token == "" { return nil, fmt.Errorf("missing environment variable token or apikey config") } subMode := mode if allUsers && mode != "workspace" { subMode += "_all" } if projectId == "" && strings.HasPrefix(subMode, "project") { return nil, fmt.Errorf("missing project id") } if workspaceId == "" && subMode == "workspace" { return nil, fmt.Errorf("missing workspace id") } var tasks []*TaskItem var err error //lint:ignore QF1002 An untagged switch makes sense here. switch { case strings.HasPrefix(subMode, "project_sections"): tasks, err = fetchTasksFromProjectSections(widget.settings.token, projectId, sections, subMode) case strings.HasPrefix(subMode, "project"): tasks, err = fetchTasksFromProject(widget.settings.token, projectId, subMode) case subMode == "workspace": tasks, err = fetchTasksFromWorkspace(widget.settings.token, workspaceId, subMode) default: err = fmt.Errorf("no mode found") } if err != nil { return nil, err } return tasks, nil } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } data := widget.tasks if len(data) == 0 { return title, "No data", false } var str string for idx, taskItem := range data { switch taskItem.taskType { case TASK_TYPE: if widget.settings.hideComplete && taskItem.completed { continue } rowColor := widget.RowColor(idx) completed := "[ []" if taskItem.completed { completed = "[x[]" } row := "" if widget.settings.allUsers && taskItem.assignee != "" { row = fmt.Sprintf( "[%s] %s %s: %s", rowColor, completed, taskItem.assignee, taskItem.name, ) } else { row = fmt.Sprintf( "[%s] %s %s", rowColor, completed, taskItem.name, ) } if taskItem.numSubtasks > 0 { row += fmt.Sprintf(" (%d)", taskItem.numSubtasks) } if taskItem.dueOn != "" { row += fmt.Sprintf(" due: %s", taskItem.dueOn) } row += " [white]" str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) case TASK_SECTION: if idx > 1 { row := "[white] " str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) } row := fmt.Sprintf( "[white] %s [white]", taskItem.name, ) str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) row = "[white] " str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) } } return title, str, false } func (widget *Widget) openTask() { sel := widget.GetSelected() if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) { task := widget.tasks[sel] if task.taskType == TASK_TYPE && task.url != "" { utils.OpenFile(task.url) } } } func (widget *Widget) toggleTaskCompletion() { sel := widget.GetSelected() if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) { task := widget.tasks[sel] if task.taskType == TASK_TYPE { widget.mu.Lock() err := toggleTaskCompletionById(widget.settings.token, task.id) if err != nil { widget.err = err } widget.mu.Unlock() widget.Refresh() } } } ================================================ FILE: modules/azuredevops/client.go ================================================ package azuredevops import ( "fmt" "strings" azrBuild "github.com/microsoft/azure-devops-go-api/azuredevops/build" "github.com/pkg/errors" ) func (widget *Widget) getBuildStats() string { projName := widget.settings.projectName statusFilter := azrBuild.BuildStatusValues.All top := widget.settings.maxRows builds, err := widget.cli.GetBuilds(widget.ctx, azrBuild.GetBuildsArgs{Project: &projName, StatusFilter: &statusFilter, Top: &top}) if err != nil { return errors.Wrap(err, "could not get builds").Error() } result := "" for _, build := range builds.Value { num := *build.BuildNumber branch := *build.SourceBranch reason := *build.Reason triggers := *build.TriggerInfo if reason == azrBuild.BuildReasonValues.PullRequest { branch = triggers["pr.sourceBranch"] } branch = strings.TrimPrefix(branch, "refs/heads/") status := *build.Status statusDisplay := "[white:grey]unknown" switch status { case azrBuild.BuildStatusValues.InProgress: statusDisplay = "[white:blue]in progress" case azrBuild.BuildStatusValues.Cancelling: statusDisplay = "[white:orange]in cancelling" case azrBuild.BuildStatusValues.Postponed, azrBuild.BuildStatusValues.NotStarted: statusDisplay = "[white:blue]waiting" case azrBuild.BuildStatusValues.Completed: buildResult := *build.Result switch buildResult { case azrBuild.BuildResultValues.Succeeded: statusDisplay = "[white:green]succeeded" case azrBuild.BuildResultValues.Failed: statusDisplay = "[white:red]failed" case azrBuild.BuildResultValues.Canceled: statusDisplay = "[white:darkgrey]cancelled" case azrBuild.BuildResultValues.PartiallySucceeded: statusDisplay = "[white:magenta]partially" } } result += fmt.Sprintf("%s[-:-:-] #%s %s (%s) \n", statusDisplay, num, branch, reason) } if result == "" { result = "no builds found" } return result } ================================================ FILE: modules/azuredevops/example-conf.yml ================================================ wtf: colors: # background: black # foreground: blue border: focusable: darkslateblue focused: orange normal: gray checked: yellow highlight: fore: black back: gray rows: even: yellow odd: white grid: # How _wide_ the columns are, in terminal characters. In this case we have # four columns, each of which are 35 characters wide. # columns: [50, ] # How _high_ the rows are, in terminal lines. In this case we have four rows # that support ten line of text and one of four. # rows: [50] refreshInterval: 1 openFileUtil: "open" mods: azuredevops: type: azuredevops title: "💻" enabled: true position: top: 0 left: 0 height: 3 width: 3 refreshInterval: 1 labelColor: lightblue # title label color (optional / default: white) apiToken: "mysecret api token" # api key (required) orgUrl: "https://dev.azure.com/myawesomecompany/" # url to your azure devops project (required) prjectName: "the awesome project" # name of your project (required) maxRows: 3 #max rows to show (optional / default 3) ================================================ FILE: modules/azuredevops/settings.go ================================================ package azuredevops import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocus = false defaultTitle = "azuredevops" ) // Settings defines the configuration options for this module type Settings struct { *cfg.Common apiToken string `help:"Your Azure DevOps Access Token."` labelColor string maxRows int orgURL string `help:"Your Azure DevOps organization URL."` projectName string } // NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocus, ymlConfig, globalConfig), apiToken: ymlConfig.UString("apiToken", os.Getenv("WTF_AZURE_DEVOPS_API_TOKEN")), labelColor: ymlConfig.UString("labelColor", "white"), maxRows: ymlConfig.UInt("maxRows", 3), orgURL: ymlConfig.UString("orgURL", os.Getenv("WTF_AZURE_DEVOPS_ORG_URL")), projectName: ymlConfig.UString("projectName", os.Getenv("WTF_AZURE_DEVOPS_PROJECT_NAME")), } cfg.ModuleSecret(name, globalConfig, &settings.apiToken). Service(settings.orgURL).Load() return &settings } ================================================ FILE: modules/azuredevops/widget.go ================================================ package azuredevops import ( "context" "fmt" azr "github.com/microsoft/azure-devops-go-api/azuredevops" azrBuild "github.com/microsoft/azure-devops-go-api/azuredevops/build" "github.com/pkg/errors" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget cli azrBuild.Client settings *Settings displayBuffer string ctx context.Context } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.View.SetScrollable(true) connection := azr.NewPatConnection(settings.orgURL, settings.apiToken) ctx := context.Background() cli, err := azrBuild.NewClient(ctx, connection) if err != nil { widget.displayBuffer = errors.Wrap(err, "could not create client 2").Error() } else { widget.cli = cli widget.ctx = ctx } widget.refreshDisplayBuffer() return &widget } func (widget *Widget) Refresh() { widget.refreshDisplayBuffer() widget.Redraw(widget.display) } func (widget *Widget) display() (string, string, bool) { return widget.CommonSettings().Title, widget.displayBuffer, true } func (widget *Widget) refreshDisplayBuffer() { if widget.cli == nil { return } widget.displayBuffer = "" widget.displayBuffer += fmt.Sprintf("[%s::bul] build status - %s\n", widget.settings.labelColor, widget.settings.projectName) widget.displayBuffer += widget.getBuildStats() } ================================================ FILE: modules/azurelogs/config.go ================================================ package azurelogs import ( _ "embed" "fmt" "os" "gopkg.in/yaml.v3" ) // QueryFile represents the structure of a query configuration file type QueryFile struct { Title string `yaml:"title"` // Display title for the query SubscriptionID string `yaml:"azure_subscription_id"` // Azure subscription ID WorkspaceID string `yaml:"azure_workspace_id"` // Log Analytics workspace ID Columns []string `yaml:"columns"` // Expected column names Query string `yaml:"query"` // KQL query string } // readQueryFile reads and parses a query configuration file func readQueryFile(sess *Session, queryPath string) error { file, err := os.OpenFile(queryPath, os.O_RDONLY, 0o600) if err != nil { return err } filename := file.Name() if len(filename) > 5 && filename[len(filename)-5:] == ".yaml" { var configFile QueryFile configFile, err = readQueryFileContent(queryPath) if err != nil { return err } sess.QueryFile = configFile } else { return fmt.Errorf("invalid query file format: %s, expected .yaml", filename) } return nil } // readQueryFileContent reads a single config file and returns a QueryFile struct func readQueryFileContent(filePath string) (QueryFile, error) { var configFile QueryFile data, err := os.ReadFile(filePath) if err != nil { return configFile, fmt.Errorf("failed to read query config file %s: %w", filePath, err) } err = yaml.Unmarshal(data, &configFile) if err != nil { return configFile, fmt.Errorf("failed to parse YAML in config file %s: %w", filePath, err) } return configFile, nil } ================================================ FILE: modules/azurelogs/config_test.go ================================================ package azurelogs import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestQueryFile_Structure(t *testing.T) { // Test QueryFile structure and YAML tags qf := QueryFile{ Title: "Test Azure Query", SubscriptionID: "subscription-123", WorkspaceID: "workspace-456", Columns: []string{"TimeGenerated", "Level", "Message"}, Query: "AzureActivity | where Level == 'Error' | limit 100", } assert.Equal(t, "Test Azure Query", qf.Title) assert.Equal(t, "subscription-123", qf.SubscriptionID) assert.Equal(t, "workspace-456", qf.WorkspaceID) assert.Len(t, qf.Columns, 3) assert.Equal(t, "TimeGenerated", qf.Columns[0]) assert.Equal(t, "Level", qf.Columns[1]) assert.Equal(t, "Message", qf.Columns[2]) assert.Contains(t, qf.Query, "AzureActivity") } func TestReadQueryFileContent_ValidYAML(t *testing.T) { // Create a temporary YAML file for testing yamlContent := `title: "Test Query" azure_subscription_id: "test-sub-123" azure_workspace_id: "test-workspace-456" columns: - "TimeGenerated" - "Level" - "Message" query: "AzureActivity | limit 10"` tmpFile, err := os.CreateTemp("", "test-query-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(yamlContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) // Test reading the file queryFile, err := readQueryFileContent(tmpFile.Name()) assert.NoError(t, err) assert.Equal(t, "Test Query", queryFile.Title) assert.Equal(t, "test-sub-123", queryFile.SubscriptionID) assert.Equal(t, "test-workspace-456", queryFile.WorkspaceID) assert.Len(t, queryFile.Columns, 3) assert.Equal(t, "TimeGenerated", queryFile.Columns[0]) assert.Equal(t, "Level", queryFile.Columns[1]) assert.Equal(t, "Message", queryFile.Columns[2]) assert.Equal(t, "AzureActivity | limit 10", queryFile.Query) } func TestReadQueryFileContent_InvalidYAML(t *testing.T) { // Create a temporary file with invalid YAML invalidYamlContent := `title: "Test Query" azure_subscription_id: "test-sub-123" invalid_yaml: [unclosed bracket` tmpFile, err := os.CreateTemp("", "test-invalid-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(invalidYamlContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) // Test reading the invalid file _, err = readQueryFileContent(tmpFile.Name()) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse YAML") } func TestReadQueryFileContent_NonexistentFile(t *testing.T) { // Test reading a file that doesn't exist _, err := readQueryFileContent("/nonexistent/file.yaml") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read query config file") } func TestReadQueryFile_ValidYAMLFile(t *testing.T) { // Create a temporary YAML file for testing yamlContent := `title: "Integration Test Query" azure_subscription_id: "integration-sub-123" azure_workspace_id: "integration-workspace-456" columns: - "Computer" - "TimeGenerated" - "SourceSystem" query: "Heartbeat | limit 5"` tmpFile, err := os.CreateTemp("", "test-integration-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(yamlContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) // Test reading into session sess := &Session{} err = readQueryFile(sess, tmpFile.Name()) assert.NoError(t, err) assert.Equal(t, "Integration Test Query", sess.QueryFile.Title) assert.Equal(t, "integration-sub-123", sess.QueryFile.SubscriptionID) assert.Equal(t, "integration-workspace-456", sess.QueryFile.WorkspaceID) assert.Len(t, sess.QueryFile.Columns, 3) assert.Equal(t, "Computer", sess.QueryFile.Columns[0]) assert.Equal(t, "Heartbeat | limit 5", sess.QueryFile.Query) } func TestReadQueryFile_NonYAMLFile(t *testing.T) { // Create a temporary file with non-YAML extension tmpFile, err := os.CreateTemp("", "test-non-yaml-*.txt") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString("some content") require.NoError(t, err) require.NoError(t, tmpFile.Close()) // Test reading non-YAML file sess := &Session{} err = readQueryFile(sess, tmpFile.Name()) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid query file format") assert.Contains(t, err.Error(), "expected .yaml") } func TestReadQueryFile_EmptyYAMLFile(t *testing.T) { // Create an empty YAML file tmpFile, err := os.CreateTemp("", "test-empty-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) require.NoError(t, tmpFile.Close()) // Test reading empty file sess := &Session{} err = readQueryFile(sess, tmpFile.Name()) // Should succeed but with empty values assert.NoError(t, err) assert.Equal(t, "", sess.QueryFile.Title) assert.Equal(t, "", sess.QueryFile.SubscriptionID) assert.Equal(t, "", sess.QueryFile.WorkspaceID) assert.Empty(t, sess.QueryFile.Columns) assert.Equal(t, "", sess.QueryFile.Query) } func TestReadQueryFile_PartialYAMLFile(t *testing.T) { // Create a YAML file with only some fields yamlContent := `title: "Partial Query" azure_subscription_id: "partial-sub-123" # Missing workspace_id, columns, and query` tmpFile, err := os.CreateTemp("", "test-partial-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(yamlContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) // Test reading partial file sess := &Session{} err = readQueryFile(sess, tmpFile.Name()) assert.NoError(t, err) assert.Equal(t, "Partial Query", sess.QueryFile.Title) assert.Equal(t, "partial-sub-123", sess.QueryFile.SubscriptionID) assert.Equal(t, "", sess.QueryFile.WorkspaceID) // Should be empty assert.Empty(t, sess.QueryFile.Columns) // Should be empty assert.Equal(t, "", sess.QueryFile.Query) // Should be empty } func TestQueryFile_YAMLTags(t *testing.T) { // This is a structural test to ensure YAML tags are properly defined // We test this by creating a QueryFile and checking field mapping yamlContent := `title: "YAML Tag Test" azure_subscription_id: "yaml-sub-123" azure_workspace_id: "yaml-workspace-456" columns: - "TestColumn1" - "TestColumn2" query: "TestQuery | limit 1"` tmpFile, err := os.CreateTemp("", "test-yaml-tags-*.yaml") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString(yamlContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) queryFile, err := readQueryFileContent(tmpFile.Name()) assert.NoError(t, err) // Verify that YAML tags correctly map to struct fields assert.Equal(t, "YAML Tag Test", queryFile.Title) // yaml:"title" assert.Equal(t, "yaml-sub-123", queryFile.SubscriptionID) // yaml:"azure_subscription_id" assert.Equal(t, "yaml-workspace-456", queryFile.WorkspaceID) // yaml:"azure_workspace_id" assert.Equal(t, []string{"TestColumn1", "TestColumn2"}, queryFile.Columns) // yaml:"columns" assert.Equal(t, "TestQuery | limit 1", queryFile.Query) // yaml:"query" } ================================================ FILE: modules/azurelogs/query.go ================================================ package azurelogs import ( "context" "fmt" "sync" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery" ) // LogQueryClients holds the Azure Logs clients for different subscriptions // This is a global variable to avoid creating a new client for each query var LogQueryClients map[string]*azquery.LogsClient // clientsMutex protects concurrent access to LogQueryClients var clientsMutex sync.RWMutex // TableRow represents a single row of data from Azure Log Analytics type TableRow []string // TableResp represents the response from an Azure Log Analytics query type TableResp struct { Header []string // Column headers Rows []TableRow // Data rows } // RunQuery executes an Azure Log Analytics query and returns the formatted results func RunQuery(sess *Session) (*TableResp, error) { qf := sess.QueryFile var err error var tableResp TableResp tableResp.Header = qf.Columns if qf.WorkspaceID == "" { return nil, fmt.Errorf("azure workspace ID is required but not configured") } if qf.SubscriptionID == "" { return nil, fmt.Errorf("azure subscription ID is required but not configured") } // Use read lock first to check if client exists clientsMutex.RLock() client := LogQueryClients[qf.SubscriptionID] clientsMapExists := LogQueryClients != nil clientsMutex.RUnlock() // If map doesn't exist or client doesn't exist, we need write access if !clientsMapExists || client == nil { clientsMutex.Lock() // Double-check after acquiring write lock (double-checked locking pattern) if LogQueryClients == nil { LogQueryClients = make(map[string]*azquery.LogsClient) } if LogQueryClients[qf.SubscriptionID] == nil { LogQueryClients[qf.SubscriptionID], err = CreateLogsClient(sess, qf.SubscriptionID) if err != nil { clientsMutex.Unlock() return nil, fmt.Errorf("failed to create Azure Logs client for subscription %s: %w", qf.SubscriptionID, err) } } client = LogQueryClients[qf.SubscriptionID] clientsMutex.Unlock() } res, err := client.QueryWorkspace( context.Background(), qf.WorkspaceID, azquery.Body{ Query: to.Ptr(qf.Query), }, nil) if err != nil { return nil, fmt.Errorf("failed to execute query on workspace %s: %w", qf.WorkspaceID, err) } if res.Error != nil { return nil, res.Error } switch len(res.Tables) { case 0: return nil, fmt.Errorf("query returned no data tables: %s", qf.Query) case 1: if len(res.Tables[0].Columns) == 0 { return nil, fmt.Errorf("query returned table with no columns: %s", qf.Query) } default: return nil, fmt.Errorf("query returned %d tables, expected 1: %s", len(res.Tables), qf.Query) } // Process each row of data for _, row := range res.Tables[0].Rows { var r TableRow for _, field := range row { if field == nil { r = append(r, "") continue } // Convert all data types to string representation switch v := field.(type) { case string: r = append(r, v) case float64: r = append(r, fmt.Sprintf("%.0f", v)) default: r = append(r, fmt.Sprintf("%v", v)) } } tableResp.Rows = append(tableResp.Rows, r) } return &tableResp, nil } ================================================ FILE: modules/azurelogs/query_concurrent_test.go ================================================ package azurelogs import ( "fmt" "sync" "testing" "github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery" "github.com/stretchr/testify/assert" ) func TestLogQueryClients_ConcurrentAccess(t *testing.T) { // Save original state originalClients := LogQueryClients defer func() { LogQueryClients = originalClients }() // Reset to nil to test initialization LogQueryClients = nil const numGoroutines = 10 const subscriptionID = "test-subscription" var wg sync.WaitGroup results := make([]bool, numGoroutines) // Launch multiple goroutines that try to access LogQueryClients simultaneously for i := 0; i < numGoroutines; i++ { wg.Add(1) go func(index int) { defer wg.Done() // Use read lock to check if client exists clientsMutex.RLock() client := LogQueryClients[subscriptionID] clientsMapExists := LogQueryClients != nil clientsMutex.RUnlock() // Record if we found the map initialized results[index] = clientsMapExists // If map doesn't exist, try to initialize it if !clientsMapExists || client == nil { clientsMutex.Lock() // Double-check after acquiring write lock if LogQueryClients == nil { LogQueryClients = make(map[string]*azquery.LogsClient) } clientsMutex.Unlock() } }(i) } wg.Wait() // Verify that LogQueryClients was properly initialized assert.NotNil(t, LogQueryClients) assert.IsType(t, map[string]*azquery.LogsClient{}, LogQueryClients) // At least one goroutine should have seen the map as not existing initially anyFoundNil := false for _, result := range results { if !result { anyFoundNil = true break } } assert.True(t, anyFoundNil, "Expected at least one goroutine to see LogQueryClients as nil initially") } func TestLogQueryClients_ConcurrentReadWrite(t *testing.T) { // Save original state originalClients := LogQueryClients defer func() { LogQueryClients = originalClients }() // Initialize with a clean map LogQueryClients = make(map[string]*azquery.LogsClient) const numReaders = 5 const numWriters = 3 const subscriptionPrefix = "subscription-" var wg sync.WaitGroup // Launch reader goroutines for i := 0; i < numReaders; i++ { wg.Add(1) go func(readerID int) { defer wg.Done() for j := 0; j < 10; j++ { // Read from the map safely clientsMutex.RLock() _ = LogQueryClients["test-subscription"] mapSize := len(LogQueryClients) clientsMutex.RUnlock() // Verify map size is reasonable (between 0 and numWriters) assert.GreaterOrEqual(t, mapSize, 0) assert.LessOrEqual(t, mapSize, numWriters) } }(i) } // Launch writer goroutines for i := 0; i < numWriters; i++ { wg.Add(1) go func(writerID int) { defer wg.Done() subscriptionID := subscriptionPrefix + string(rune('A'+writerID)) // Write to the map safely clientsMutex.Lock() LogQueryClients[subscriptionID] = nil // Simulate adding a client entry clientsMutex.Unlock() }(i) } wg.Wait() // Verify final state clientsMutex.RLock() finalSize := len(LogQueryClients) clientsMutex.RUnlock() assert.Equal(t, numWriters, finalSize, "Expected exactly %d entries after concurrent writes", numWriters) } func TestLogQueryClients_RaceCondition(t *testing.T) { // This test verifies that concurrent access to LogQueryClients is thread-safe // Save original state originalClients := LogQueryClients defer func() { LogQueryClients = originalClients }() const numGoroutines = 100 const numIterations = 10 for attempt := 0; attempt < 5; attempt++ { // Run multiple attempts to catch race conditions // Reset to trigger concurrent initialization LogQueryClients = nil var wg sync.WaitGroup // Launch goroutines that all try to access/initialize LogQueryClients for i := 0; i < numGoroutines; i++ { wg.Add(1) go func(goroutineID int) { defer wg.Done() subscriptionID := fmt.Sprintf("subscription-%d", goroutineID%3) // Use 3 different subscriptions for j := 0; j < numIterations; j++ { // Exactly match the logic from RunQuery function clientsMutex.RLock() client := LogQueryClients[subscriptionID] clientsMapExists := LogQueryClients != nil clientsMutex.RUnlock() if !clientsMapExists || client == nil { clientsMutex.Lock() // Double-check after acquiring write lock if LogQueryClients == nil { LogQueryClients = make(map[string]*azquery.LogsClient) } // Double-check for this specific subscription if LogQueryClients[subscriptionID] == nil { LogQueryClients[subscriptionID] = nil // Simulate client creation } clientsMutex.Unlock() } } }(i) } wg.Wait() // Verify final state is consistent - no race conditions occurred clientsMutex.RLock() assert.NotNil(t, LogQueryClients, "Attempt %d: LogQueryClients should be initialized", attempt+1) // Should have exactly 3 subscriptions (based on goroutineID%3) assert.Equal(t, 3, len(LogQueryClients), "Attempt %d: Expected 3 subscription entries", attempt+1) clientsMutex.RUnlock() } } ================================================ FILE: modules/azurelogs/query_test.go ================================================ package azurelogs import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery" "github.com/stretchr/testify/assert" ) // createMockSession creates a mock session for testing func createMockSession() *Session { return &Session{ QueryFile: QueryFile{ WorkspaceID: "test-workspace-id", SubscriptionID: "test-subscription-id", Query: "test-query", Columns: []string{"Column1", "Column2", "Column3"}, }, } } // Tests for input validation that don't require Azure SDK mocking func TestRunQuery_MissingWorkspaceID(t *testing.T) { sess := createMockSession() sess.QueryFile.WorkspaceID = "" // Since we can't mock the Azure client easily, we expect this to fail // during client creation or earlier validation result, err := RunQuery(sess) assert.Nil(t, result) assert.Error(t, err) assert.Contains(t, err.Error(), "azure workspace ID is required") } func TestRunQuery_MissingSubscriptionID(t *testing.T) { sess := createMockSession() sess.QueryFile.SubscriptionID = "" result, err := RunQuery(sess) assert.Nil(t, result) assert.Error(t, err) assert.Contains(t, err.Error(), "azure subscription ID is required") } func TestTableResp_Structure(t *testing.T) { // Test TableResp structure creation and manipulation tableResp := &TableResp{ Header: []string{"Col1", "Col2", "Col3"}, Rows: []TableRow{ {"Value1", "Value2", "Value3"}, {"Value4", "Value5", "Value6"}, }, } assert.NotNil(t, tableResp) assert.Len(t, tableResp.Header, 3) assert.Len(t, tableResp.Rows, 2) assert.Equal(t, "Col1", tableResp.Header[0]) assert.Equal(t, "Value1", tableResp.Rows[0][0]) } func TestTableRow_Operations(t *testing.T) { // Test TableRow operations row := TableRow{"data1", "data2", "data3"} assert.Len(t, row, 3) assert.Equal(t, "data1", row[0]) assert.Equal(t, "data2", row[1]) assert.Equal(t, "data3", row[2]) // Test appending to row row = append(row, "data4") assert.Len(t, row, 4) assert.Equal(t, "data4", row[3]) } func TestLogQueryClients_GlobalVariable(t *testing.T) { // Test the global LogQueryClients variable behavior originalClients := LogQueryClients defer func() { LogQueryClients = originalClients }() // Test initialization LogQueryClients = nil assert.Nil(t, LogQueryClients) // Test map creation LogQueryClients = make(map[string]*azquery.LogsClient) assert.NotNil(t, LogQueryClients) assert.Len(t, LogQueryClients, 0) // Test that the map exists and can be used assert.IsType(t, map[string]*azquery.LogsClient{}, LogQueryClients) } ================================================ FILE: modules/azurelogs/session.go ================================================ package azurelogs import ( "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery" "os" ) const ( envAzureClientID = "AZURE_CLIENT_ID" envAzureClientSecret = "AZURE_CLIENT_SECRET" envAzureTenantID = "AZURE_TENANT_ID" ) // Init initializes a new Azure session with the specified query file func Init(queryPath *string) (*Session, error) { sess := &Session{} sess.Azure = &AZSession{} // Initialize Azure authentication using modern non-deprecated libraries if err := InitializeAzureAuthentication(sess); err != nil { return nil, fmt.Errorf("failed to initialize Azure authentication: %w", err) } err := readQueryFile(sess, *queryPath) if err != nil { return nil, fmt.Errorf("failed to read query file %s: %w", *queryPath, err) } return sess, nil } // Session holds the configuration and state for an Azure Log Analytics session type Session struct { App struct { SemVer string } Azure *AZSession QueriesPath string QueryFile QueryFile } // AZClientSecretCredential holds Azure service principal credentials type AZClientSecretCredential struct { ClientID string ClientSecret string TenantID string } // AZSession holds Azure authentication and client information type AZSession struct { Credential azcore.TokenCredential ClientSecretCredential AZClientSecretCredential } // InitializeAzureAuthentication sets up Azure authentication using modern SDK func InitializeAzureAuthentication(sess *Session) error { var err error sess.Azure.ClientSecretCredential.ClientID = os.Getenv(envAzureClientID) sess.Azure.ClientSecretCredential.ClientSecret = os.Getenv(envAzureClientSecret) sess.Azure.ClientSecretCredential.TenantID = os.Getenv(envAzureTenantID) // Prefer client secret credential if all required environment variables are set if sess.Azure.ClientSecretCredential.ClientID != "" && sess.Azure.ClientSecretCredential.ClientSecret != "" && sess.Azure.ClientSecretCredential.TenantID != "" { sess.Azure.Credential, err = azidentity.NewClientSecretCredential( sess.Azure.ClientSecretCredential.TenantID, sess.Azure.ClientSecretCredential.ClientID, sess.Azure.ClientSecretCredential.ClientSecret, &azidentity.ClientSecretCredentialOptions{}) if err != nil { return err } return nil } sess.Azure.Credential, err = azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{}) if err != nil { return err } return nil } // CreateLogsClient creates a cached Azure Log Analytics client for the specified subscription func CreateLogsClient(sess *Session, subscriptionID string) (*azquery.LogsClient, error) { if sess.Azure.Credential == nil { return nil, fmt.Errorf("azure credentials not initialized for subscription %s: please set up authentication first", subscriptionID) } // Create a new client for this subscription ID using modern Azure SDK client, err := azquery.NewLogsClient(sess.Azure.Credential, nil) if err != nil { return nil, fmt.Errorf("failed to create Azure Logs client for subscription %s: %w", subscriptionID, err) } return client, nil } ================================================ FILE: modules/azurelogs/session_test.go ================================================ package azurelogs import ( "os" "testing" "github.com/stretchr/testify/assert" ) func TestAZClientSecretCredential_Structure(t *testing.T) { // Test AZClientSecretCredential structure cred := AZClientSecretCredential{ ClientID: "test-client-id", ClientSecret: "test-client-secret", TenantID: "test-tenant-id", } assert.Equal(t, "test-client-id", cred.ClientID) assert.Equal(t, "test-client-secret", cred.ClientSecret) assert.Equal(t, "test-tenant-id", cred.TenantID) } func TestAZSession_Structure(t *testing.T) { // Test AZSession structure azSession := &AZSession{ ClientSecretCredential: AZClientSecretCredential{ ClientID: "client-123", ClientSecret: "secret-456", TenantID: "tenant-789", }, } assert.Equal(t, "client-123", azSession.ClientSecretCredential.ClientID) assert.Equal(t, "secret-456", azSession.ClientSecretCredential.ClientSecret) assert.Equal(t, "tenant-789", azSession.ClientSecretCredential.TenantID) } func TestSession_Structure(t *testing.T) { // Test Session structure sess := &Session{ QueriesPath: "/path/to/queries", QueryFile: QueryFile{ Title: "Test Query", SubscriptionID: "sub-123", WorkspaceID: "workspace-456", Columns: []string{"Col1", "Col2"}, Query: "TestQuery | limit 10", }, } assert.Equal(t, "/path/to/queries", sess.QueriesPath) assert.Equal(t, "Test Query", sess.QueryFile.Title) assert.Equal(t, "sub-123", sess.QueryFile.SubscriptionID) assert.Equal(t, "workspace-456", sess.QueryFile.WorkspaceID) assert.Len(t, sess.QueryFile.Columns, 2) assert.Equal(t, "TestQuery | limit 10", sess.QueryFile.Query) } func TestInitializeAzureAuthentication_EnvironmentVariables(t *testing.T) { // Save original environment variables originalClientID := os.Getenv(envAzureClientID) originalClientSecret := os.Getenv(envAzureClientSecret) originalTenantID := os.Getenv(envAzureTenantID) // Clean up after test defer func() { _ = os.Setenv(envAzureClientID, originalClientID) _ = os.Setenv(envAzureClientSecret, originalClientSecret) _ = os.Setenv(envAzureTenantID, originalTenantID) }() // Test with all environment variables set t.Run("with all env vars set", func(t *testing.T) { _ = os.Setenv(envAzureClientID, "test-client-id") _ = os.Setenv(envAzureClientSecret, "test-client-secret") _ = os.Setenv(envAzureTenantID, "test-tenant-id") sess := &Session{Azure: &AZSession{}} err := InitializeAzureAuthentication(sess) // We expect this to succeed in setting up the credential structure // even if the actual Azure authentication fails assert.NoError(t, err) assert.Equal(t, "test-client-id", sess.Azure.ClientSecretCredential.ClientID) assert.Equal(t, "test-client-secret", sess.Azure.ClientSecretCredential.ClientSecret) assert.Equal(t, "test-tenant-id", sess.Azure.ClientSecretCredential.TenantID) assert.NotNil(t, sess.Azure.Credential) }) // Test with missing environment variables (should fall back to default credential) t.Run("with missing env vars", func(t *testing.T) { _ = os.Unsetenv(envAzureClientID) _ = os.Unsetenv(envAzureClientSecret) _ = os.Unsetenv(envAzureTenantID) sess := &Session{Azure: &AZSession{}} err := InitializeAzureAuthentication(sess) // Should fall back to DefaultAzureCredential // This may fail in test environment, but we're testing the fallback logic if err != nil { // In test environment, DefaultAzureCredential might fail // This is expected behavior assert.Contains(t, err.Error(), "DefaultAzureCredential") } else { assert.NotNil(t, sess.Azure.Credential) } }) // Test with partial environment variables (should fall back to default) t.Run("with partial env vars", func(t *testing.T) { _ = os.Setenv(envAzureClientID, "test-client-id") _ = os.Unsetenv(envAzureClientSecret) _ = os.Unsetenv(envAzureTenantID) sess := &Session{Azure: &AZSession{}} err := InitializeAzureAuthentication(sess) // Should fall back to DefaultAzureCredential since not all vars are set if err != nil { assert.Contains(t, err.Error(), "DefaultAzureCredential") } else { assert.NotNil(t, sess.Azure.Credential) } }) } func TestCreateLogsClient_NilCredentials(t *testing.T) { // Test CreateLogsClient with nil credentials sess := &Session{ Azure: &AZSession{ Credential: nil, }, } client, err := CreateLogsClient(sess, "test-subscription") assert.Nil(t, client) assert.Error(t, err) assert.Contains(t, err.Error(), "azure credentials not initialized") assert.Contains(t, err.Error(), "test-subscription") } func TestInit_InvalidQueryPath(t *testing.T) { // Test Init with invalid query path invalidPath := "/nonexistent/path/to/query.yml" sess, err := Init(&invalidPath) assert.Nil(t, sess) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read query file") } func TestInit_NilQueryPath(t *testing.T) { // Test Init with nil query path (should panic or handle gracefully) defer func() { if r := recover(); r != nil { // Expected behavior - accessing *nil should panic // This is the expected behavior, so the test passes t.Log("Init correctly panicked when given nil query path") } }() sess, err := Init(nil) // If we get here, the function handled nil gracefully assert.Nil(t, sess) assert.Error(t, err) } func TestEnvironmentConstants(t *testing.T) { // Test that environment variable constants are correctly defined assert.Equal(t, "AZURE_CLIENT_ID", envAzureClientID) assert.Equal(t, "AZURE_CLIENT_SECRET", envAzureClientSecret) assert.Equal(t, "AZURE_TENANT_ID", envAzureTenantID) } ================================================ FILE: modules/azurelogs/settings.go ================================================ package azurelogs import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Azure Logs" ) // Settings defines the configuration for the Azure Logs widget type Settings struct { *cfg.Common // Queryfile is the path to the YAML file containing the Azure query configuration Queryfile string `help:"Path to YAML file containing Azure Log Analytics query configuration"` } // NewSettingsFromYAML creates a new Settings instance from YAML configuration func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), Queryfile: ymlConfig.UString("queryFile", ""), } return &settings } ================================================ FILE: modules/azurelogs/settings_test.go ================================================ package azurelogs import ( "testing" "github.com/olebedev/config" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" ) func TestSettings_Structure(t *testing.T) { // Test Settings structure settings := &Settings{ Common: &cfg.Common{ Title: "Test Azure Logs", }, Queryfile: "/path/to/query.yml", } assert.NotNil(t, settings.Common) assert.Equal(t, "Test Azure Logs", settings.Title) assert.Equal(t, "/path/to/query.yml", settings.Queryfile) } func TestNewSettingsFromYAML(t *testing.T) { tests := []struct { name string configData map[string]interface{} expectedTitle string expectedQuery string }{ { name: "with custom query file", configData: map[string]interface{}{ "queryFile": "/custom/path/query.yml", "title": "Custom Azure Logs", }, expectedTitle: "Custom Azure Logs", expectedQuery: "/custom/path/query.yml", }, { name: "with default values", configData: map[string]interface{}{ // No queryFile specified, should use default empty string }, expectedTitle: defaultTitle, // Should use default title expectedQuery: "", // Should use default empty string }, { name: "with empty query file", configData: map[string]interface{}{ "queryFile": "", "title": "Empty Query Azure Logs", }, expectedTitle: "Empty Query Azure Logs", expectedQuery: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create YAML config from test data ymlConfig, err := config.ParseYaml(yamlFromMap(tt.configData)) assert.NoError(t, err) // Create global config (can be minimal for this test) globalConfig, err := config.ParseYaml("global: {}") assert.NoError(t, err) settings := NewSettingsFromYAML("test-widget", ymlConfig, globalConfig) assert.NotNil(t, settings) assert.NotNil(t, settings.Common) assert.Equal(t, tt.expectedTitle, settings.Title) assert.Equal(t, tt.expectedQuery, settings.Queryfile) }) } } func TestDefaultConstants(t *testing.T) { // Test that default constants are correctly defined assert.True(t, defaultFocusable) assert.Equal(t, "Azure Logs", defaultTitle) } func TestSettings_QueryfileField(t *testing.T) { // Test that Queryfile field can be set and retrieved settings := &Settings{} // Test setting various query file paths testPaths := []string{ "/absolute/path/query.yml", "relative/path/query.yml", "./current/dir/query.yml", "../parent/dir/query.yml", "", } for _, path := range testPaths { settings.Queryfile = path assert.Equal(t, path, settings.Queryfile) } } func TestNewSettingsFromYAML_Integration(t *testing.T) { // Test with a more complete YAML configuration configData := map[string]interface{}{ "queryFile": "/etc/wtf/azure-query.yml", "title": "Production Azure Logs", "enabled": true, "position": map[string]interface{}{ "top": 0, "left": 0, "width": 2, "height": 1, }, "refreshInterval": "5m", } ymlConfig, err := config.ParseYaml(yamlFromMap(configData)) assert.NoError(t, err) globalConfig, err := config.ParseYaml(` wtf: term: "xterm-256color" grid: columns: [40, 40, 40] rows: [13, 13, 4] `) assert.NoError(t, err) settings := NewSettingsFromYAML("azure-logs", ymlConfig, globalConfig) assert.NotNil(t, settings) assert.Equal(t, "Production Azure Logs", settings.Title) assert.Equal(t, "/etc/wtf/azure-query.yml", settings.Queryfile) assert.NotNil(t, settings.Common) } // Helper function to convert map to YAML string for testing func yamlFromMap(data map[string]interface{}) string { if len(data) == 0 { return "{}" } yaml := "" for key, value := range data { switch v := value.(type) { case string: yaml += key + ": \"" + v + "\"\n" case bool: if v { yaml += key + ": true\n" } else { yaml += key + ": false\n" } case map[string]interface{}: yaml += key + ":\n" for subKey, subValue := range v { yaml += " " + subKey + ": " + interfaceToString(subValue) + "\n" } default: yaml += key + ": " + interfaceToString(v) + "\n" } } return yaml } // Helper function to convert interface{} to string for YAML func interfaceToString(v interface{}) string { switch val := v.(type) { case string: return "\"" + val + "\"" case int: return string(rune(val + '0')) case bool: if val { return "true" } return "false" default: return "null" } } ================================================ FILE: modules/azurelogs/widget.go ================================================ package azurelogs import ( "fmt" "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) const ( defaultTableWidth = 120 minColumnWidth = 8 maxColumnWidth = 30 maxDisplayRows = 50 truncateMarker = "..." sampleRowsForWidth = 15 ) type Widget struct { view.TextWidget settings *Settings loading bool lastError error dataLoaded bool tableData *TableResp } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } widget.settings.RefreshInterval = 60 * time.Second return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } // Reset state to allow fresh data fetch widget.loading = false widget.lastError = nil widget.dataLoaded = false widget.tableData = nil widget.Redraw(widget.content) } /* -------------------- Helper Functions -------------------- */ func (widget *Widget) fetchDataAsync() { sess, err := Init(to.Ptr(widget.settings.Queryfile)) if err != nil { widget.setError(fmt.Errorf("failed to initialize Azure session: %w", err)) return } // Execute Azure query directly tableResp, err := RunQuery(sess) if err != nil { widget.setError(fmt.Errorf("failed to execute Azure query: %w", err)) return } // Check if we have valid data structure if tableResp == nil || len(tableResp.Header) == 0 { widget.setError(fmt.Errorf("no table structure returned from query")) return } // Store the data and mark as loaded widget.tableData = tableResp widget.dataLoaded = true widget.loading = false widget.Redraw(widget.content) } // setError is a helper function to set error state and trigger redraw func (widget *Widget) setError(err error) { widget.lastError = err widget.loading = false widget.Redraw(widget.content) } func (widget *Widget) renderTable(title string) (string, string, bool) { if widget.tableData == nil { return title, "[red]Error: No table data available[white]", true } // Calculate column widths and format table - headers are always shown when available colWidths := calculateAdaptiveColumnWidths(widget.tableData, defaultTableWidth) var sb strings.Builder // Always show headers when we have table structure widget.formatTableHeaders(&sb, widget.tableData.Header, colWidths) widget.formatTableSeparator(&sb, widget.tableData.Header, colWidths) // Show data rows if available, otherwise show informative message if len(widget.tableData.Rows) == 0 { sb.WriteString("[dim](No data rows returned)[white]\n") } else { widget.formatTableRows(&sb, widget.tableData.Rows, widget.tableData.Header, colWidths) } return title, sb.String(), false } // formatTableHeaders writes the table header row to the string builder func (widget *Widget) formatTableHeaders(sb *strings.Builder, headers []string, colWidths []int) { for i, header := range headers { if i > 0 { sb.WriteString(" ¦") } headerText := header if i < len(colWidths) && len(headerText) > colWidths[i] { headerText = headerText[:colWidths[i]-len(truncateMarker)] + truncateMarker } _, _ = fmt.Fprintf(sb, "[lightblue]%-*s[white]", colWidths[i], headerText) } sb.WriteString("\n") } // formatTableSeparator writes the table separator row to the string builder func (widget *Widget) formatTableSeparator(sb *strings.Builder, headers []string, colWidths []int) { for i := range headers { if i > 0 { sb.WriteString("---") } sb.WriteString(strings.Repeat("-", colWidths[i])) } sb.WriteString("\n") } // formatTableRows writes the table data rows to the string builder func (widget *Widget) formatTableRows(sb *strings.Builder, rows []TableRow, headers []string, colWidths []int) { maxRows := maxDisplayRows rowCount := len(rows) if rowCount > maxRows { rowCount = maxRows } for rowIdx := 0; rowIdx < rowCount; rowIdx++ { row := rows[rowIdx] for colIdx, cell := range row { if colIdx >= len(headers) { break } if colIdx > 0 { sb.WriteString(" ¦") } cellText := strings.TrimSpace(cell) if colIdx < len(colWidths) && len(cellText) > colWidths[colIdx] { cellText = cellText[:colWidths[colIdx]-len(truncateMarker)] + truncateMarker } _, _ = fmt.Fprintf(sb, "%-*s", colWidths[colIdx], cellText) } sb.WriteString("\n") } if len(rows) > maxRows { _, _ = fmt.Fprintf(sb, "\n[gray]... (%d more rows truncated for display)[white]\n", len(rows)-maxRows) } } // calculateAdaptiveColumnWidths computes optimal column widths based on content and available space func calculateAdaptiveColumnWidths(tr *TableResp, availableWidth int) []int { if len(tr.Header) == 0 { return []int{} } // Calculate content-based widths widths := make([]int, len(tr.Header)) // Start with header widths for i, header := range tr.Header { widths[i] = len(header) } // Check data rows to find maximum content width per column (if any rows exist) if len(tr.Rows) > 0 { maxRows := sampleRowsForWidth // Sample first N rows for width calculation rowCount := len(tr.Rows) if rowCount > maxRows { rowCount = maxRows } for rowIdx := 0; rowIdx < rowCount; rowIdx++ { row := tr.Rows[rowIdx] for colIdx, cell := range row { if colIdx >= len(widths) { break } cellLength := len(strings.TrimSpace(cell)) if cellLength > widths[colIdx] { widths[colIdx] = cellLength } } } } // Apply minimum and maximum constraints totalWidth := 0 for i := range widths { if widths[i] < minColumnWidth { widths[i] = minColumnWidth } if widths[i] > maxColumnWidth { widths[i] = maxColumnWidth } totalWidth += widths[i] } // Add space for separators: (n-1) * 2 chars for " ¦" separatorSpace := (len(widths) - 1) * 2 totalUsed := totalWidth + separatorSpace // If we exceed available width, proportionally reduce columns if totalUsed > availableWidth { scaleFactor := float64(availableWidth-separatorSpace) / float64(totalWidth) for i := range widths { widths[i] = int(float64(widths[i]) * scaleFactor) if widths[i] < minColumnWidth { widths[i] = minColumnWidth } } } return widths } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title // Check if query file is configured if widget.settings.Queryfile == "" { return title, "[red]Error: queryFile must be configured in widget settings[white]\n\n", false } // If we have a previous error, show it immediately if widget.lastError != nil { return title, fmt.Sprintf("[red]Error: %v[white]\n\n[dim]Press 'r' to retry[white]", widget.lastError), true } // If data is already loaded, show it if widget.dataLoaded { return widget.renderTable(title) } // Show loading text while fetching data if !widget.loading { widget.loading = true // Start async data fetch go widget.fetchDataAsync() return title, "[yellow]Loading Azure Logs data...[white]\n\n[dim]• Initializing Azure session\n• Executing query on workspace\n• Processing results[white]", false } // Still loading, show loading text return title, "[yellow]Loading Azure Logs data...[white]\n\n[dim]• Initializing Azure session\n• Executing query on workspace\n• Processing results[white]", false } ================================================ FILE: modules/azurelogs/widget_test.go ================================================ package azurelogs import ( "strings" "testing" "time" "github.com/rivo/tview" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" ) func TestNewWidget(t *testing.T) { app := tview.NewApplication() redrawChan := make(chan bool, 1) settings := &Settings{ Common: &cfg.Common{ Title: "Test Azure Logs", }, Queryfile: "/path/to/query.yml", } widget := NewWidget(app, redrawChan, nil, settings) assert.NotNil(t, widget) assert.Equal(t, settings, widget.settings) assert.Equal(t, 60*time.Second, widget.settings.RefreshInterval) assert.False(t, widget.loading) assert.False(t, widget.dataLoaded) assert.Nil(t, widget.lastError) assert.Nil(t, widget.tableData) } // TestWidget_Refresh removed as it tests core WTF framework functionality (Disabled() method) // rather than Azure-specific logic func TestWidget_SetError(t *testing.T) { widget := createTestWidget() widget.loading = true testError := assert.AnError widget.setError(testError) assert.Equal(t, testError, widget.lastError) assert.False(t, widget.loading) } func TestWidget_RenderTable(t *testing.T) { tests := []struct { name string tableData *TableResp expectedTitle string expectedError bool expectedOutput string }{ { name: "nil table data", tableData: nil, expectedTitle: "Test Title", expectedError: true, expectedOutput: "[red]Error: No table data available[white]", }, { name: "table with headers but no data", tableData: &TableResp{ Header: []string{"Column1", "Column2"}, Rows: []TableRow{}, }, expectedTitle: "Test Title", expectedError: false, expectedOutput: "[lightblue]Column1 [white] ¦[lightblue]Column2 [white]", // Just check the header part }, { name: "table with headers and data", tableData: &TableResp{ Header: []string{"Col1", "Col2"}, Rows: []TableRow{ {"Value1", "Value2"}, {"Value3", "Value4"}, }, }, expectedTitle: "Test Title", expectedError: false, expectedOutput: func() string { // This will contain the formatted table with headers, separator, and data return "[lightblue]Col1 [white] ¦[lightblue]Col2 [white]\n--------¦--------\nValue1 ¦Value2 \nValue3 ¦Value4 \n" }(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { widget := createTestWidget() widget.tableData = tt.tableData title, content, hasError := widget.renderTable("Test Title") assert.Equal(t, tt.expectedTitle, title) assert.Equal(t, tt.expectedError, hasError) assert.Contains(t, content, strings.Split(tt.expectedOutput, "\n")[0]) // Check first line }) } } func TestWidget_FormatTableHeaders(t *testing.T) { widget := createTestWidget() var sb strings.Builder headers := []string{"Header1", "Header2", "Header3"} colWidths := []int{10, 15, 8} widget.formatTableHeaders(&sb, headers, colWidths) result := sb.String() assert.Contains(t, result, "[lightblue]Header1 [white]") assert.Contains(t, result, "¦") assert.Contains(t, result, "[lightblue]Header2 [white]") assert.Contains(t, result, "[lightblue]Header3 [white]") assert.True(t, strings.HasSuffix(result, "\n")) } func TestWidget_FormatTableSeparator(t *testing.T) { widget := createTestWidget() var sb strings.Builder headers := []string{"A", "B", "C"} colWidths := []int{5, 8, 6} widget.formatTableSeparator(&sb, headers, colWidths) result := sb.String() // Expected: 5 dashes + "---" + 8 dashes + "---" + 6 dashes + "\n" assert.Equal(t, "-----"+"---"+"--------"+"---"+"------\n", result) } func TestWidget_FormatTableRows(t *testing.T) { widget := createTestWidget() var sb strings.Builder headers := []string{"Col1", "Col2"} colWidths := []int{8, 8} rows := []TableRow{ {"Data1", "Data2"}, {"LongData", "Short"}, } widget.formatTableRows(&sb, rows, headers, colWidths) result := sb.String() lines := strings.Split(strings.TrimSpace(result), "\n") assert.Len(t, lines, 2) assert.Contains(t, lines[0], "Data1") assert.Contains(t, lines[0], "Data2") assert.Contains(t, lines[1], "LongData") assert.Contains(t, lines[1], "Short") } func TestWidget_FormatTableRows_WithTruncation(t *testing.T) { widget := createTestWidget() var sb strings.Builder headers := []string{"Col1", "Col2"} colWidths := []int{8, 8} // Create more rows than maxDisplayRows to test truncation rows := make([]TableRow, maxDisplayRows+10) for i := range rows { rows[i] = TableRow{"data1", "data2"} } widget.formatTableRows(&sb, rows, headers, colWidths) result := sb.String() assert.Contains(t, result, "more rows truncated") } func TestWidget_Content(t *testing.T) { tests := []struct { name string queryfile string lastError error dataLoaded bool loading bool expectedTitle string expectedContains string }{ { name: "no query file configured", queryfile: "", expectedTitle: "Test Azure Logs", expectedContains: "[red]Error: queryFile must be configured", }, { name: "has error", queryfile: "/path/to/query.yml", lastError: assert.AnError, expectedTitle: "Test Azure Logs", expectedContains: "[red]Error:", }, { name: "data loaded", queryfile: "/path/to/query.yml", dataLoaded: true, expectedTitle: "Test Azure Logs", expectedContains: "[red]Error: No table data available", // Since tableData is nil }, { name: "loading state", queryfile: "/path/to/query.yml", loading: false, // Will trigger loading expectedTitle: "Test Azure Logs", expectedContains: "[yellow]Loading Azure Logs data", }, { name: "still loading", queryfile: "/path/to/query.yml", loading: true, expectedTitle: "Test Azure Logs", expectedContains: "[yellow]Loading Azure Logs data", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { widget := createTestWidget() widget.settings.Queryfile = tt.queryfile widget.lastError = tt.lastError widget.dataLoaded = tt.dataLoaded widget.loading = tt.loading title, content, _ := widget.content() assert.Equal(t, tt.expectedTitle, title) assert.Contains(t, content, tt.expectedContains) }) } } func TestCalculateAdaptiveColumnWidths(t *testing.T) { tests := []struct { name string tableResp *TableResp availableWidth int expected []int }{ { name: "empty headers", tableResp: &TableResp{ Header: []string{}, Rows: []TableRow{}, }, availableWidth: 100, expected: []int{}, }, { name: "headers only", tableResp: &TableResp{ Header: []string{"Short", "VeryLongHeaderName"}, Rows: []TableRow{}, }, availableWidth: 100, expected: []int{minColumnWidth, 18}, // "VeryLongHeaderName" is 18 chars }, { name: "headers with data", tableResp: &TableResp{ Header: []string{"Col1", "Col2"}, Rows: []TableRow{ {"ShortData", "VeryLongDataValue"}, {"X", "Y"}, }, }, availableWidth: 100, expected: []int{9, 17}, // Max of header/data lengths }, { name: "width constraints", tableResp: &TableResp{ Header: []string{"VeryVeryVeryLongColumnNameThatExceedsMaxWidth"}, Rows: []TableRow{}, }, availableWidth: 100, expected: []int{maxColumnWidth}, // Capped at maxColumnWidth }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := calculateAdaptiveColumnWidths(tt.tableResp, tt.availableWidth) assert.Equal(t, tt.expected, result) }) } } func TestCalculateAdaptiveColumnWidths_Scaling(t *testing.T) { // Test case where columns need to be scaled down tableResp := &TableResp{ Header: []string{"LongHeader1", "LongHeader2", "LongHeader3"}, Rows: []TableRow{}, } // Very small available width to force scaling result := calculateAdaptiveColumnWidths(tableResp, 20) // All columns should be scaled down to minimum width for _, width := range result { assert.GreaterOrEqual(t, width, minColumnWidth) } // Total width + separators should not exceed available width significantly totalWidth := 0 for _, width := range result { totalWidth += width } separatorSpace := (len(result) - 1) * 2 assert.LessOrEqual(t, totalWidth+separatorSpace, 30) // Allow some margin for scaling } // Helper function to create a test widget func createTestWidget() *Widget { app := tview.NewApplication() redrawChan := make(chan bool, 1) settings := &Settings{ Common: &cfg.Common{ Title: "Test Azure Logs", Enabled: true, // Enable by default for tests }, Queryfile: "/path/to/query.yml", } return NewWidget(app, redrawChan, nil, settings) } ================================================ FILE: modules/bamboohr/calendar.go ================================================ package bamboohr type Calendar struct { Items []Item `xml:"item"` } /* -------------------- Public Functions -------------------- */ func (calendar *Calendar) Holidays() []Item { return calendar.filteredItems("holiday") } func (calendar *Calendar) ItemsByType(itemType string) []Item { if itemType == "timeOff" { return calendar.TimeOffs() } return calendar.Holidays() } func (calendar *Calendar) TimeOffs() []Item { return calendar.filteredItems("timeOff") } /* -------------------- Private Functions -------------------- */ func (calendar *Calendar) filteredItems(itemType string) []Item { items := []Item{} for _, item := range calendar.Items { if item.Type == itemType { items = append(items, item) } } return items } ================================================ FILE: modules/bamboohr/client.go ================================================ package bamboohr import ( "encoding/xml" "fmt" ) // A Client represents the data required to connect to the BambooHR API type Client struct { apiBase string apiKey string subdomain string } // NewClient creates and returns a new BambooHR client func NewClient(url string, apiKey string, subdomain string) *Client { client := Client{ apiBase: url, apiKey: apiKey, subdomain: subdomain, } return &client } /* -------------------- Public Functions -------------------- */ // Away returns a string representation of the people who are out of the office during the defined period func (client *Client) Away(itemType, startDate, endDate string) []Item { calendar, err := client.getWhoIsAway(startDate, endDate) if err != nil { return []Item{} } items := calendar.ItemsByType(itemType) return items } /* -------------------- Private Functions -------------------- */ // getWhoIsAway is the private interface for retrieving structural data about who will be out of the office // This method does the actual communication with BambooHR and returns the raw Go // data structures used by the public interface func (client *Client) getWhoIsAway(startDate, endDate string) (cal Calendar, err error) { apiURL := fmt.Sprintf( "%s/%s/v1/time_off/whos_out?start=%s&end=%s", client.apiBase, client.subdomain, startDate, endDate, ) data, err := Request(client.apiKey, apiURL) if err != nil { return cal, err } err = xml.Unmarshal(data, &cal) return } ================================================ FILE: modules/bamboohr/employee.go ================================================ package bamboohr /* * Note: this currently implements the minimum number of fields to fulfill the Away functionality. * Undoubtedly there are more fields than this to an employee */ type Employee struct { ID int `xml:"id,attr"` Name string `xml:",chardata"` } ================================================ FILE: modules/bamboohr/item.go ================================================ package bamboohr import ( "fmt" "github.com/wtfutil/wtf/wtf" ) type Item struct { Employee Employee `xml:"employee"` End string `xml:"end"` Holiday string `xml:"holiday"` Start string `xml:"start"` Type string `xml:"type,attr"` } func (item *Item) String() string { return fmt.Sprintf("Item: %s, %s, %s, %s", item.Type, item.Employee.Name, item.Start, item.End) } /* -------------------- Exported Functions -------------------- */ func (item *Item) IsOneDay() bool { return item.Start == item.End } func (item *Item) Name() string { if (item.Employee != Employee{}) { return item.Employee.Name } return item.Holiday } func (item *Item) PrettyStart() string { return wtf.PrettyDate(item.Start) } func (item *Item) PrettyEnd() string { return wtf.PrettyDate(item.End) } ================================================ FILE: modules/bamboohr/request.go ================================================ package bamboohr import ( "bytes" "net/http" ) func Request(apiKey string, apiURL string) ([]byte, error) { req, err := http.NewRequest("GET", apiURL, http.NoBody) if err != nil { return nil, err } req.SetBasicAuth(apiKey, "x") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() data, err := ParseBody(resp) if err != nil { return nil, err } return data, err } func ParseBody(resp *http.Response) ([]byte, error) { var buffer bytes.Buffer _, err := buffer.ReadFrom(resp.Body) if err != nil { return nil, err } return buffer.Bytes(), nil } ================================================ FILE: modules/bamboohr/settings.go ================================================ package bamboohr import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "BambooHR" ) type Settings struct { *cfg.Common apiKey string `help:"Your BambooHR API token."` subdomain string `help:"Your BambooHR API subdomain name."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_BAMBOO_HR_TOKEN"))), subdomain: ymlConfig.UString("subdomain", os.Getenv("WTF_BAMBOO_HR_SUBDOMAIN")), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/bamboohr/widget.go ================================================ package bamboohr import ( "fmt" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "github.com/wtfutil/wtf/wtf" ) const apiURI = "https://api.bamboohr.com/api/gateway.php" type Widget struct { view.TextWidget settings *Settings items []Item } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { client := NewClient( apiURI, widget.settings.apiKey, widget.settings.subdomain, ) widget.items = client.Away( "timeOff", time.Now().Local().Format(wtf.DateFormat), time.Now().Local().Format(wtf.DateFormat), ) widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { str := "" if len(widget.items) == 0 { str = fmt.Sprintf("\n\n\n\n\n\n\n\n%s", utils.CenterText("[grey]no one[white]", 50)) } else { for _, item := range widget.items { str += widget.format(item) } } return widget.CommonSettings().Title, str, false } func (widget *Widget) format(item Item) string { var str string if item.IsOneDay() { str = fmt.Sprintf(" [green]%s[white]\n %s\n\n", item.Name(), item.PrettyEnd()) } else { str = fmt.Sprintf(" [green]%s[white]\n %s - %s\n\n", item.Name(), item.PrettyStart(), item.PrettyEnd()) } return str } ================================================ FILE: modules/bargraph/settings.go ================================================ package bargraph import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Bargraph" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/bargraph/widget.go ================================================ package bargraph /************** This is a demo bargraph that just populates some random date/val data */ import ( "math/rand" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.BarGraph tviewApp *tview.Application } // NewWidget Make new instance of widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ BarGraph: view.NewBarGraph(tviewApp, redrawChan, "Sample Bar Graph", settings.Common), tviewApp: tviewApp, } widget.View.SetWrap(true) widget.View.SetWordWrap(true) return &widget } /* -------------------- Exported Functions -------------------- */ // MakeGraph - Load the dead drop stats func MakeGraph(widget *Widget) { //this could come from config const lineCount = 8 var stats [lineCount]view.Bar barTime := time.Now() for i := 0; i < lineCount; i++ { barTime = barTime.Add(time.Minute) bar := view.Bar{ Label: barTime.Format("15:04"), Percent: rand.Intn(100-5) + 5, LabelColor: "red", } stats[i] = bar } widget.BuildBars(stats[:]) } // Refresh & update after interval time func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.View.Clear() MakeGraph(widget) } ================================================ FILE: modules/buildkite/client.go ================================================ package buildkite import ( "fmt" "net/http" "github.com/wtfutil/wtf/utils" ) type Pipeline struct { Slug string `json:"slug"` } type Build struct { State string `json:"state"` Pipeline Pipeline `json:"pipeline"` Branch string `json:"branch"` WebUrl string `json:"web_url"` } func (widget *Widget) getBuilds() ([]Build, error) { builds := []Build{} for _, pipeline := range widget.settings.pipelines { buildsForPipeline, err := widget.recentBuilds(pipeline) if err != nil { return nil, err } mostRecent := mostRecentBuildForBranches(buildsForPipeline, pipeline.branches) builds = append(builds, mostRecent...) } return builds, nil } func (widget *Widget) recentBuilds(pipeline PipelineSettings) ([]Build, error) { url := fmt.Sprintf( "https://api.buildkite.com/v2/organizations/%s/pipelines/%s/builds%s", widget.settings.orgSlug, pipeline.slug, branchesQuery(pipeline.branches), ) req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", widget.settings.apiKey)) httpClient := &http.Client{Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, }} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } builds := []Build{} err = utils.ParseJSON(&builds, resp.Body) if err != nil { return nil, err } return builds, nil } func branchesQuery(branches []string) string { if len(branches) == 0 { return "" } if len(branches) == 1 { return fmt.Sprintf("?branch=%s", branches[0]) } queryString := fmt.Sprintf("?branch[]=%s", branches[0]) for _, branch := range branches[1:] { queryString += fmt.Sprintf("&branch[]=%s", branch) } return queryString } func mostRecentBuildForBranches(builds []Build, branches []string) []Build { recentBuilds := []Build{} haveMostRecentBuildForBranch := map[string]bool{} for _, branch := range branches { haveMostRecentBuildForBranch[branch] = false } for _, build := range builds { if !haveMostRecentBuildForBranch[build.Branch] { haveMostRecentBuildForBranch[build.Branch] = true recentBuilds = append(recentBuilds, build) } } return recentBuilds } ================================================ FILE: modules/buildkite/keyboard.go ================================================ package buildkite func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) } ================================================ FILE: modules/buildkite/pipelines_display_data.go ================================================ package buildkite import ( "fmt" "sort" "strings" ) type pipelinesDisplayData struct { buildsForPipeline map[string][]Build orderedPipelines []string } func (data *pipelinesDisplayData) Content() string { maxPipelineLength := getLongestLength(data.orderedPipelines) str := "" for _, pipeline := range data.orderedPipelines { str += fmt.Sprintf("[white]%s", padRight(pipeline, maxPipelineLength)) for _, build := range data.buildsForPipeline[pipeline] { str += fmt.Sprintf(" [%s]%s[white]", buildColor(build.State), build.Branch) } str += "\n" } return str } func newPipelinesDisplayData(builds []Build) pipelinesDisplayData { grouped := make(map[string][]Build) for _, build := range builds { if _, ok := grouped[build.Pipeline.Slug]; ok { grouped[build.Pipeline.Slug] = append(grouped[build.Pipeline.Slug], build) } else { grouped[build.Pipeline.Slug] = []Build{} grouped[build.Pipeline.Slug] = append(grouped[build.Pipeline.Slug], build) } } orderedPipelines := make([]string, len(grouped)) i := 0 for pipeline := range grouped { orderedPipelines[i] = pipeline i++ } sort.Strings(orderedPipelines) name := func(b1, b2 *Build) bool { return b1.Branch < b2.Branch } for _, builds := range grouped { ByBuild(name).Sort(builds) } return pipelinesDisplayData{ buildsForPipeline: grouped, orderedPipelines: orderedPipelines, } } type ByBuild func(b1, b2 *Build) bool func (by ByBuild) Sort(builds []Build) { sorter := &buildSorter{ builds: builds, by: by, } sort.Sort(sorter) } type buildSorter struct { builds []Build by func(b1, b2 *Build) bool } func (bs *buildSorter) Len() int { return len(bs.builds) } func (bs *buildSorter) Swap(i, j int) { bs.builds[i], bs.builds[j] = bs.builds[j], bs.builds[i] } func (bs *buildSorter) Less(i, j int) bool { return bs.by(&bs.builds[i], &bs.builds[j]) } func getLongestLength(strs []string) int { longest := 0 for _, str := range strs { if len(str) > longest { longest = len(str) } } return longest } func padRight(text string, length int) string { padLength := length - len(text) if padLength <= 0 { return text[:length] } return text + strings.Repeat(" ", padLength) } func buildColor(state string) string { switch state { case "passed": return "green" case "failed": return "red" default: return "yellow" } } ================================================ FILE: modules/buildkite/settings.go ================================================ package buildkite import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultTitle = "Buildkite" defaultFocusable = true ) // PipelineSettings defines the configuration properties for a pipeline type PipelineSettings struct { slug string branches []string } // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"Your Buildkite API Token"` orgSlug string `help:"Organization Slug"` pipelines []PipelineSettings `help:"An array of pipelines to get data from"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_BUILDKITE_TOKEN")), orgSlug: ymlConfig.UString("organizationSlug"), pipelines: buildPipelineSettings(ymlConfig), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } /* -------------------- Unexported Functions -------------------- */ func buildPipelineSettings(ymlConfig *config.Config) []PipelineSettings { pipelines := []PipelineSettings{} for slug := range ymlConfig.UMap("pipelines") { branches := utils.ToStrs(ymlConfig.UList("pipelines." + slug + ".branches")) if len(branches) == 0 { branches = []string{"master"} } pipeline := PipelineSettings{ slug: slug, branches: branches, } pipelines = append(pipelines, pipeline) } return pipelines } ================================================ FILE: modules/buildkite/widget.go ================================================ package buildkite import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings builds []Build err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() widget.View.SetScrollable(true) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { builds, err := widget.getBuilds() if err != nil { widget.err = err widget.builds = nil } else { widget.builds = builds widget.err = nil } // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - [green]%s", widget.CommonSettings().Title, widget.settings.orgSlug) if widget.err != nil { return title, widget.err.Error(), true } displayData := newPipelinesDisplayData(widget.builds) return title, displayData.Content(), false } ================================================ FILE: modules/cds/favorites/display.go ================================================ package cdsfavorites import ( "fmt" "github.com/ovh/cds/sdk" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { if len(widget.View.GetHighlights()) > 0 { widget.View.ScrollToHighlight() } else { widget.View.ScrollToBeginning() } widget.Items = make([]int64, 0) workflow := widget.currentCDSWorkflow() if workflow == nil { return "", " Workflow not selected ", false } _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.workflows), widget.Idx, width) + "\n" title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.title(workflow)) str += widget.displayWorkflowRuns(workflow) return title, str, false } func (widget *Widget) title(workflow *sdk.Workflow) string { return fmt.Sprintf( "[%s]%s/%s[white]", widget.settings.Colors.Title, workflow.ProjectKey, workflow.Name, ) } func (widget *Widget) displayWorkflowRuns(workflow *sdk.Workflow) string { runs, _ := widget.client.WorkflowRunList(workflow.ProjectKey, workflow.Name, 0, 16) widget.SetItemCount(len(runs)) if len(runs) == 0 { return " [grey]none[white]\n" } content := "" for idx, run := range runs { var tags string for _, tag := range run.Tags { toadd := true for _, v := range widget.settings.hideTags { if v == tag.Tag { toadd = false break } } if toadd { tags = fmt.Sprintf("%s%s:%s ", tags, tag.Tag, tag.Value) } } content += fmt.Sprintf(`[%s]["%d"]%d %-6s[""][gray] %s`, getStatusColor(run.Status), idx, run.Number, run.Status, tags) content += "\n" widget.Items = append(widget.Items, run.Number) } return content } func getStatusColor(status string) string { switch status { case sdk.StatusSuccess: return "green" case sdk.StatusBuilding, sdk.StatusWaiting: return "blue" case sdk.StatusFail: return "red" case sdk.StatusStopped: return "red" case sdk.StatusSkipped: return "grey" case sdk.StatusDisabled: return "grey" } return "red" } ================================================ FILE: modules/cds/favorites/keyboard.go ================================================ package cdsfavorites import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next workflow") widget.SetKeyboardChar("k", widget.Prev, "Select previous workflow") widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("o", widget.openWorkflow, "Open workflow in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next workflow") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous workflow") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") widget.SetKeyboardKey(tcell.KeyEnter, widget.openWorkflow, "Open workflow in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/cds/favorites/settings.go ================================================ package cdsfavorites import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "CDS Favorites" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common token string `help:"Your CDS API token."` apiURL string `help:"Your CDS API URL."` uiURL string hideTags []string `help:"Hide some workflow tags."` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), token: ymlConfig.UString("token", ymlConfig.UString("token", os.Getenv("CDS_TOKEN"))), apiURL: ymlConfig.UString("apiURL", os.Getenv("CDS_API_URL")), hideTags: utils.ToStrs(ymlConfig.UList("hideTags")), } settings.SetDocumentationPath("cds/favorites") return &settings } ================================================ FILE: modules/cds/favorites/widget.go ================================================ package cdsfavorites import ( "fmt" "strconv" "github.com/ovh/cds/sdk" "github.com/ovh/cds/sdk/cdsclient" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.MultiSourceWidget view.TextWidget workflows []sdk.Workflow client cdsclient.Interface settings *Settings Selected int maxItems int Items []int64 } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "workflow", "workflows"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.SetDisplayFunction(widget.display) widget.Unselect() widget.client = cdsclient.New(cdsclient.Config{ Host: settings.apiURL, BuiltinConsumerAuthenticationToken: settings.token, }) config, _ := widget.client.ConfigUser() if config.URLUI != "" { widget.settings.uiURL = config.URLUI } widget.workflows = widget.buildWorkflowsCollection() return &widget } /* -------------------- Exported Functions -------------------- */ // SetItemCount sets the amount of workflows throughout the widgets display creation func (widget *Widget) SetItemCount(items int) { widget.maxItems = items } // GetItemCount returns the amount of workflows calculated so far as an int func (widget *Widget) GetItemCount() int { return widget.maxItems } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } // Refresh reloads the data func (widget *Widget) Refresh() { widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) buildWorkflowsCollection() []sdk.Workflow { workflows := []sdk.Workflow{} data, _ := widget.client.Navbar() for _, v := range data { if v.Favorite && v.WorkflowName != "" { workflows = append(workflows, sdk.Workflow{ProjectKey: v.Key, Name: v.WorkflowName}) } } return workflows } func (widget *Widget) currentCDSWorkflow() *sdk.Workflow { if len(widget.workflows) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.workflows) { widget.Idx = 0 } p := widget.workflows[widget.Idx] return &p } func (widget *Widget) openWorkflow() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && currentSelection[0] != "" { wf := widget.currentCDSWorkflow() url := fmt.Sprintf("%s/project/%s/workflow/%s/run/%d", widget.settings.uiURL, wf.ProjectKey, wf.Name, widget.Items[widget.Selected]) utils.OpenFile(url) } } ================================================ FILE: modules/cds/queue/display.go ================================================ package cdsqueue import ( "fmt" "strings" "time" "github.com/ovh/cds/sdk" "github.com/ovh/cds/sdk/cdsclient" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { if len(widget.View.GetHighlights()) > 0 { widget.View.ScrollToHighlight() } else { widget.View.ScrollToBeginning() } widget.Items = make([]sdk.WorkflowNodeJobRun, 0) filter := widget.currentFilter() _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.filters), widget.Idx, width) + "\n" str += widget.displayQueue(filter) title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.title(filter)) return title, str, false } func (widget *Widget) title(filter string) string { return fmt.Sprintf( "[%s]%d - %s[white]", widget.settings.Colors.Title, widget.maxItems, filter, ) } func (widget *Widget) displayQueue(filter string) string { runs, _ := widget.client.QueueWorkflowNodeJobRun(cdsclient.Status(filter)) widget.SetItemCount(len(runs)) if len(runs) == 0 { return " [grey]none[white]\n" } var content string for idx, job := range runs { content += fmt.Sprintf(`[grey]["%d"]%s`, idx, widget.generateQueueJobLine(job.Parameters, job.Job, time.Since(job.Queued), job.BookedBy, job.Status)) widget.Items = append(widget.Items, job) } return content } func (widget *Widget) generateQueueJobLine(parameters []sdk.Parameter, executedJob sdk.ExecutedJob, duration time.Duration, bookedBy sdk.BookedBy, status string) string { prj := getVarsInPbj("cds.project", parameters) workflow := getVarsInPbj("cds.workflow", parameters) node := getVarsInPbj("cds.node", parameters) run := getVarsInPbj("cds.run", parameters) triggeredBy := getVarsInPbj("cds.triggered_by.username", parameters) row := make([]string, 6) row[0] = pad(sdk.Round(duration, time.Second).String(), 9) row[2] = pad(run, 7) row[3] = pad(prj+"/"+workflow+"/"+node, 40) switch { case status == sdk.StatusBuilding: row[1] = pad(fmt.Sprintf(" %s.%s ", executedJob.WorkerName, executedJob.WorkerID), 27) case bookedBy.ID != 0: row[1] = pad(fmt.Sprintf(" %s.%d ", bookedBy.Name, bookedBy.ID), 27) default: row[1] = pad("", 27) } row[4] = fmt.Sprintf("➤ %s", pad(triggeredBy, 17)) c := "grey" if status == sdk.StatusWaiting { if duration > 120*time.Second { c = "red" } else if duration > 50*time.Second { c = "yellow" } } return fmt.Sprintf("[%s]%s [grey]%s %s %s %s\n", c, row[0], row[1], row[2], row[3], row[4]) } func pad(t string, size int) string { if len(t) > size { return t[0:size-3] + "..." } return t + strings.Repeat(" ", size-len(t)) } func getVarsInPbj(key string, ps []sdk.Parameter) string { for _, p := range ps { if p.Name == key { return p.Value } } return "" } ================================================ FILE: modules/cds/queue/keyboard.go ================================================ package cdsqueue import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next workflow") widget.SetKeyboardChar("k", widget.Prev, "Select previous workflow") widget.SetKeyboardChar("l", widget.NextSource, "Select next filter") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous filter") widget.SetKeyboardChar("o", widget.openWorkflow, "Open workflow in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next workflow") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous workflow") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next filter") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous filter") widget.SetKeyboardKey(tcell.KeyEnter, widget.openWorkflow, "Open workflow in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/cds/queue/settings.go ================================================ package cdsqueue import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "CDS Queue" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common token string `help:"Your CDS API token."` apiURL string `help:"Your CDS API URL."` uiURL string } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), token: ymlConfig.UString("token", ymlConfig.UString("token", os.Getenv("CDS_TOKEN"))), apiURL: ymlConfig.UString("apiURL", os.Getenv("CDS_API_URL")), } settings.SetDocumentationPath("cds/queue") return &settings } ================================================ FILE: modules/cds/queue/widget.go ================================================ package cdsqueue import ( "fmt" "strconv" "github.com/ovh/cds/sdk" "github.com/ovh/cds/sdk/cdsclient" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.MultiSourceWidget view.TextWidget filters []string client cdsclient.Interface settings *Settings Selected int maxItems int Items []sdk.WorkflowNodeJobRun } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "workflow", "workflows"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.SetDisplayFunction(widget.display) widget.Unselect() widget.filters = []string{sdk.StatusWaiting, sdk.StatusBuilding} widget.client = cdsclient.New(cdsclient.Config{ Host: settings.apiURL, BuiltinConsumerAuthenticationToken: settings.token, }) config, _ := widget.client.ConfigUser() if config.URLUI != "" { widget.settings.uiURL = config.URLUI } return &widget } /* -------------------- Exported Functions -------------------- */ // SetItemCount sets the amount of workflows throughout the widgets display creation func (widget *Widget) SetItemCount(items int) { widget.maxItems = items } // GetItemCount returns the amount of workflows calculated so far as an int func (widget *Widget) GetItemCount() int { return widget.maxItems } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } // Refresh reloads the data func (widget *Widget) Refresh() { widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) currentFilter() string { if len(widget.filters) == 0 { return sdk.StatusWaiting } if widget.Idx < 0 || widget.Idx >= len(widget.filters) { widget.Idx = 0 return sdk.StatusWaiting } return widget.filters[widget.Idx] } func (widget *Widget) openWorkflow() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && currentSelection[0] != "" { job := widget.Items[widget.Selected] prj := getVarsInPbj("cds.project", job.Parameters) workflow := getVarsInPbj("cds.workflow", job.Parameters) runNumber := getVarsInPbj("cds.run.number", job.Parameters) url := fmt.Sprintf("%s/project/%s/workflow/%s/run/%s", widget.settings.uiURL, prj, workflow, runNumber) utils.OpenFile(url) } } ================================================ FILE: modules/cds/status/display.go ================================================ package cdsstatus import ( "fmt" "strings" "github.com/ovh/cds/sdk" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { if len(widget.View.GetHighlights()) > 0 { widget.View.ScrollToHighlight() } else { widget.View.ScrollToBeginning() } widget.Items = make([]sdk.MonitoringStatusLine, 0) str := widget.displayStatus() title := widget.CommonSettings().Title return title, str, false } func (widget *Widget) displayStatus() string { status, err := widget.client.MonStatus() if err != nil || len(status.Lines) == 0 { return fmt.Sprintf(" [red]Error: %v[white]\n", err.Error()) } widget.SetItemCount(len(status.Lines)) var ( global []string globalWarn []string globalRed []string ok []string warn []string red []string ) for _, line := range status.Lines { switch { case line.Status == sdk.MonitoringStatusWarn && strings.Contains(line.Component, "Global"): globalWarn = append(globalWarn, line.String()) case line.Status != sdk.MonitoringStatusOK && strings.Contains(line.Component, "Global"): globalRed = append(globalRed, line.String()) case strings.Contains(line.Component, "Global"): global = append(global, line.String()) case line.Status == sdk.MonitoringStatusWarn: warn = append(warn, line.String()) case line.Status == sdk.MonitoringStatusOK: ok = append(ok, line.String()) default: red = append(red, line.String()) } } var idx int var content string for _, v := range globalRed { content += fmt.Sprintf("[grey][\"%d\"][red]%s\n", idx, v) idx++ } for _, v := range globalWarn { content += fmt.Sprintf("[grey][\"%d\"][yellow]%s\n", idx, v) idx++ } for _, v := range global { content += fmt.Sprintf("[grey][\"%d\"][grey]%s\n", idx, v) idx++ } for _, v := range red { content += fmt.Sprintf("[grey][\"%d\"][red]%s\n", idx, v) idx++ } for _, v := range warn { content += fmt.Sprintf("[grey][\"%d\"][yellow]%s\n", idx, v) idx++ } for _, v := range ok { content += fmt.Sprintf("[grey][\"%d\"][grey]%s\n", idx, v) idx++ } return content } ================================================ FILE: modules/cds/status/keyboard.go ================================================ package cdsstatus import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next line") widget.SetKeyboardChar("k", widget.Prev, "Select previous line") widget.SetKeyboardChar("o", widget.openWorkflow, "Open status in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next line") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous line") widget.SetKeyboardKey(tcell.KeyEnter, widget.openWorkflow, "Open status in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/cds/status/settings.go ================================================ package cdsstatus import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "CDS Status" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common token string `help:"Your CDS API token."` apiURL string `help:"Your CDS API URL."` uiURL string } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), token: ymlConfig.UString("token", ymlConfig.UString("token", os.Getenv("CDS_TOKEN"))), apiURL: ymlConfig.UString("apiURL", os.Getenv("CDS_API_URL")), } settings.SetDocumentationPath("cds/status") return &settings } ================================================ FILE: modules/cds/status/widget.go ================================================ package cdsstatus import ( "fmt" "strconv" "github.com/ovh/cds/sdk" "github.com/ovh/cds/sdk/cdsclient" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.MultiSourceWidget view.TextWidget filters []string client cdsclient.Interface settings *Settings Selected int maxItems int Items []sdk.MonitoringStatusLine } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "workflow", "workflows"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.SetDisplayFunction(widget.display) widget.Unselect() widget.filters = []string{sdk.StatusWaiting, sdk.StatusBuilding} widget.client = cdsclient.New(cdsclient.Config{ Host: settings.apiURL, BuiltinConsumerAuthenticationToken: settings.token, }) config, _ := widget.client.ConfigUser() if config.URLUI != "" { widget.settings.uiURL = config.URLUI } return &widget } /* -------------------- Exported Functions -------------------- */ // SetItemCount sets the amount of line throughout the widgets display creation func (widget *Widget) SetItemCount(items int) { widget.maxItems = items } // GetItemCount returns the amount of line calculated so far as an int func (widget *Widget) GetItemCount() int { return widget.maxItems } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } // Refresh reloads the data func (widget *Widget) Refresh() { widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) openWorkflow() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && currentSelection[0] != "" { url := fmt.Sprintf("%s/admin/services", widget.settings.uiURL) utils.OpenFile(url) } } ================================================ FILE: modules/circleci/build.go ================================================ package circleci type Build struct { AuthorEmail string `json:"author_email"` AuthorName string `json:"author_name"` Branch string `json:"branch"` BuildNum int `json:"build_num"` Reponame string `json:"reponame"` Status string `json:"status"` } ================================================ FILE: modules/circleci/client.go ================================================ package circleci import ( "bytes" "fmt" "io" "net/http" "net/url" "github.com/wtfutil/wtf/utils" ) type Client struct { apiKey string } func NewClient(apiKey string) *Client { client := Client{ apiKey: apiKey, } return &client } func (client *Client) BuildsFor() ([]*Build, error) { builds := []*Build{} resp, err := client.circleRequest("recent-builds") if err != nil { return builds, err } err = utils.ParseJSON(&builds, bytes.NewReader(resp)) if err != nil { return builds, err } return builds, nil } /* -------------------- Unexported Functions -------------------- */ var ( circleAPIURL = &url.URL{Scheme: "https", Host: "circleci.com", Path: "/api/v1/"} ) func (client *Client) circleRequest(path string) ([]byte, error) { params := url.Values{} params.Add("circle-token", client.apiKey) url := circleAPIURL.ResolveReference(&url.URL{Path: path, RawQuery: params.Encode()}) req, err := http.NewRequest("GET", url.String(), http.NoBody) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") if err != nil { return nil, err } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } ================================================ FILE: modules/circleci/settings.go ================================================ package circleci import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "CircleCI" ) type Settings struct { *cfg.Common apiKey string `help:"Your CircleCI API token."` numberOfBuilds int `help:"The number of build, 10 by default"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_CIRCLE_API_KEY"))), numberOfBuilds: ymlConfig.UInt("numberOfBuilds", 10), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/circleci/widget.go ================================================ package circleci import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget *Client settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), Client: NewClient(settings.apiKey), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { builds, err := widget.BuildsFor() title := fmt.Sprintf("%s - Builds", widget.CommonSettings().Title) var str string wrap := false if err != nil { wrap = true str = err.Error() } else { for idx, build := range builds { if idx > widget.settings.numberOfBuilds { break } str += fmt.Sprintf( "[%s] %s-%d (%s) [white]%s\n", buildColor(build), build.Reponame, build.BuildNum, build.Branch, build.AuthorName, ) } } return title, str, wrap } func buildColor(build *Build) string { switch build.Status { case "failed": return "red" case "running": return "yellow" case "success": return "green" case "fixed": return "green" default: return "white" } } ================================================ FILE: modules/clocks/clock.go ================================================ package clocks import ( "strings" "time" ) type Clock struct { Label string Location *time.Location } func NewClock(label string, timeLoc *time.Location) Clock { clock := Clock{ Label: label, Location: timeLoc, } return clock } func BuildClock(label string, location string) (clock Clock, err error) { timeLoc, err := time.LoadLocation(sanitizeLocation(location)) if err != nil { return Clock{}, err } return NewClock(label, timeLoc), nil } func (clock *Clock) Date(dateFormat string) string { return clock.LocalTime().Format(dateFormat) } func (clock *Clock) LocalTime() time.Time { return clock.ToLocal(time.Now()) } func (clock *Clock) ToLocal(t time.Time) time.Time { return t.In(clock.Location) } func (clock *Clock) Time(timeFormat string) string { return clock.LocalTime().Format(timeFormat) } func sanitizeLocation(locStr string) string { return strings.ReplaceAll(locStr, " ", "_") } ================================================ FILE: modules/clocks/clock_collection.go ================================================ package clocks import ( "sort" "time" ) type ClockCollection struct { Clocks []Clock } func (clocks *ClockCollection) Sorted(sortOrder string) []Clock { switch sortOrder { case "natural": // do nothing case "chronological": clocks.SortedChronologically() case "reversechronological": clocks.SortedReverseChronologically() default: clocks.SortedAlphabetically() } return clocks.Clocks } func (clocks *ClockCollection) SortedAlphabetically() { sort.Slice(clocks.Clocks, func(i, j int) bool { clock := clocks.Clocks[i] other := clocks.Clocks[j] return clock.Label < other.Label }) } func (clocks *ClockCollection) SortedChronologically() { now := time.Now() sort.Slice(clocks.Clocks, func(i, j int) bool { clock := clocks.Clocks[i] other := clocks.Clocks[j] return clock.ToLocal(now).String() < other.ToLocal(now).String() }) } func (clocks *ClockCollection) SortedReverseChronologically() { now := time.Now() sort.Slice(clocks.Clocks, func(i, j int) bool { clock := clocks.Clocks[i] other := clocks.Clocks[j] return clock.ToLocal(now).String() > other.ToLocal(now).String() }) } ================================================ FILE: modules/clocks/display.go ================================================ package clocks import "fmt" func (widget *Widget) display(clocks []Clock, dateFormat string, timeFormat string) { str := "" locationWidth := 12 for _, clock := range clocks { if len(clock.Label) > locationWidth { locationWidth = len(clock.Label) + 2 } } if len(clocks) == 0 { str = fmt.Sprintf("\n%s", " no timezone data available") } else { for idx, clock := range clocks { str += fmt.Sprintf( " [%s]%-*s %-10s %7s[white]\n", widget.CommonSettings().RowColor(idx), locationWidth, clock.Label, clock.Time(timeFormat), clock.Date(dateFormat), ) } } widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, str, false }) } ================================================ FILE: modules/clocks/settings.go ================================================ package clocks import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = false defaultTitle = "Clocks" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common dateFormat string `help:"The format of the date string for all clocks." values:"Any valid Go date layout which is handled by Time.Format. Defaults to Jan 2."` timeFormat string `help:"The format of the time string for all clocks." values:"Any valid Go time layout which is handled by Time.Format. Defaults to 15:04 MST."` locations []Clock `help:"Defines the timezones for the world clocks that you want to display. key is a unique label that will be displayed in the UI. value is a timezone name." values:"Any TZ database timezone."` sort string `help:"Defines the display order of the clocks in the widget." values:"'alphabetical', 'chronological', or 'natural. 'alphabetical' will sort in ascending order by key, 'chronological' will sort in ascending order by date/time, 'natural' will keep ordering as per the config."` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), dateFormat: ymlConfig.UString("dateFormat", utils.SimpleDateFormat), timeFormat: ymlConfig.UString("timeFormat", utils.SimpleTimeFormat), locations: buildLocations(ymlConfig), sort: ymlConfig.UString("sort"), } return &settings } func buildLocations(ymlConfig *config.Config) []Clock { clocks := []Clock{} locations, err := ymlConfig.Map("locations") if err == nil { for k, v := range locations { name := k zone := v.(string) clock, err := BuildClock(name, zone) if err == nil { clocks = append(clocks, clock) } } return clocks } listLocations := ymlConfig.UList("locations") for _, location := range listLocations { if location, ok := location.(map[string]interface{}); ok { for k, v := range location { name := k zone := v.(string) clock, err := BuildClock(name, zone) if err == nil { clocks = append(clocks, clock) } } } } return clocks } ================================================ FILE: modules/clocks/widget.go ================================================ package clocks import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget clockColl ClockCollection dateFormat string timeFormat string settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, dateFormat: settings.dateFormat, timeFormat: settings.timeFormat, } widget.clockColl = widget.buildClockCollection() return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { sortedClocks := widget.clockColl.Sorted(widget.settings.sort) widget.display(sortedClocks, widget.dateFormat, widget.timeFormat) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) buildClockCollection() ClockCollection { clockColl := ClockCollection{} clockColl.Clocks = widget.settings.locations return clockColl } ================================================ FILE: modules/cmdrunner/settings.go ================================================ package cmdrunner import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "CmdRunner" ) // Settings for the cmdrunner widget type Settings struct { *cfg.Common args []string `help:"The arguments to the command, with each item as an element in an array. Example: for curl -I cisco.com, the arguments array would be ['-I', 'cisco.com']."` cmd string `help:"The terminal command to be run, withouth the arguments. Ie: ping, whoami, curl."` tail bool `help:"Automatically scroll to the end of the command output."` pty bool `help:"Run the command in a pseudo-terminal. Some apps will behave differently if they feel in a terminal. For example, some apps will produce colorized output in a terminal, and non-colorized output otherwise. Default false" optional:"true"` ptySuppressErrors bool `help:"Do not display pty errors. Some apps producing colorized output may result in trailing errors. This will attempt to hide them, use only when necessary. Default false" optional:"true"` maxLines int `help:"Maximum number of lines kept in the buffer."` workingDir string `help:"Working directory for command to run in" optional:"true"` // The dimensions of the module width int height int } // NewSettingsFromYAML loads the cmdrunner portion of the WTF config func NewSettingsFromYAML(name string, moduleConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, moduleConfig, globalConfig), args: utils.ToStrs(moduleConfig.UList("args")), workingDir: moduleConfig.UString("workingDir", "."), cmd: moduleConfig.UString("cmd"), pty: moduleConfig.UBool("pty", false), ptySuppressErrors: moduleConfig.UBool("ptySuppressErrors", false), tail: moduleConfig.UBool("tail", false), maxLines: moduleConfig.UInt("maxLines", 256), } width, height, err := utils.CalculateDimensions(moduleConfig, globalConfig) if err == nil { settings.width = width settings.height = height } return &settings } ================================================ FILE: modules/cmdrunner/widget.go ================================================ package cmdrunner import ( "bytes" "errors" "fmt" "io" "os" "os/exec" "os/signal" "strings" "sync" "syscall" "github.com/creack/pty" "github.com/rivo/tview" "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/view" ) // Widget contains the data for this widget type Widget struct { view.TextWidget settings *Settings m sync.Mutex buffer *bytes.Buffer runChan chan bool redrawChan chan bool } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, buffer: &bytes.Buffer{}, } widget.View.SetWrap(true) widget.View.SetScrollable(true) widget.runChan = make(chan bool) widget.redrawChan = make(chan bool) go runCommandLoop(&widget) go redrawLoop(&widget) widget.runChan <- true return &widget } // Refresh signals the runCommandLoop to continue, or triggers a re-draw if the // command is still running. func (widget *Widget) Refresh() { // Try to run the command. If the command is still running, let it keep // running and do a refresh instead. Otherwise, the widget will redraw when // the command completes. select { case widget.runChan <- true: default: widget.redrawChan <- true } } // String returns the string representation of the widget func (widget *Widget) String() string { args := strings.Join(widget.settings.args, " ") if args != "" { return fmt.Sprintf("%s %s", widget.settings.cmd, args) } return widget.settings.cmd } func (widget *Widget) Write(p []byte) (n int, err error) { widget.m.Lock() defer widget.m.Unlock() // Write the new data into the buffer n, err = widget.buffer.Write(p) // Remove lines that exceed maxLines lines := widget.countLines() if lines > widget.settings.maxLines { err = widget.drainLines(lines - widget.settings.maxLines) } return n, err } /* -------------------- Unexported Functions -------------------- */ // countLines counts the lines of data in the buffer func (widget *Widget) countLines() int { return bytes.Count(widget.buffer.Bytes(), []byte{'\n'}) } // drainLines removed the first n lines from the buffer func (widget *Widget) drainLines(n int) error { for i := 0; i < n; i++ { _, err := widget.buffer.ReadBytes('\n') if err != nil { return err } } return nil } func (widget *Widget) environment() []string { envs := os.Environ() envs = append( envs, fmt.Sprintf("WTF_WIDGET_WIDTH=%d", widget.settings.width), fmt.Sprintf("WTF_WIDGET_HEIGHT=%d", widget.settings.height), ) return envs } func runCommandLoop(widget *Widget) { // Run the command forever in a loop. Refresh() will put a value into the // channel to signal the loop to continue. for { <-widget.runChan widget.resetBuffer() cmd := exec.Command(widget.settings.cmd, widget.settings.args...) cmd.Env = widget.environment() cmd.Dir = widget.settings.workingDir var err error if widget.settings.pty { err = runCommandPty(widget, cmd) } else { err = runCommand(widget, cmd) } if err != nil { widget.handleError(err) } widget.redrawChan <- true } } func runCommand(widget *Widget, cmd *exec.Cmd) error { cmd.Stdout = widget return cmd.Run() } func runCommandPty(widget *Widget, cmd *exec.Cmd) error { f, err := pty.Start(cmd) // The command has exited, print any error messages if err != nil { if widget.settings.ptySuppressErrors { return cmd.Wait() } else { return err } } // Make sure to close the pty at the end. defer func() { _ = f.Close() }() // Best effort. // Handle pty size. ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGWINCH) go func() { for range ch { if err := pty.InheritSize(os.Stdin, f); err != nil { logger.Log(fmt.Sprintf("error resizing pty: %s", err)) } } }() ch <- syscall.SIGWINCH // Initial resize. defer func() { signal.Stop(ch); close(ch) }() // Cleanup signals when done. // Extract output _, err = io.Copy(widget.buffer, f) if err != nil { if widget.settings.ptySuppressErrors && errors.Is(err, syscall.EIO) { return cmd.Wait() } return err } return cmd.Wait() } func (widget *Widget) handleError(err error) { widget.m.Lock() defer widget.m.Unlock() _, writeErr := widget.buffer.WriteString(err.Error()) if writeErr != nil { return } } func redrawLoop(widget *Widget) { for { widget.Redraw(widget.content) if widget.settings.tail { widget.View.ScrollToEnd() } <-widget.redrawChan } } func (widget *Widget) content() (string, string, bool) { widget.m.Lock() result := widget.buffer.String() widget.m.Unlock() ansiTitle := tview.TranslateANSI(tview.Escape(widget.CommonSettings().Title)) if ansiTitle == defaultTitle { ansiTitle = tview.TranslateANSI(tview.Escape(widget.String())) } ansiResult := tview.TranslateANSI(tview.Escape(result)) return ansiTitle, ansiResult, false } func (widget *Widget) resetBuffer() { widget.m.Lock() defer widget.m.Unlock() widget.buffer.Reset() } ================================================ FILE: modules/cryptocurrency/bittrex/bittrex.go ================================================ package bittrex type summaryList struct { items []*bCurrency } // Base Currency type bCurrency struct { name string displayName string markets []*mCurrency } // Market Currency type mCurrency struct { name string summaryInfo } type summaryInfo struct { Low string High string Volume string Last string OpenSellOrders string OpenBuyOrders string } type summaryResponse struct { Success bool `json:"success"` Message string `json:"message"` Result []struct { MarketName string `json:"MarketName"` High float64 `json:"High"` Low float64 `json:"Low"` Last float64 `json:"Last"` Volume float64 `json:"Volume"` OpenSellOrders int `json:"OpenSellOrders"` OpenBuyOrders int `json:"OpenBuyOrders"` } `json:"result"` } func (list *summaryList) addSummaryItem(name, displayName string, marketList []*mCurrency) { list.items = append(list.items, &bCurrency{ name: name, displayName: displayName, markets: marketList, }) } ================================================ FILE: modules/cryptocurrency/bittrex/display.go ================================================ package bittrex import ( "bytes" "fmt" "text/template" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { if !ok { return widget.CommonSettings().Title, errorText, true } list := &widget.summaryList str := "" for _, baseCurrency := range list.items { str += fmt.Sprintf( " [%s]%s[%s] (%s)\n\n", widget.settings.base.displayName, baseCurrency.displayName, widget.settings.base.name, baseCurrency.name, ) resultTemplate := template.New("bittrex") for _, marketCurrency := range baseCurrency.markets { writer := new(bytes.Buffer) strTemplate, _ := resultTemplate.Parse( " [{{.nameColor}}]{{.mName}}\n" + formatableText("High", "High") + formatableText("Low", "Low") + formatableText("Last", "Last") + formatableText("Volume", "Volume") + "\n" + formatableText("Open Buy", "OpenBuyOrders") + formatableText("Open Sell", "OpenSellOrders"), ) err := strTemplate.Execute(writer, map[string]string{ "nameColor": widget.settings.market.name, "fieldColor": widget.settings.market.field, "valueColor": widget.settings.market.value, "mName": marketCurrency.name, "High": marketCurrency.High, "Low": marketCurrency.Low, "Last": marketCurrency.Last, "Volume": marketCurrency.Volume, "OpenBuyOrders": marketCurrency.OpenBuyOrders, "OpenSellOrders": marketCurrency.OpenSellOrders, }) if err != nil { str = err.Error() } else { str += writer.String() + "\n" } } } return widget.CommonSettings().Title, str, true } func formatableText(key, value string) string { return fmt.Sprintf("[{{.fieldColor}}]%12s: [{{.valueColor}}]{{.%s}}\n", key, value) } ================================================ FILE: modules/cryptocurrency/bittrex/settings.go ================================================ package bittrex import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Bittrex" ) type colors struct { base struct { name string displayName string } market struct { name string field string value string } } type currency struct { displayName string market []interface{} } type summary struct { currencies map[string]*currency } type Settings struct { *cfg.Common colors summary } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } settings.base.name = ymlConfig.UString("colors.base.name") settings.base.displayName = ymlConfig.UString("colors.base.displayName") settings.market.name = ymlConfig.UString("colors.market.name") settings.market.field = ymlConfig.UString("colors.market.field") settings.market.value = ymlConfig.UString("colors.market.value") settings.currencies = make(map[string]*currency) for key, val := range ymlConfig.UMap("summary") { coercedVal := val.(map[string]interface{}) currency := ¤cy{ displayName: coercedVal["displayName"].(string), market: coercedVal["market"].([]interface{}), } settings.currencies[key] = currency } settings.SetDocumentationPath("cryptocurrencies/bittrex") return &settings } ================================================ FILE: modules/cryptocurrency/bittrex/widget.go ================================================ package bittrex import ( "encoding/json" "fmt" "time" "net/http" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) const ( baseURL = "https://bittrex.com/api/v1.1/public/getmarketsummary" ) var ( errorText = "" ok = true ) // Widget define wtf widget to register widget later type Widget struct { view.TextWidget settings *Settings summaryList } // NewWidget Make new instance of widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, summaryList: summaryList{}, } ok = true errorText = "" widget.setSummaryList() return &widget } func (widget *Widget) setSummaryList() { for symbol, currency := range widget.settings.currencies { mCurrencyList := widget.makeSummaryMarketList(currency.market) widget.addSummaryItem(symbol, currency.displayName, mCurrencyList) } } func (widget *Widget) makeSummaryMarketList(market []interface{}) []*mCurrency { mCurrencyList := []*mCurrency{} for _, marketSymbol := range market { mCurrencyList = append(mCurrencyList, makeMarketCurrency(marketSymbol.(string))) } return mCurrencyList } func makeMarketCurrency(name string) *mCurrency { return &mCurrency{ name: name, summaryInfo: summaryInfo{ High: "", Low: "", Volume: "", Last: "", OpenBuyOrders: "", OpenSellOrders: "", }, } } /* -------------------- Exported Functions -------------------- */ // Refresh & update after interval time func (widget *Widget) Refresh() { widget.updateSummary() widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) updateSummary() { // In case if anything bad happened! defer func() { if r := recover(); r != nil { fmt.Println("recovered in updateSummary()", r) } }() client := &http.Client{ Timeout: 5 * time.Second, } for _, baseCurrency := range widget.items { for _, mCurrency := range baseCurrency.markets { request := makeRequest(baseCurrency.name, mCurrency.name) response, err := client.Do(request) ok = true errorText = "" if err != nil { ok = false errorText = "Please Check Your Internet Connection!" break } if response.StatusCode != http.StatusOK { errorText = response.Status ok = false break } defer func() { _ = response.Body.Close() }() jsonResponse := summaryResponse{} decoder := json.NewDecoder(response.Body) err = decoder.Decode(&jsonResponse) if err != nil { errorText = "Could not parse JSON!" break } if !jsonResponse.Success { ok = false errorText = fmt.Sprintf("%s-%s: %s", baseCurrency.name, mCurrency.name, jsonResponse.Message) break } ok = true errorText = "" mCurrency.Last = fmt.Sprintf("%f", jsonResponse.Result[0].Last) mCurrency.High = fmt.Sprintf("%f", jsonResponse.Result[0].High) mCurrency.Low = fmt.Sprintf("%f", jsonResponse.Result[0].Low) mCurrency.Volume = fmt.Sprintf("%f", jsonResponse.Result[0].Volume) mCurrency.OpenBuyOrders = fmt.Sprintf("%d", jsonResponse.Result[0].OpenBuyOrders) mCurrency.OpenSellOrders = fmt.Sprintf("%d", jsonResponse.Result[0].OpenSellOrders) } } widget.display() } func makeRequest(baseName, marketName string) *http.Request { url := fmt.Sprintf("%s?market=%s-%s", baseURL, baseName, marketName) request, _ := http.NewRequest("GET", url, http.NoBody) return request } ================================================ FILE: modules/cryptocurrency/blockfolio/settings.go ================================================ package blockfolio import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Blockfolio" ) type colors struct { name string grows string drop string } type Settings struct { *cfg.Common colors deviceToken string displayHoldings bool } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), deviceToken: ymlConfig.UString("device_token"), displayHoldings: ymlConfig.UBool("displayHoldings", true), } settings.SetDocumentationPath("cryptocurrencies/blockfolio") return &settings } ================================================ FILE: modules/cryptocurrency/blockfolio/widget.go ================================================ package blockfolio import ( "encoding/json" "fmt" "io" "log" "net/http" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget device_token string settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), device_token: settings.deviceToken, settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { positions, err := Fetch(widget.device_token) title := widget.CommonSettings().Title if err != nil { return title, err.Error(), true } res := "" totalFiat := float32(0.0) for i := 0; i < len(positions.PositionList); i++ { colorForChange := widget.settings.grows if positions.PositionList[i].TwentyFourHourPercentChangeFiat <= 0 { colorForChange = widget.settings.drop } totalFiat += positions.PositionList[i].HoldingValueFiat if widget.settings.displayHoldings { res += fmt.Sprintf( "[%s]%-6s - %5.2f ([%s]%.3fk [%s]%.2f%s)\n", widget.settings.name, positions.PositionList[i].Coin, positions.PositionList[i].Quantity, colorForChange, positions.PositionList[i].HoldingValueFiat/1000, colorForChange, positions.PositionList[i].TwentyFourHourPercentChangeFiat, "%", ) } else { res += fmt.Sprintf( "[%s]%-6s - %5.2f ([%s]%.2f%s)\n", widget.settings.name, positions.PositionList[i].Coin, positions.PositionList[i].Quantity, colorForChange, positions.PositionList[i].TwentyFourHourPercentChangeFiat, "%", ) } } if widget.settings.displayHoldings { res += fmt.Sprintf("\n[%s]Total value: $%.3fk", "green", totalFiat/1000) } return title, res, true } // always the same const magic = "edtopjhgn2345piuty89whqejfiobh89-2q453" type Position struct { Coin string `json:"coin"` LastPriceFiat float32 `json:"lastPriceFiat"` TwentyFourHourPercentChangeFiat float32 `json:"twentyFourHourPercentChangeFiat"` Quantity float32 `json:"quantity"` HoldingValueFiat float32 `json:"holdingValueFiat"` } type AllPositionsResponse struct { PositionList []Position `json:"positionList"` } func MakeApiRequest(token string, method string) ([]byte, error) { client := &http.Client{} url := "https://api-v0.blockfolio.com/rest/" + method + "/" + token + "?use_alias=true&fiat_currency=USD" req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } req.Header.Add("magic", magic) resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, err } func GetAllPositions(token string) (*AllPositionsResponse, error) { jsn, _ := MakeApiRequest(token, "get_all_positions") var parsed AllPositionsResponse err := json.Unmarshal(jsn, &parsed) if err != nil { log.Fatalf("Failed to parse json %v", err) return nil, err } return &parsed, err } func Fetch(token string) (*AllPositionsResponse, error) { return GetAllPositions(token) } ================================================ FILE: modules/cryptocurrency/cryptolive/price/price.go ================================================ package price type list struct { items []*fromCurrency } type fromCurrency struct { name string displayName string to []*toCurrency } type toCurrency struct { name string price float32 } type cResponse map[string]float32 /* -------------------- Unexported Functions -------------------- */ func (l *list) addItem(name string, displayName string, to []*toCurrency) { l.items = append(l.items, &fromCurrency{ name: name, displayName: displayName, to: to, }) } ================================================ FILE: modules/cryptocurrency/cryptolive/price/settings.go ================================================ package price import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "CryptoLive" ) type colors struct { from struct { name string displayName string } to struct { name string price string } top struct { from struct { name string displayName string } to struct { name string field string value string } } } type currency struct { displayName string to []interface{} } type Settings struct { *cfg.Common colors currencies map[string]*currency } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } settings.from.name = ymlConfig.UString("colors.from.name") settings.from.displayName = ymlConfig.UString("colors.from.displayName") settings.to.name = ymlConfig.UString("colors.to.name") settings.to.price = ymlConfig.UString("colors.to.price") settings.top.from.name = ymlConfig.UString("colors.top.from.name") settings.top.from.displayName = ymlConfig.UString("colors.top.from.displayName") settings.top.to.name = ymlConfig.UString("colors.top.to.name") settings.top.to.field = ymlConfig.UString("colors.top.to.field") settings.top.to.value = ymlConfig.UString("colors.top.to.value") settings.currencies = make(map[string]*currency) for key, val := range ymlConfig.UMap("currencies") { coercedVal := val.(map[string]interface{}) currency := ¤cy{ displayName: coercedVal["displayName"].(string), to: coercedVal["to"].([]interface{}), } settings.currencies[key] = currency } return &settings } ================================================ FILE: modules/cryptocurrency/cryptolive/price/widget.go ================================================ package price import ( "encoding/json" "fmt" "net/http" "sync" "time" ) var baseURL = "https://min-api.cryptocompare.com/data/price" var ok = true // Widget define wtf widget to register widget later type Widget struct { *list settings *Settings Result string RefreshInterval time.Duration } // NewWidget Make new instance of widget func NewWidget(settings *Settings) *Widget { widget := Widget{ settings: settings, } widget.setList() return &widget } func (widget *Widget) setList() { widget.list = &list{} for symbol, currency := range widget.settings.currencies { toList := widget.getToList(symbol) widget.addItem(symbol, currency.displayName, toList) } } /* -------------------- Exported Functions -------------------- */ // Refresh & update after interval time func (widget *Widget) Refresh(wg *sync.WaitGroup) { if len(widget.items) != 0 { widget.updateCurrencies() if !ok { widget.Result = "Please check your internet connection" } else { widget.display() } } wg.Done() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) display() { str := "" for _, item := range widget.items { str += fmt.Sprintf( " [%s]%s[%s] (%s)\n", widget.settings.from.name, item.displayName, widget.settings.from.name, item.name, ) for _, toItem := range item.to { str += fmt.Sprintf( "\t[%s]%s: [%s]%f\n", widget.settings.to.name, toItem.name, widget.settings.to.price, toItem.price, ) } str += "\n" } widget.Result = fmt.Sprintf("\n%s", str) } func (widget *Widget) getToList(symbol string) []*toCurrency { var toList []*toCurrency for _, to := range widget.settings.currencies[symbol].to { toList = append(toList, &toCurrency{ name: to.(string), price: 0, }) } return toList } func (widget *Widget) updateCurrencies() { defer func() { if r := recover(); r != nil { fmt.Println("recovered in updateSummary()", r) } }() for _, fromCurrency := range widget.items { var ( client http.Client jsonResponse cResponse ) client = http.Client{ Timeout: 5 * time.Second, } request := makeRequest(fromCurrency) response, err := client.Do(request) if err != nil { ok = false } else { ok = true } defer func() { _ = response.Body.Close() }() _ = json.NewDecoder(response.Body).Decode(&jsonResponse) setPrices(&jsonResponse, fromCurrency) } } func makeRequest(currency *fromCurrency) *http.Request { tsyms := "" for _, to := range currency.to { tsyms += fmt.Sprintf("%s,", to.name) } url := fmt.Sprintf("%s?fsym=%s&tsyms=%s", baseURL, currency.name, tsyms) request, _ := http.NewRequest("GET", url, http.NoBody) return request } func setPrices(response *cResponse, currencry *fromCurrency) { for idx, toCurrency := range currencry.to { currencry.to[idx].price = (*response)[toCurrency.name] } } ================================================ FILE: modules/cryptocurrency/cryptolive/settings.go ================================================ package cryptolive import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/modules/cryptocurrency/cryptolive/price" "github.com/wtfutil/wtf/modules/cryptocurrency/cryptolive/toplist" ) const ( defaultFocusable = false defaultTitle = "CryptolLive" ) type colors struct { from struct { name string displayName string } to struct { name string price string } top struct { from struct { name string displayName string } to struct { name string field string value string } } } type Settings struct { *cfg.Common colors currencies map[string]interface{} top map[string]interface{} priceSettings *price.Settings toplistSettings *toplist.Settings } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { currencies, _ := ymlConfig.Map("currencies") top, _ := ymlConfig.Map("top") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), currencies: currencies, top: top, priceSettings: price.NewSettingsFromYAML(name, ymlConfig, globalConfig), toplistSettings: toplist.NewSettingsFromYAML(name, ymlConfig, globalConfig), } settings.from.name = ymlConfig.UString("colors.from.name") settings.from.displayName = ymlConfig.UString("colors.from.displayName") settings.to.name = ymlConfig.UString("colors.to.name") settings.to.price = ymlConfig.UString("colors.to.price") settings.colors.top.from.name = ymlConfig.UString("colors.top.from.name") settings.colors.top.from.displayName = ymlConfig.UString("colors.top.from.displayName") settings.colors.top.to.name = ymlConfig.UString("colors.top.to.name") settings.colors.top.to.field = ymlConfig.UString("colors.top.to.field") settings.colors.top.to.value = ymlConfig.UString("colors.top.to.value") settings.SetDocumentationPath("cryptocurrencies/cryptolive") return &settings } ================================================ FILE: modules/cryptocurrency/cryptolive/toplist/display.go ================================================ package toplist import "fmt" func (widget *Widget) display() { str := "" for _, fromCurrency := range widget.list.items { str += fmt.Sprintf( "[%s]%s [%s](%s)\n", widget.settings.from.displayName, fromCurrency.displayName, widget.settings.from.name, fromCurrency.name, ) str += widget.makeToListText(fromCurrency.to) } widget.Result = str } func (widget *Widget) makeToListText(toList []*tCurrency) string { str := "" for _, toCurrency := range toList { str += widget.makeToText(toCurrency) } return str } func (widget *Widget) makeToText(toCurrency *tCurrency) string { str := "" str += fmt.Sprintf( " [%s]%s\n", widget.settings.to.name, toCurrency.name, ) for _, info := range toCurrency.info { str += widget.makeInfoText(info) str += "\n\n" } return str } func (widget *Widget) makeInfoText(info tInfo) string { return fmt.Sprintf( " [%s]Exchange: [%s]%s\n", widget.settings.colors.top.to.field, widget.settings.colors.top.to.value, info.exchange, ) + fmt.Sprintf( " [%s]Volume(24h): [%s]%f-[%s]%f", widget.settings.colors.top.to.field, widget.settings.colors.top.to.value, info.volume24h, widget.settings.colors.top.to.value, info.volume24hTo, ) } ================================================ FILE: modules/cryptocurrency/cryptolive/toplist/settings.go ================================================ package toplist import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "CryptoLive" ) type colors struct { from struct { name string displayName string } to struct { name string price string } top struct { from struct { name string displayName string } to struct { name string field string value string } } } type currency struct { displayName string limit int to []interface{} } type Settings struct { *cfg.Common colors top map[string]*currency } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } settings.from.name = ymlConfig.UString("colors.from.name") settings.from.displayName = ymlConfig.UString("colors.from.displayName") settings.to.name = ymlConfig.UString("colors.to.name") settings.to.price = ymlConfig.UString("colors.to.price") settings.colors.top.from.name = ymlConfig.UString("colors.top.from.name") settings.colors.top.from.displayName = ymlConfig.UString("colors.top.from.displayName") settings.colors.top.to.name = ymlConfig.UString("colors.top.to.name") settings.colors.top.to.field = ymlConfig.UString("colors.top.to.field") settings.colors.top.to.value = ymlConfig.UString("colors.top.to.value") settings.top = make(map[string]*currency) for key, val := range ymlConfig.UMap("top") { coercedVal := val.(map[string]interface{}) limit, _ := coercedVal["limit"].(int) currency := ¤cy{ displayName: coercedVal["displayName"].(string), limit: limit, to: coercedVal["to"].([]interface{}), } settings.top[key] = currency } return &settings } ================================================ FILE: modules/cryptocurrency/cryptolive/toplist/toplist.go ================================================ package toplist type cList struct { items []*fCurrency } type fCurrency struct { name, displayName string limit int to []*tCurrency } type tCurrency struct { name string info []tInfo } type tInfo struct { exchange string volume24h, volume24hTo float32 } type responseInterface struct { Response string `json:"Response"` Data []struct { Exchange string `json:"exchange"` FromSymbol string `json:"fromSymbol"` ToSymbol string `json:"toSymbol"` Volume24h float32 `json:"volume24h"` Volume24hTo float32 `json:"volume24hTo"` } `json:"Data"` } func (list *cList) addItem(name, displayName string, limit int, to []*tCurrency) { list.items = append(list.items, &fCurrency{ name: name, displayName: displayName, limit: limit, to: to, }) } ================================================ FILE: modules/cryptocurrency/cryptolive/toplist/widget.go ================================================ package toplist import ( "encoding/json" "fmt" "net/http" "os" "sync" "time" ) var baseURL = "https://min-api.cryptocompare.com/data/top/exchanges" // Widget Toplist Widget type Widget struct { Result string RefreshInterval time.Duration list *cList settings *Settings } // NewWidget Make new toplist widget func NewWidget(settings *Settings) *Widget { widget := Widget{ settings: settings, } widget.list = &cList{} widget.setList() return &widget } func (widget *Widget) setList() { for symbol, currency := range widget.settings.top { toList := widget.makeToList(symbol, currency.limit) widget.list.addItem(symbol, currency.displayName, currency.limit, toList) } } func (widget *Widget) makeToList(symbol string, limit int) (list []*tCurrency) { for _, to := range widget.settings.top[symbol].to { list = append(list, &tCurrency{ name: to.(string), info: make([]tInfo, limit), }) } return } /* -------------------- Exported Functions -------------------- */ // Refresh & update after interval time func (widget *Widget) Refresh(wg *sync.WaitGroup) { if len(widget.list.items) != 0 { widget.updateData() widget.display() } wg.Done() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) updateData() { defer func() { if r := recover(); r != nil { fmt.Println("recovered in updateSummary()", r) } }() client := &http.Client{ Timeout: 5 * time.Second, } for _, fromCurrency := range widget.list.items { for _, toCurrency := range fromCurrency.to { request := makeRequest(fromCurrency.name, toCurrency.name, fromCurrency.limit) response, _ := client.Do(request) var jsonResponse responseInterface err := json.NewDecoder(response.Body).Decode(&jsonResponse) if err != nil { os.Exit(1) } for idx, info := range jsonResponse.Data { toCurrency.info[idx] = tInfo{ exchange: info.Exchange, volume24h: info.Volume24h, volume24hTo: info.Volume24hTo, } } } } } func makeRequest(fsym, tsym string, limit int) *http.Request { url := fmt.Sprintf("%s?fsym=%s&tsym=%s&limit=%d", baseURL, fsym, tsym, limit) request, _ := http.NewRequest("GET", url, http.NoBody) return request } ================================================ FILE: modules/cryptocurrency/cryptolive/widget.go ================================================ package cryptolive import ( "fmt" "sync" "github.com/rivo/tview" "github.com/wtfutil/wtf/modules/cryptocurrency/cryptolive/price" "github.com/wtfutil/wtf/modules/cryptocurrency/cryptolive/toplist" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.TextWidget priceWidget *price.Widget toplistWidget *toplist.Widget settings *Settings } // NewWidget Make new instance of widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), priceWidget: price.NewWidget(settings.priceSettings), toplistWidget: toplist.NewWidget(settings.toplistSettings), settings: settings, } widget.priceWidget.RefreshInterval = widget.RefreshInterval() widget.toplistWidget.RefreshInterval = widget.RefreshInterval() return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh & update after interval time func (widget *Widget) Refresh() { var wg sync.WaitGroup wg.Add(2) widget.priceWidget.Refresh(&wg) widget.toplistWidget.Refresh(&wg) wg.Wait() widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { str := "" str += widget.priceWidget.Result str += widget.toplistWidget.Result return widget.CommonSettings().Title, fmt.Sprintf("\n%s", str), false } ================================================ FILE: modules/cryptocurrency/mempool/settings.go ================================================ package mempool import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "mempool" ) // Settings defines the configuration properties for this module type Settings struct { common *cfg.Common // Define your settings attributes here } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), // Configure your settings attributes here. See http://github.com/olebedev/config for type details } return &settings } ================================================ FILE: modules/cryptocurrency/mempool/widget.go ================================================ package mempool import ( "fmt" "net/http" "github.com/rivo/tview" "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common), settings: settings, } return &widget } type feeStruct struct { FastFee int `json:"fastestFee"` HalfHourFee int `json:"halfHourFee"` HourFee int `json:"hourFee"` EcoFee int `json:"economyFee"` } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { return getBTCTxFees() } func getBTCTxFees() string { url := "https://mempool.space/api/v1/fees/recommended" resp, err := http.Get(url) if err != nil { logger.Log(fmt.Sprintf("[mempool] Error: Failed to make request to mempool. Reason: %s", err)) return "[mempool] error callng mempool API" } defer resp.Body.Close() parsed := feeStruct{} err = utils.ParseJSON(&parsed, resp.Body) if err != nil { logger.Log(fmt.Sprintf("[mempool] Error: Failed to decode JSON data from mempool. Reason: %s", err)) return "[mempool] error parsing JSON from mempool API" } finalStr := "" finalStr += fmt.Sprintf("%-7s %2d sat/vB\n", "Fast", parsed.FastFee) finalStr += fmt.Sprintf("%-7s %2d sat/vB\n", "30 min", parsed.HalfHourFee) finalStr += fmt.Sprintf("%-7s %2d sat/vB\n", "60 min", parsed.HourFee) finalStr += fmt.Sprintf("%-7s %2d sat/vB\n", "Eco", parsed.EcoFee) return finalStr } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } ================================================ FILE: modules/datadog/client.go ================================================ package datadog import ( "github.com/wtfutil/wtf/utils" datadog "github.com/zorkian/go-datadog-api" ) // Monitors returns a list of Datadog monitors func (widget *Widget) Monitors() ([]datadog.Monitor, error) { client := datadog.NewClient( widget.settings.apiKey, widget.settings.applicationKey, ) tags := utils.ToStrs(widget.settings.tags) monitors, err := client.GetMonitorsByTags(tags) if err != nil { return nil, err } return monitors, nil } ================================================ FILE: modules/datadog/keyboard.go ================================================ package datadog import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openItem, "Open item in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyEnter, widget.openItem, "Open item in browser") } ================================================ FILE: modules/datadog/settings.go ================================================ package datadog import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "DataDog" ) type Settings struct { *cfg.Common apiKey string `help:"Your Datadog API key."` applicationKey string `help:"Your Datadog Application key."` tags []interface{} `help:"Array of tags you want to query monitors by."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_DATADOG_API_KEY"))), applicationKey: ymlConfig.UString("applicationKey", os.Getenv("WTF_DATADOG_APPLICATION_KEY")), tags: ymlConfig.UList("monitors.tags"), } cfg.ModuleSecret(name+"-api", globalConfig, &settings.apiKey).Load() cfg.ModuleSecret(name+"-app", globalConfig, &settings.applicationKey).Load() return &settings } ================================================ FILE: modules/datadog/widget.go ================================================ package datadog import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" datadog "github.com/zorkian/go-datadog-api" ) type Widget struct { view.ScrollableWidget monitors []datadog.Monitor settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.err = nil monitors, monitorErr := widget.Monitors() if monitorErr != nil { widget.monitors = nil widget.err = monitorErr widget.SetItemCount(0) widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, monitorErr.Error(), true }) return } triggeredMonitors := []datadog.Monitor{} for _, monitor := range monitors { state := *monitor.OverallState if state == "Alert" { triggeredMonitors = append(triggeredMonitors, monitor) } } widget.monitors = triggeredMonitors widget.SetItemCount(len(widget.monitors)) widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { triggeredMonitors := widget.monitors var str string title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } if len(triggeredMonitors) > 0 { str += fmt.Sprintf( " %s\n", fmt.Sprintf( "[%s]Triggered Monitors[white]", widget.settings.Colors.Subheading, ), ) for idx, triggeredMonitor := range triggeredMonitors { row := fmt.Sprintf(`[%s][red] %s[%s]`, widget.RowColor(idx), *triggeredMonitor.Name, widget.RowColor(idx), ) str += utils.HighlightableHelper(widget.View, row, idx, len(*triggeredMonitor.Name)) } } else { str += fmt.Sprintf( " %s\n", "[green]No Triggered Monitors[white]", ) } return title, str, false } func (widget *Widget) openItem() { sel := widget.GetSelected() if sel >= 0 && widget.monitors != nil && sel < len(widget.monitors) { item := &widget.monitors[sel] utils.OpenFile(fmt.Sprintf("https://app.datadoghq.com/monitors/%d?q=*", *item.Id)) } } ================================================ FILE: modules/devto/keyboard.go ================================================ package devto import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("d", widget.Next, "Select next item") widget.SetKeyboardChar("a", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openStory, "Open story in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openStory, "Open story in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/devto/settings.go ================================================ package devto import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "dev.to | News Feed" ) // Settings defines the configuration options for this module type Settings struct { *cfg.Common numberOfArticles int `help:"Number of stories to show. Default is 10" optional:"true"` contentTag string `help:"List articles from a specific tag. Default is empty" optional:"true"` contentUsername string `help:"List articles from a specific user. Default is empty" optional:"true"` contentState string `help:"Order the feed by fresh/rising. Default is rising" optional:"true"` } // NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated func NewSettingsFromYAML(name string, yamlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, yamlConfig, globalConfig), numberOfArticles: yamlConfig.UInt("numberOfArticles", 10), contentTag: yamlConfig.UString("contentTag", ""), contentUsername: yamlConfig.UString("contentUsername", ""), contentState: yamlConfig.UString("contentState", ""), } return &settings } ================================================ FILE: modules/devto/widget.go ================================================ package devto import ( "context" "fmt" "github.com/VictorAvelar/devto-api-go/devto" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget articles []devto.ListedArticle settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.View.SetScrollable(true) widget.initializeKeyboardControls() return widget } func (widget *Widget) Refresh() { if widget.Disabled() { return } ctx := context.Background() wCfg, _ := devto.NewConfig(false, "") c, _ := devto.NewClient(ctx, wCfg, nil, devto.BaseURL) options := devto.ArticleListOptions{ Tags: widget.settings.contentTag, Username: widget.settings.contentUsername, State: widget.settings.contentState, } articles, err := c.Articles.List(ctx, options) if err != nil { widget.err = err widget.articles = nil widget.SetItemCount(0) } else { var displayArticles []devto.ListedArticle var l int if len(articles) < widget.settings.numberOfArticles { l = len(articles) } else { l = widget.settings.numberOfArticles - 1 } for i, art := range articles { if i > l { break } displayArticles = append(displayArticles, art) } widget.articles = displayArticles widget.SetItemCount(len(displayArticles)) } widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - %s stories", widget.CommonSettings().Title, widget.settings.contentTag) if widget.err != nil { return title, widget.err.Error(), true } articles := widget.articles if len(articles) == 0 { return title, "No stories to display", false } var str string for idx, article := range articles { row := fmt.Sprintf( `[%s]%2d. %s [lightblue](%s)[white]`, widget.RowColor(idx), idx+1, article.Title, article.User.Username, ) str += utils.HighlightableHelper(widget.View, row, idx, len(article.Title)) } return title, str, false } func (widget *Widget) openStory() { sel := widget.GetSelected() if sel >= 0 && widget.articles != nil && sel < len(widget.articles) { article := &widget.articles[sel] utils.OpenFile(article.URL.String()) } } ================================================ FILE: modules/digitalclock/clocks.go ================================================ package digitalclock import ( "fmt" "strconv" "time" ) // AM defines the AM string format const AM = "A" // PM defines the PM string format const PM = "P" const minRowsForBorder = 3 // Converts integer to string along with makes sure the length of string is > 2 func intStrConv(val int) string { valStr := strconv.Itoa(val) if len(valStr) < 2 { valStr = "0" + valStr } return valStr } // Returns Hour + minute + AM/PM information based on the settings func getHourMinute(hourFormat string) string { strHours := intStrConv(time.Now().Hour()) AMPM := " " if hourFormat == "12" { hour := time.Now().Hour() strHours = intStrConv(hour % 12) if (hour % 12) == hour { AMPM = AM } else { AMPM = PM } } strMinutes := intStrConv(time.Now().Minute()) strMinutes += AMPM return strHours + getColon() + strMinutes } // Returns the : with blinking based on the seconds func getColon() string { if time.Now().Second()%2 == 0 { return ":" } return " " } func getDate(dateFormat string, withDatePrefix bool) string { if withDatePrefix { return fmt.Sprintf("Date: %s", time.Now().Format(dateFormat)) } return time.Now().Format(dateFormat) } func getUTC() string { return fmt.Sprintf("UTC: %s", time.Now().UTC().Format(time.RFC3339)) } func getEpoch() string { return fmt.Sprintf("Epoch: %d", time.Now().Unix()) } // Renders the clock as string by accessing appropriate font from configured in settings func renderClock(widgetSettings Settings) (string, bool) { var digFont ClockFont clockTime := getHourMinute(widgetSettings.hourFormat) digFont = getFont(widgetSettings) chars := [][]string{} for _, char := range clockTime { chars = append(chars, digFont.get(string(char))) } needBorder := digFont.fontRows <= minRowsForBorder return fontsJoin(chars, digFont.fontRows, widgetSettings.color), needBorder } ================================================ FILE: modules/digitalclock/display.go ================================================ package digitalclock import "strings" func mergeLines(outString []string) string { return strings.Join(outString, "\n") } func renderWidget(widgetSettings Settings) string { var outputStrings []string clockString, needBorder := renderClock(widgetSettings) if needBorder { outputStrings = append(outputStrings, mergeLines([]string{"", clockString, ""})) } else { outputStrings = append(outputStrings, clockString) } if widgetSettings.withDate { outputStrings = append(outputStrings, getDate(widgetSettings.dateFormat, widgetSettings.withDatePrefix)) } if widgetSettings.withUTC { outputStrings = append(outputStrings, getUTC()) } if widgetSettings.withEpoch { outputStrings = append(outputStrings, getEpoch()) } return mergeLines(outputStrings) } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { title := widget.CommonSettings().Title if widget.settings.dateTitle { title = getDate(widget.settings.dateFormat, false) } return title, renderWidget(*widget.settings), false }) } ================================================ FILE: modules/digitalclock/fonts.go ================================================ package digitalclock import ( "fmt" "strings" ) // ClockFontInterface to makes sure all fonts implement join and get methods type ClockFontInterface interface { join() string get() string } // ClockFont struct to hold the font info type ClockFont struct { fontRows int fonts map[string][]string } // function to join fonts, since the fonts have multi rows func fontsJoin(fontCharArray [][]string, rows int, color string) string { outString := "" for i := 0; i < rows; i++ { outString += fmt.Sprintf("[%s]", color) for _, charFont := range fontCharArray { outString += " " + fmt.Sprintf("[%s]%s", color, charFont[i]) } outString += "\n" } return strings.TrimSuffix(outString, "\n") } func (font *ClockFont) get(char string) []string { return font.fonts[char] } func getDigitalFont() ClockFont { fontsMap := map[string][]string{ "1": {"▄█ ", " █ ", "▄█▄"}, "2": {"█▀█", " ▄▀", "█▄▄"}, "3": {"█▀▀█", " ▀▄", "█▄▄█"}, "4": {" █▀█ ", "█▄▄█▄", " █ "}, "5": {"█▀▀", "▀▀▄", "▄▄▀"}, "6": {"▄▀▀▄", "█▄▄ ", "▀▄▄▀"}, "7": {"▀▀▀█", " █ ", " ▐▌ "}, "8": {"▄▀▀▄", "▄▀▀▄", "▀▄▄▀"}, "9": {"▄▀▀▄", "▀▄▄█", " ▄▄▀"}, "0": {"█▀▀█", "█ █", "█▄▄█"}, ":": {"█", " ", "█"}, " ": {" ", " ", " "}, "A": {"", "", "AM"}, "P": {"", "", "PM"}, } digitalFont := ClockFont{fontRows: 3, fonts: fontsMap} return digitalFont } func getBigFont() ClockFont { fontsMap := map[string][]string{ "1": {" ┏┓ ", "┏┛┃ ", "┗┓┃ ", " ┃┃ ", "┏┛┗┓", "┗━━┛"}, "2": {"┏━━━┓", "┃┏━┓┃", "┗┛┏┛┃", "┏━┛┏┛", "┃ ┗━┓", "┗━━━┛"}, "3": {"┏━━━┓", "┃┏━┓┃", "┗┛┏┛┃", "┏┓┗┓┃", "┃┗━┛┃", "┗━━━┛"}, "4": {"┏┓ ┏┓", "┃┃ ┃┃", "┃┗━┛┃", "┗━━┓┃", " ┃┃", " ┗┛"}, "5": {"┏━━━┓", "┃┏━━┛", "┃┗━━┓", "┗━━┓┃", "┏━━┛┃", "┗━━━┛"}, "6": {"┏━━━┓", "┃┏━━┛", "┃┗━━┓", "┃┏━┓┃", "┃┗━┛┃", "┗━━━┛"}, "7": {"┏━━━┓", "┃┏━┓┃", "┗┛┏┛┃", " ┃┏┛", " ┃┃ ", " ┗┛ "}, "8": {"┏━━━┓", "┃┏━┓┃", "┃┗━┛┃", "┃┏━┓┃", "┃┗━┛┃", "┗━━━┛"}, "9": {"┏━━━┓", "┃┏━┓┃", "┃┗━┛┃", "┗━━┓┃", "┏━━┛┃", "┗━━━┛"}, "0": {"┏━━━┓", "┃┏━┓┃", "┃┃ ┃┃", "┃┃ ┃┃", "┃┗━┛┃", "┗━━━┛"}, ":": {" ", "┏━┓", "┗━┛", "┏━┓", "┗━┛", " "}, " ": {" ", " ", " ", " ", " ", " "}, "A": {"", "", "", "", "", "AM"}, "P": {"", "", "", "", "", "PM"}, } bigFont := ClockFont{fontRows: 6, fonts: fontsMap} return bigFont } func getBoldFont() ClockFont { fontsMap := map[string][]string{ "1": {"██", "██", "██", "██", "██"}, "2": {"██████", " ██", "██████", "██ ", "██████"}, "3": {"██████", " ██", "██████", " ██", "██████"}, "4": {"██ ██", "██ ██", "██████", " ██", " ██"}, "5": {"██████", "██ ", "██████", " ██", "██████"}, "6": {"██████", "██ ", "██████", "██ ██", "██████"}, "7": {"██████", " ██", " ██", " ██", " ██"}, "8": {"██████", "██ ██", "██████", "██ ██", "██████"}, "9": {"██████", "██ ██", "██████", " ██", "██████"}, "0": {"██████", "██ ██", "██ ██", "██ ██", "██████"}, ":": {" ", "██", " ", "██", " "}, " ": {" ", " ", " ", " ", " "}, "A": {"", "", "", "", "AM"}, "P": {"", "", "", "", "PM"}, } boldFont := ClockFont{fontRows: 5, fonts: fontsMap} return boldFont } // getFont returns appropriate font map based on the font settings func getFont(widgetSettings Settings) ClockFont { switch strings.ToLower(widgetSettings.font) { case "digitalfont": return getDigitalFont() case "boldfont": return getBoldFont() default: return getBigFont() } } ================================================ FILE: modules/digitalclock/settings.go ================================================ package digitalclock import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Clocks" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common color string `help:"The color of the clock."` font string `help:"The font of the clock." values:"bigfont or digitalfont"` hourFormat string `help:"The format of the clock." values:"12 or 24"` dateFormat string `help:"The format of the date."` dateTitle bool `help:"Whether or not to display date as widget title"` withDate bool `help:"Whether or not to display date information"` withUTC bool `help:"Whether or not to display UTC information"` withEpoch bool `help:"Whether or not to display Epoch information"` withDatePrefix bool `help:"Whether or not to display Date: prefix"` centerAlign bool `help:"Whether or not to use center align in widget"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), color: ymlConfig.UString("color"), font: ymlConfig.UString("font"), hourFormat: ymlConfig.UString("hourFormat", "24"), dateFormat: ymlConfig.UString("dateFormat", "Monday January 02 2006"), dateTitle: ymlConfig.UBool("dateTitle", false), withDate: ymlConfig.UBool("withDate", true), withUTC: ymlConfig.UBool("withUTC", true), withEpoch: ymlConfig.UBool("withEpoch", true), withDatePrefix: ymlConfig.UBool("withDatePrefix", true), centerAlign: ymlConfig.UBool("centerAlign", false), } return &settings } ================================================ FILE: modules/digitalclock/widget.go ================================================ package digitalclock import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is a text widget struct to hold info about the current widget type Widget struct { view.TextWidget settings *Settings } // NewWidget creates a new widget using settings func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } if settings.centerAlign { widget.View.SetTextAlign(tview.AlignCenter) } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { widget.display() } ================================================ FILE: modules/digitalocean/display.go ================================================ package digitalocean import ( "fmt" "github.com/wtfutil/wtf/utils" ) const maxColWidth = 12 func (widget *Widget) content() (string, string, bool) { columnSet := widget.settings.columns title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } if len(columnSet) < 1 { return title, " no columns defined", false } str := fmt.Sprintf(" [::b][%s]", widget.settings.Colors.Subheading) for _, colName := range columnSet { truncName := utils.Truncate(colName, maxColWidth, false) str += fmt.Sprintf("%-12s", truncName) } str += "\n" for idx, droplet := range widget.droplets { // This defines the formatting for the row, one tab-separated string for each defined column fmtStr := " [%s]" for range columnSet { fmtStr += "%-12s" } vals := []interface{}{ widget.RowColor(idx), } // Dynamically access the droplet to get the requested columns values for _, colName := range columnSet { val, err := droplet.StringValueForProperty(colName) if err != nil { val = "???" } truncVal := utils.Truncate(val, maxColWidth, false) vals = append(vals, truncVal) } // And format, print, and color the row row := fmt.Sprintf(fmtStr, vals...) str += utils.HighlightableHelper(widget.View, row, idx, 33) } return title, str, false } func (widget *Widget) display() { widget.Redraw(widget.content) } ================================================ FILE: modules/digitalocean/droplet.go ================================================ package digitalocean import ( "strings" "github.com/digitalocean/godo" "github.com/wtfutil/wtf/utils" ) // Droplet represents WTF's view of a DigitalOcean droplet type Droplet struct { godo.Droplet Image godo.Image Region godo.Region } // NewDroplet creates and returns an instance of Droplet func NewDroplet(doDroplet godo.Droplet) *Droplet { return &Droplet{ doDroplet, *doDroplet.Image, *doDroplet.Region, } } /* -------------------- Exported Functions -------------------- */ // StringValueForProperty returns a string value for the given column func (drop *Droplet) StringValueForProperty(propName string) (string, error) { // Figure out if we should forward this property to a sub-object // Lets us support "Region.Name" column definitions split := strings.Split(propName, ".") switch split[0] { case "Image": return utils.StringValueForProperty(drop.Image, split[1]) case "Region": return utils.StringValueForProperty(drop.Region, split[1]) default: return utils.StringValueForProperty(drop, propName) } } ================================================ FILE: modules/digitalocean/droplet_properties_table.go ================================================ package digitalocean import ( "fmt" "strconv" "strings" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type dropletPropertiesTable struct { droplet *Droplet propertyMap map[string]string colWidth0 int colWidth1 int tableHeight int } // newDropletPropertiesTable creates and returns an instance of DropletPropertiesTable func newDropletPropertiesTable(droplet *Droplet) *dropletPropertiesTable { propTable := &dropletPropertiesTable{ droplet: droplet, colWidth0: 24, colWidth1: 47, tableHeight: 16, } propTable.propertyMap = propTable.buildPropertyMap() return propTable } /* -------------------- Unexported Functions -------------------- */ // buildPropertyMap creates a mapping of droplet property names to droplet property values func (propTable *dropletPropertiesTable) buildPropertyMap() map[string]string { propMap := map[string]string{} if propTable.droplet == nil { return propMap } publicV4, _ := propTable.droplet.PublicIPv4() publicV6, _ := propTable.droplet.PublicIPv6() propMap["CPUs"] = strconv.Itoa(propTable.droplet.Vcpus) propMap["Created"] = propTable.droplet.Created propMap["Disk"] = strconv.Itoa(propTable.droplet.Disk) propMap["Features"] = utils.Truncate(strings.Join(propTable.droplet.Features, ","), propTable.colWidth1, true) propMap["Image"] = fmt.Sprintf("%s (%s)", propTable.droplet.Image.Name, propTable.droplet.Image.Distribution) propMap["Memory"] = strconv.Itoa(propTable.droplet.Memory) propMap["Public IP v4"] = publicV4 propMap["Public IP v6"] = publicV6 propMap["Region"] = fmt.Sprintf("%s (%s)", propTable.droplet.Region.Name, propTable.droplet.Region.Slug) propMap["Size"] = propTable.droplet.SizeSlug propMap["Status"] = propTable.droplet.Status propMap["Tags"] = utils.Truncate(strings.Join(propTable.droplet.Tags, ","), propTable.colWidth1, true) propMap["URN"] = utils.Truncate(propTable.droplet.URN(), propTable.colWidth1, true) propMap["VPC"] = propTable.droplet.VPCUUID return propMap } // render creates a new Table and returns it as a displayable string func (propTable *dropletPropertiesTable) render() string { tbl := view.NewInfoTable( []string{"Property", "Value"}, propTable.propertyMap, propTable.colWidth0, propTable.colWidth1, propTable.tableHeight, ) return tbl.Render() } ================================================ FILE: modules/digitalocean/keyboard.go ================================================ package digitalocean import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("?", widget.showInfo, "Show info about the selected droplet") widget.SetKeyboardChar("b", widget.dropletRestart, "Reboot the selected droplet") widget.SetKeyboardChar("j", widget.Prev, "Select previous item") widget.SetKeyboardChar("k", widget.Next, "Select next item") widget.SetKeyboardChar("p", widget.dropletEnabledPrivateNetworking, "Enable private networking for the selected drople") widget.SetKeyboardChar("s", widget.dropletShutDown, "Shut down the selected droplet") widget.SetKeyboardChar("u", widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyCtrlD, widget.dropletDestroy, "Destroy the selected droplet") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyEnter, widget.showInfo, "Show info about the selected droplet") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") } ================================================ FILE: modules/digitalocean/settings.go ================================================ package digitalocean import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/wtf" ) const ( defaultFocusable = true defaultTitle = "DigitalOcean" ) // defaultColumns defines the default set of columns to display in the widget // This can be over-ridden in the cofig by explicitly defining a set of columns var defaultColumns = []interface{}{ "Name", "Status", "Region.Slug", } // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"Your DigitalOcean API key."` columns []string `help:"A list of the droplet properties to display."` dateFormat string `help:"The format to display dates and times in."` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_DIGITALOCEAN_API_KEY"))), columns: utils.ToStrs(ymlConfig.UList("columns", defaultColumns)), dateFormat: ymlConfig.UString("dateFormat", wtf.DateFormat), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/digitalocean/widget.go ================================================ package digitalocean import ( "context" "errors" "fmt" "github.com/digitalocean/godo" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "golang.org/x/oauth2" ) /* -------------------- Oauth2 Token -------------------- */ type tokenSource struct { AccessToken string } // Token creates and returns an Oauth2 token func (t *tokenSource) Token() (*oauth2.Token, error) { token := &oauth2.Token{ AccessToken: t.AccessToken, } return token, nil } /* -------------------- Widget -------------------- */ // Widget is the container for droplet data type Widget struct { view.ScrollableWidget app *tview.Application client *godo.Client droplets []*Droplet pages *tview.Pages settings *Settings err error } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), app: tviewApp, pages: pages, settings: settings, } widget.initializeKeyboardControls() widget.View.SetScrollable(true) widget.SetRenderFunction(widget.display) widget.createClient() return &widget } /* -------------------- Exported Functions -------------------- */ // Fetch retrieves droplet data func (widget *Widget) Fetch() error { if widget.client == nil { return errors.New("client could not be initialized") } var err error widget.droplets, err = widget.dropletsFetch() return err } // Next selects the next item in the list func (widget *Widget) Next() { widget.ScrollableWidget.Next() } // Prev selects the previous item in the list func (widget *Widget) Prev() { widget.ScrollableWidget.Prev() } // Refresh updates the data for this widget and displays it onscreen func (widget *Widget) Refresh() { err := widget.Fetch() if err != nil { widget.err = err widget.SetItemCount(0) } else { widget.err = nil widget.SetItemCount(len(widget.droplets)) } widget.display() } // Unselect clears the selection of list items func (widget *Widget) Unselect() { widget.ScrollableWidget.Unselect() widget.RenderFunction() } /* -------------------- Unexported Functions -------------------- */ // createClient create a persisten DigitalOcean client for use in the calls below func (widget *Widget) createClient() { tokenSource := &tokenSource{ AccessToken: widget.settings.apiKey, } oauthClient := oauth2.NewClient(context.Background(), tokenSource) widget.client = godo.NewClient(oauthClient) } // currentDroplet returns the currently-selected droplet, if there is one // Returns nil if no droplet is selected func (widget *Widget) currentDroplet() *Droplet { if len(widget.droplets) == 0 { return nil } if len(widget.droplets) <= widget.Selected { return nil } return widget.droplets[widget.Selected] } // dropletsFetch uses the DigitalOcean API to fetch information about all the available droplets func (widget *Widget) dropletsFetch() ([]*Droplet, error) { dropletList := []*Droplet{} opts := &godo.ListOptions{} for { doDroplets, resp, err := widget.client.Droplets.List(context.Background(), opts) if err != nil { return dropletList, err } for _, doDroplet := range doDroplets { droplet := NewDroplet(doDroplet) dropletList = append(dropletList, droplet) } if resp.Links == nil || resp.Links.IsLastPage() { break } page, err := resp.Links.CurrentPage() if err != nil { return dropletList, err } // Set the page we want for the next request opts.Page = page + 1 } return dropletList, nil } /* -------------------- Droplet Actions -------------------- */ // dropletDestroy destroys the selected droplet func (widget *Widget) dropletDestroy() { currDroplet := widget.currentDroplet() if currDroplet == nil { return } _, err := widget.client.Droplets.Delete(context.Background(), currDroplet.ID) if err != nil { return } widget.dropletRemoveSelected() widget.Refresh() } // dropletEnabledPrivateNetworking enabled private networking on the selected droplet func (widget *Widget) dropletEnabledPrivateNetworking() { currDroplet := widget.currentDroplet() if currDroplet == nil { return } _, _, err := widget.client.DropletActions.EnablePrivateNetworking(context.Background(), currDroplet.ID) if err != nil { return } widget.Refresh() } // dropletRemoveSelected removes the currently-selected droplet from the internal list of droplets func (widget *Widget) dropletRemoveSelected() { currDroplet := widget.currentDroplet() if currDroplet != nil { widget.droplets[len(widget.droplets)-1], widget.droplets[widget.Selected] = widget.droplets[widget.Selected], widget.droplets[len(widget.droplets)-1] widget.droplets = widget.droplets[:len(widget.droplets)-1] } } // dropletRestart restarts the selected droplet func (widget *Widget) dropletRestart() { currDroplet := widget.currentDroplet() if currDroplet == nil { return } _, _, err := widget.client.DropletActions.Reboot(context.Background(), currDroplet.ID) if err != nil { return } widget.Refresh() } // dropletShutDown powers down the selected droplet func (widget *Widget) dropletShutDown() { currDroplet := widget.currentDroplet() if currDroplet == nil { return } _, _, err := widget.client.DropletActions.Shutdown(context.Background(), currDroplet.ID) if err != nil { return } widget.Refresh() } /* -------------------- Common Actions -------------------- */ // showInfo shows a modal window with information about the selected droplet func (widget *Widget) showInfo() { droplet := widget.currentDroplet() if droplet == nil { return } closeFunc := func() { widget.pages.RemovePage("info") widget.app.SetFocus(widget.View) } propTable := newDropletPropertiesTable(droplet).render() propTable += utils.CenterText("Esc to close", 80) modal := view.NewBillboardModal(propTable, closeFunc) modal.SetTitle(fmt.Sprintf(" %s ", droplet.Name)) widget.pages.AddPage("info", modal, false, true) widget.app.SetFocus(modal) widget.app.QueueUpdateDraw(func() { widget.app.Draw() }) } ================================================ FILE: modules/docker/client.go ================================================ package docker import ( "context" "fmt" "sort" "strings" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/dustin/go-humanize" "github.com/pkg/errors" ) func (widget *Widget) getSystemInfo() string { info, err := widget.cli.Info(context.Background()) if err != nil { return errors.Wrap(err, "could not get docker system info").Error() } diskUsage, err := widget.cli.DiskUsage(context.Background(), types.DiskUsageOptions{}) if err != nil { return errors.Wrap(err, "could not get disk usage").Error() } var duContainer int64 for _, c := range diskUsage.Containers { duContainer += c.SizeRw } var duImg int64 for _, im := range diskUsage.Images { duImg += im.Size } var duVol int64 for _, v := range diskUsage.Volumes { duVol += v.UsageData.Size } sysInfo := []struct { name string value string }{ { name: "name:", value: fmt.Sprintf("[%s]%s", widget.settings.Colors.EvenForeground, info.Name), }, { name: "version:", value: fmt.Sprintf("[%s]%s", widget.settings.Colors.EvenForeground, info.ServerVersion), }, { name: "root:", value: fmt.Sprintf("[%s]%s", widget.settings.Colors.EvenForeground, info.DockerRootDir), }, { name: "containers:", value: fmt.Sprintf("[lime]%d[white]/[yellow]%d[white]/[red]%d", info.ContainersRunning, info.ContainersPaused, info.ContainersStopped), }, { name: "images:", value: fmt.Sprintf("[%s]%d", widget.settings.Colors.EvenForeground, info.Images), }, { name: "volumes:", value: fmt.Sprintf("[%s]%v", widget.settings.Colors.EvenForeground, len(diskUsage.Volumes)), }, { name: "memory limit:", value: fmt.Sprintf("[%s]%s", widget.settings.Colors.EvenForeground, humanize.Bytes(uint64(info.MemTotal))), }, { name: "disk usage:", value: fmt.Sprintf(` [%s]* containers: [%s]%s [%s]* images: [%s]%s [%s]* volumes: [%s]%s [%s]* [::b]total: [%s]%s[::-] `, widget.settings.labelColor, widget.settings.Colors.EvenForeground, humanize.Bytes(uint64(duContainer)), widget.settings.labelColor, widget.settings.Colors.EvenForeground, humanize.Bytes(uint64(duImg)), widget.settings.labelColor, widget.settings.Colors.EvenForeground, humanize.Bytes(uint64(duVol)), widget.settings.labelColor, widget.settings.Colors.EvenForeground, humanize.Bytes(uint64(duContainer+duImg+duVol))), }, } padSlice(true, sysInfo, func(i int) string { return sysInfo[i].name }, func(i int, newVal string) { sysInfo[i].name = newVal }) result := "" for _, info := range sysInfo { result += fmt.Sprintf("[%s]%s %s\n", widget.settings.labelColor, info.name, info.value) } return result } func (widget *Widget) getContainerStates() string { cntrs, err := widget.cli.ContainerList(context.Background(), container.ListOptions{All: true}) if err != nil { return errors.Wrapf(err, " could not get container list").Error() } if len(cntrs) == 0 { return " no containers" } colorMap := map[string]string{ "created": "green", "running": "lime", "paused": "yellow", "restarting": "yellow", "removing": "yellow", "exited": "red", "dead": "red", } containers := []struct { name string state string }{} for _, c := range cntrs { container := struct { name string state string }{ name: c.Names[0], state: c.State, } container.name = strings.ReplaceAll(container.name, "/", "") containers = append(containers, container) } sort.Slice(containers, func(i, j int) bool { return containers[i].name < containers[j].name }) padSlice(false, containers, func(i int) string { return containers[i].name }, func(i int, val string) { containers[i].name = val }) result := "" for _, c := range containers { result += fmt.Sprintf("[white]%s [%s]%s\n", c.name, colorMap[c.state], c.state) } return result } ================================================ FILE: modules/docker/example-conf.yml ================================================ wtf: colors: # background: black # foreground: blue border: focusable: darkslateblue focused: orange normal: gray checked: yellow highlight: fore: black back: gray rows: even: yellow odd: white grid: # How _wide_ the columns are, in terminal characters. In this case we have # four columns, each of which are 35 characters wide. # columns: [50, ] # How _high_ the rows are, in terminal lines. In this case we have four rows # that support ten line of text and one of four. # rows: [50] refreshInterval: 1 openFileUtil: "open" mods: docker: type: docker title: "💻" enabled: true position: top: 0 left: 0 height: 3 width: 3 refreshInterval: 1 labelColor: lightblue ================================================ FILE: modules/docker/settings.go ================================================ package docker import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "docker" ) // Settings defines the configuration options for this module type Settings struct { *cfg.Common labelColor string } // NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), labelColor: ymlConfig.UString("labelColor", "white"), } return &settings } ================================================ FILE: modules/docker/utils.go ================================================ package docker import ( "fmt" "math" "reflect" "strconv" ) func padSlice(padLeft bool, slice interface{}, getter func(i int) string, setter func(i int, newVal string)) { rv := reflect.ValueOf(slice) length := rv.Len() maxLen := 0 for i := 0; i < length; i++ { val := getter(i) maxLen = int(math.Max(float64(len(val)), float64(maxLen))) } sign := "-" if padLeft { sign = "" } for i := 0; i < length; i++ { val := getter(i) val = fmt.Sprintf("%"+sign+strconv.Itoa(maxLen)+"s", val) setter(i, val) } } ================================================ FILE: modules/docker/widget.go ================================================ package docker import ( "fmt" "github.com/docker/docker/client" "github.com/pkg/errors" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget cli *client.Client settings *Settings displayBuffer string } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.View.SetScrollable(true) cli, err := client.NewClientWithOpts(client.FromEnv) if err != nil { widget.displayBuffer = errors.Wrap(err, "could not create client").Error() } else { widget.cli = cli } widget.refreshDisplayBuffer() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.refreshDisplayBuffer() widget.Redraw(widget.display) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) display() (string, string, bool) { return widget.CommonSettings().Title, widget.displayBuffer, true } func (widget *Widget) refreshDisplayBuffer() { if widget.cli == nil { return } widget.displayBuffer = "" widget.displayBuffer += fmt.Sprintf("[%s] System[white]\n", widget.settings.Colors.Subheading) widget.displayBuffer += widget.getSystemInfo() widget.displayBuffer += "\n" widget.displayBuffer += fmt.Sprintf("[%s] Containers[white]\n", widget.settings.Colors.Subheading) widget.displayBuffer += widget.getContainerStates() } ================================================ FILE: modules/feedreader/keyboard.go ================================================ package feedreader import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openStory, "Open story in browser") widget.SetKeyboardChar("t", widget.toggleDisplayText, "Toggle display between title, link and title+content") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openStory, "Open story in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/feedreader/settings.go ================================================ package feedreader import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "Feed Reader" ) type colors struct { source string `help:"Color to use for feed source titles." optional:"true" default:"green"` publishDate string `help:"Color to use for publish dates." optional:"true" default:"orange"` } // auth stores [username, password]-credentials for private RSS feeds using Basic Auth type auth struct { username string password string } // Settings defines the configuration properties for this module type Settings struct { *cfg.Common colors feeds []string `help:"An array of RSS and Atom feed URLs"` feedLimit int `help:"The maximum number of stories to display for each feed"` showSource bool `help:"Whether or not to show feed source in front of item titles." values:"true or false" optional:"true" default:"true"` showPublishDate bool `help:"Whether or not to show publish date in front of item titles." values:"true or false" optional:"true" default:"false"` dateFormat string `help:"Date format to use for publish dates" values:"Any valid Go time layout which is handled by Time.Format" optional:"true" default:"Jan 02"` credentials map[string]auth `help:"Map of private feed URLs with required authentication credentials"` disableHTTP2 bool `help:"Whether or not to use the HTTP/2 protocol. Certain sites, such as reddit.com, will not work unless HTTP/2 is disabled." values:"true or false" optional:"true" default:"false"` userAgent string `help:"HTTP User-Agent to use when fetching RSS feeds." optional:"true"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig, globalConfig *config.Config) *Settings { settings := &Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), feeds: utils.ToStrs(ymlConfig.UList("feeds")), feedLimit: ymlConfig.UInt("feedLimit", -1), showSource: ymlConfig.UBool("showSource", true), showPublishDate: ymlConfig.UBool("showPublishDate", false), dateFormat: ymlConfig.UString("dateFormat", "Jan 02"), credentials: make(map[string]auth), disableHTTP2: ymlConfig.UBool("disableHTTP2", false), userAgent: ymlConfig.UString("userAgent", "wtfutil (https://github.com/wtfutil/wtf)"), } settings.source = ymlConfig.UString("colors.source", "green") settings.publishDate = ymlConfig.UString("colors.publishDate", "orange") // If feeds cannot be parsed as list try parsing as a map with username+password fields if len(settings.feeds) == 0 { credentials := make(map[string]auth) feeds := make([]string, 0) for url, creds := range ymlConfig.UMap("feeds") { parsed, ok := creds.(map[string]interface{}) if !ok { continue } user, ok := parsed["username"].(string) if !ok { continue } pass, ok := parsed["password"].(string) if !ok { continue } credentials[url] = auth{ username: user, password: pass, } feeds = append(feeds, url) } settings.feeds = feeds settings.credentials = credentials } return settings } ================================================ FILE: modules/feedreader/widget.go ================================================ package feedreader import ( "crypto/tls" "fmt" "html" "net/http" "regexp" "sort" "strings" "github.com/mmcdole/gofeed" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "jaytaylor.com/html2text" ) type ShowType int const ( SHOW_TITLE ShowType = iota SHOW_LINK SHOW_CONTENT ) // FeedItem represents an item returned from an RSS or Atom feed type FeedItem struct { item *gofeed.Item sourceTitle string viewed bool } // Widget is the container for RSS and Atom data type Widget struct { view.ScrollableWidget stories []*FeedItem parser *gofeed.Parser settings *Settings err error showType ShowType } func rotateShowType(showtype ShowType) ShowType { returnValue := SHOW_TITLE switch showtype { case SHOW_TITLE: returnValue = SHOW_LINK case SHOW_LINK: returnValue = SHOW_CONTENT case SHOW_CONTENT: returnValue = SHOW_TITLE } return returnValue } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { parser := gofeed.NewParser() if settings.disableHTTP2 { // If HTTP/2 is disabled, we override the parser client // with a client using a simple HTTP transport which // removes the client's default behavior of first // trying HTTP/2 before downgrading to older protocol // versions. parser.Client = &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS13, }, }, } } parser.UserAgent = settings.userAgent widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), parser: parser, settings: settings, showType: SHOW_TITLE, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ // Fetch retrieves RSS and Atom feed data func (widget *Widget) Fetch(feedURLs []string) ([]*FeedItem, error) { var data []*FeedItem for _, feedURL := range feedURLs { feedItems, err := widget.fetchForFeed(feedURL) if err != nil { return nil, err } data = append(data, feedItems...) } data = widget.sort(data) return data, nil } // Refresh updates the data in the widget func (widget *Widget) Refresh() { feedItems, err := widget.Fetch(widget.settings.feeds) if err != nil { widget.err = err widget.stories = nil widget.SetItemCount(0) } else { widget.err = nil widget.stories = feedItems widget.SetItemCount(len(feedItems)) } widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) fetchForFeed(feedURL string) ([]*FeedItem, error) { var ( feed *gofeed.Feed err error ) if auth, isPrivateRSS := widget.settings.credentials[feedURL]; isPrivateRSS { widget.parser.AuthConfig = &gofeed.Auth{ Username: auth.username, Password: auth.password, } feed, err = widget.parser.ParseURL(feedURL) widget.parser.AuthConfig = nil } else { feed, err = widget.parser.ParseURL(feedURL) } if err != nil { return nil, err } var feedItems []*FeedItem for idx, gofeedItem := range feed.Items { if widget.settings.feedLimit >= 1 && idx >= widget.settings.feedLimit { // We only want to get the widget.settings.feedLimit latest articles, // not all of them. To get all, set feedLimit to < 1 break } feedItem := &FeedItem{ item: gofeedItem, sourceTitle: feed.Title, viewed: false, } feedItems = append(feedItems, feedItem) } return feedItems, nil } func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } data := widget.stories if len(data) == 0 { return title, "No data", false } var str string for idx, feedItem := range data { rowColor := widget.RowColor(idx) if feedItem.viewed { // Grays out viewed items in the list, while preserving background highlighting when selected rowColor = "gray" if idx == widget.Selected { rowColor = fmt.Sprintf("gray:%s", widget.settings.Colors.HighlightedBackground) } } displayText := widget.getShowText(feedItem, rowColor) row := fmt.Sprintf( "[%s]%2d. %s[white]", rowColor, idx+1, displayText, ) str += utils.HighlightableHelper(widget.View, row, idx, len(feedItem.item.Title)) } return title, str, false } func (widget *Widget) getShowText(feedItem *FeedItem, rowColor string) string { if feedItem == nil { return "" } space := regexp.MustCompile(`\s+`) source := "" publishDate := "" title := space.ReplaceAllString(feedItem.item.Title, " ") if widget.settings.showSource && feedItem.sourceTitle != "" { source = "[" + widget.settings.source + "]" + feedItem.sourceTitle + " " } if widget.settings.showPublishDate && feedItem.item.Published != "" { publishDate = "[" + widget.settings.publishDate + "]" + feedItem.item.PublishedParsed.Format(widget.settings.dateFormat) + " " } // Convert any escaped characters to their character representation title = html.UnescapeString(source + publishDate + "[" + rowColor + "]" + title) switch widget.showType { case SHOW_LINK: return feedItem.item.Link case SHOW_CONTENT: text, _ := html2text.FromString(feedItem.item.Content, html2text.Options{PrettyTables: true}) return strings.TrimSpace(title + "\n" + strings.TrimSpace(text)) default: return title } } // feedItems are sorted by published date func (widget *Widget) sort(feedItems []*FeedItem) []*FeedItem { sort.Slice(feedItems, func(i, j int) bool { return feedItems[i].item.PublishedParsed != nil && feedItems[j].item.PublishedParsed != nil && feedItems[i].item.PublishedParsed.After(*feedItems[j].item.PublishedParsed) }) return feedItems } func (widget *Widget) openStory() { sel := widget.GetSelected() if sel >= 0 && widget.stories != nil && sel < len(widget.stories) { story := widget.stories[sel] story.viewed = true utils.OpenFile(story.item.Link) } } func (widget *Widget) toggleDisplayText() { widget.showType = rotateShowType(widget.showType) widget.Render() } ================================================ FILE: modules/feedreader/widget_test.go ================================================ package feedreader import ( "testing" "github.com/mmcdole/gofeed" "gotest.tools/assert" ) func Test_getShowText(t *testing.T) { tests := []struct { name string feedItem *FeedItem showType ShowType expected string }{ { name: "with nil FeedItem", feedItem: nil, showType: SHOW_TITLE, expected: "", }, { name: "with plain title", feedItem: &FeedItem{ item: &gofeed.Item{Title: "Cats and Dogs"}, }, showType: SHOW_TITLE, expected: "[white]Cats and Dogs", }, { name: "with escaped title", feedItem: &FeedItem{ item: &gofeed.Item{Title: "<Cats and Dogs>"}, }, showType: SHOW_TITLE, expected: "[white]", }, { name: "with unescaped title", feedItem: &FeedItem{ item: &gofeed.Item{Title: ""}, }, showType: SHOW_TITLE, expected: "[white]", }, { name: "with source-title", feedItem: &FeedItem{ sourceTitle: "WTF", item: &gofeed.Item{Title: ""}, }, showType: SHOW_TITLE, expected: "[green]WTF [white]", }, { name: "with link", feedItem: &FeedItem{ item: &gofeed.Item{Title: "Cats and Dogs", Link: "https://cats.com/dog.xml"}, }, showType: SHOW_LINK, expected: "https://cats.com/dog.xml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { widget := &Widget{ settings: &Settings{ colors: colors{ source: "green", publishDate: "orange", }, showSource: true, }, showType: tt.showType, } actual := widget.getShowText(tt.feedItem, "white") assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: modules/football/client.go ================================================ package football import ( "fmt" "net/http" ) var ( footballAPIUrl = "https://api.football-data.org/v2" ) type leagueInfo struct { id int caption string } type Client struct { apiKey string } func NewClient(apiKey string) *Client { client := Client{ apiKey: apiKey, } return &client } func (client *Client) footballRequest(path string, id int) (*http.Response, error) { url := fmt.Sprintf("%s/competitions/%d/%s", footballAPIUrl, id, path) req, err := http.NewRequest("GET", url, http.NoBody) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("X-Auth-Token", client.apiKey) if err != nil { return nil, err } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() return resp, nil } ================================================ FILE: modules/football/settings.go ================================================ package football import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "football" ) type Settings struct { *cfg.Common apiKey string `help:"Your Football-data API token."` league string `help:"Name of the competition. For example PL"` favTeam string `help:"Teams to follow in mentioned league"` matchesFrom int `help:"Matches till Today (Today - Number of days), Default: 2"` matchesTo int `help:"Matches from Today (Today + Number of days), Default: 5"` standingCount int `help:"Top N number of teams in standings, Default: 5"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_FOOTBALL_API_KEY"))), league: ymlConfig.UString("league", ymlConfig.UString("league", os.Getenv("WTF_FOOTBALL_LEAGUE"))), favTeam: ymlConfig.UString("favTeam", ymlConfig.UString("favTeam", os.Getenv("WTF_FOOTBALL_TEAM"))), matchesFrom: ymlConfig.UInt("matchesFrom", 5), matchesTo: ymlConfig.UInt("matchesTo", 5), standingCount: ymlConfig.UInt("standingCount", 5), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() settings.SetDocumentationPath("sports/football") return &settings } ================================================ FILE: modules/football/types.go ================================================ package football type Team struct { Name string `json:"name"` } type LeagueStandings struct { Standings []struct { Table []Table `json:"table"` } `json:"standings"` } type Table struct { Draw int `json:"draw"` GoalDifference int `json:"goalDifference"` Lost int `json:"lost"` Won int `json:"won"` PlayedGames int `json:"playedGames"` Points int `json:"points"` Position int `json:"position"` Team Team `json:"team"` } type LeagueFixtuers struct { Matches []Matches `json:"matches"` } type Matches struct { AwayTeam Team `json:"awayTeam"` HomeTeam Team `json:"homeTeam"` Score Score `json:"score"` Stage string `json:"stage"` Status string `json:"status"` Date string `json:"utcDate"` } type Score struct { FullTime ScoreByTime `json:"fullTime"` HalfTime ScoreByTime `json:"halfTime"` Winner string `json:"winner"` } type ScoreByTime struct { AwayTeam int `json:"awayTeam"` HomeTeam int `json:"homeTeam"` } ================================================ FILE: modules/football/util.go ================================================ package football import ( "bytes" "fmt" "strings" "time" "github.com/olekukonko/tablewriter" ) func createTable(header []string, buf *bytes.Buffer) *tablewriter.Table { table := tablewriter.NewWriter(buf) if len(header) != 0 { table.SetHeader(header) } table.SetBorder(false) table.SetCenterSeparator(" ") table.SetColumnSeparator(" ") table.SetRowSeparator(" ") table.SetAlignment(tablewriter.ALIGN_LEFT) return table } func parseDateString(d string) string { return fmt.Sprintf("🕙 %s", strings.Replace(d, "T", " ", 1)) } func getDateString(offset int) string { today := time.Now() return today.AddDate(0, 0, offset).Format("2006-01-02") } ================================================ FILE: modules/football/widget.go ================================================ package football import ( "bytes" "encoding/json" "fmt" "io" "strconv" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) var leagueID = map[string]leagueInfo{ "BSA": {2013, "Brazil Série A"}, "PL": {2021, "English Premier League"}, "EC": {2016, "English Championship"}, "EUC": {2018, "European Championship"}, "EL2": {444, "Campeonato Brasileiro da Série A"}, "CL": {2001, "UEFA Champions League"}, "FL1": {2015, "French Ligue 1"}, "GB": {2002, "German Bundesliga"}, "ISA": {2019, "Italy Serie A"}, "NE": {2003, "Netherlands Eredivisie"}, "PPL": {2017, "Portugal Primeira Liga"}, "SPD": {2014, "Spain Primera Division"}, "WC": {2000, "FIFA World Cup"}, } type Widget struct { view.TextWidget *Client settings *Settings League leagueInfo err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { var widget Widget leagueId, err := getLeague(settings.league) if err != nil { widget = Widget{ err: fmt.Errorf("unable to get the league id for provided league '%s'", settings.league), Client: NewClient(settings.apiKey), settings: settings, } return &widget } widget = Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), Client: NewClient(settings.apiKey), League: leagueId, settings: settings, } return &widget } func (widget *Widget) Refresh() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { var content string title := fmt.Sprintf("%s %s", widget.CommonSettings().Title, widget.League.caption) wrap := false if widget.err != nil { return title, widget.err.Error(), true } content += widget.GetStandings(widget.League.id) content += widget.GetMatches(widget.League.id) return title, content, wrap } func getLeague(league string) (leagueInfo, error) { var l leagueInfo if val, ok := leagueID[league]; ok { return val, nil } return l, fmt.Errorf("no such league") } // GetStandings of particular league func (widget *Widget) GetStandings(leagueId int) string { var l LeagueStandings var content string content += "Standings:\n\n" buf := new(bytes.Buffer) tStandings := createTable([]string{"No.", "Team", "MP", "Won", "Draw", "Lost", "GD", "Points"}, buf) resp, err := widget.footballRequest("standings", leagueId) if err != nil { return fmt.Sprintf("Error fetching standings: %s", err.Error()) } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return fmt.Sprintf("Error fetching standings: %s", err.Error()) } err = json.Unmarshal(data, &l) if err != nil { return "Error fetching standings" } if len(l.Standings) == 0 { return "Error fetching standings" } for _, i := range l.Standings[0].Table { if i.Position <= widget.settings.standingCount { row := []string{strconv.Itoa(i.Position), i.Team.Name, strconv.Itoa(i.PlayedGames), strconv.Itoa(i.Won), strconv.Itoa(i.Draw), strconv.Itoa(i.Lost), strconv.Itoa(i.GoalDifference), strconv.Itoa(i.Points)} tStandings.Append(row) } } tStandings.Render() content += buf.String() return content } // GetMatches of particular league func (widget *Widget) GetMatches(leagueId int) string { var l LeagueFixtuers var content string scheduledBuf := new(bytes.Buffer) playedBuf := new(bytes.Buffer) tScheduled := createTable([]string{}, scheduledBuf) tPlayed := createTable([]string{}, playedBuf) from := getDateString(-widget.settings.matchesFrom) to := getDateString(widget.settings.matchesTo) requestPath := fmt.Sprintf("matches?dateFrom=%s&dateTo=%s", from, to) resp, err := widget.footballRequest(requestPath, leagueId) if err != nil { return fmt.Sprintf("Error fetching matches: %s", err.Error()) } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return fmt.Sprintf("Error fetching matches: %s", err.Error()) } err = json.Unmarshal(data, &l) if err != nil { return fmt.Sprintf("Error fetching matches: %s", err.Error()) } if len(l.Matches) == 0 { return "Error fetching matches" } for _, m := range l.Matches { widget.markFavorite(&m) switch m.Status { case "SCHEDULED": row := []string{m.HomeTeam.Name, "🆚", m.AwayTeam.Name, parseDateString(m.Date)} tScheduled.Append(row) case "FINISHED": row := []string{m.HomeTeam.Name, strconv.Itoa(m.Score.FullTime.HomeTeam), "🆚", m.AwayTeam.Name, strconv.Itoa(m.Score.FullTime.AwayTeam)} tPlayed.Append(row) } } tScheduled.Render() tPlayed.Render() if playedBuf.String() != "" { content += "\nMatches Played:\n\n" content += playedBuf.String() } if scheduledBuf.String() != "" { content += "\nUpcoming Matches:\n\n" content += scheduledBuf.String() } return content } func (widget *Widget) markFavorite(m *Matches) { switch { case widget.settings.favTeam == "": return case strings.Contains(m.AwayTeam.Name, widget.settings.favTeam): m.AwayTeam.Name = fmt.Sprintf("%s ⭐", m.AwayTeam.Name) case strings.Contains(m.HomeTeam.Name, widget.settings.favTeam): m.HomeTeam.Name = fmt.Sprintf("%s ⭐", m.HomeTeam.Name) } } ================================================ FILE: modules/gcal/cal_event.go ================================================ package gcal import ( "fmt" "time" "github.com/wtfutil/wtf/utils" "google.golang.org/api/calendar/v3" ) type CalEvent struct { event *calendar.Event } func NewCalEvent(event *calendar.Event) *CalEvent { calEvent := CalEvent{ event: event, } return &calEvent } /* -------------------- Exported Functions -------------------- */ func (calEvent *CalEvent) AllDay() bool { return len(calEvent.event.Start.Date) > 0 } func (calEvent *CalEvent) ConflictsWith(otherEvents []*CalEvent) bool { hasConflict := false for _, otherEvent := range otherEvents { if calEvent.event == otherEvent.event { continue } if calEvent.Start().Before(otherEvent.End()) && calEvent.End().After(otherEvent.Start()) { hasConflict = true break } } return hasConflict } func (calEvent *CalEvent) Now() bool { return time.Now().After(calEvent.Start()) && time.Now().Before(calEvent.End()) } func (calEvent *CalEvent) Past() bool { if calEvent.AllDay() { // FIXME: This should calculate properly return false } return !calEvent.Now() && calEvent.Start().Before(time.Now()) } func (calEvent *CalEvent) ResponseFor(email string) string { for _, attendee := range calEvent.event.Attendees { if attendee.Email == email { return attendee.ResponseStatus } } return "" } /* -------------------- DateTimes -------------------- */ func (calEvent *CalEvent) End() time.Time { var calcTime string var end time.Time if calEvent.AllDay() { calcTime = calEvent.event.End.Date end, _ = time.ParseInLocation("2006-01-02", calcTime, time.Local) } else { calcTime = calEvent.event.End.DateTime end, _ = time.Parse(time.RFC3339, calcTime) } return end } func (calEvent *CalEvent) Start() time.Time { var calcTime string var start time.Time if calEvent.AllDay() { calcTime = calEvent.event.Start.Date start, _ = time.ParseInLocation("2006-01-02", calcTime, time.Local) } else { calcTime = calEvent.event.Start.DateTime start, _ = time.Parse(time.RFC3339, calcTime) } return start } func (calEvent *CalEvent) Timestamp(hourFormat string, showEndTime bool) string { if calEvent.AllDay() { startTime, _ := time.ParseInLocation("2006-01-02", calEvent.event.Start.Date, time.Local) return startTime.Format(utils.FriendlyDateFormat) } startTime, _ := time.Parse(time.RFC3339, calEvent.event.Start.DateTime) endTime, _ := time.Parse(time.RFC3339, calEvent.event.End.DateTime) timeFormat := utils.MinimumTimeFormat24 if hourFormat == "12" { timeFormat = utils.MinimumTimeFormat12 } if showEndTime { return fmt.Sprintf("%s-%s", startTime.Format(timeFormat), endTime.Format(timeFormat)) } return startTime.Format(timeFormat) } ================================================ FILE: modules/gcal/client.go ================================================ /* * This butt-ugly code is direct from Google itself * https://developers.google.com/calendar/quickstart/go * * With some changes by me to improve things a bit. */ package gcal import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "sort" "time" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/calendar/v3" "google.golang.org/api/option" ) /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Fetch() ([]*CalEvent, error) { ctx := context.Background() secretPath, _ := utils.ExpandHomeDir(widget.settings.secretFile) b, err := os.ReadFile(filepath.Clean(secretPath)) if err != nil { return nil, err } config, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope) if err != nil { return nil, err } client := getClient(ctx, config, widget.settings.email) srv, err := calendar.NewService(context.Background(), option.WithHTTPClient(client)) if err != nil { return nil, err } // Get calendar events var events calendar.Events startTime := fromMidnight().Format(time.RFC3339) eventLimit := int64(widget.settings.eventCount) timezone := widget.settings.timezone calendarIDs, err := widget.getCalendarIdList(srv) for _, calendarID := range calendarIDs { calendarEvents, listErr := srv.Events.List(calendarID).TimeZone(timezone).ShowDeleted(false).TimeMin(startTime).MaxResults(eventLimit).SingleEvents(true).OrderBy("startTime").Do() if listErr != nil { break } events.Items = append(events.Items, calendarEvents.Items...) } if err != nil { return nil, err } // Sort events timeDateChooser := func(event *calendar.Event) (time.Time, error) { if len(event.Start.Date) > 0 { return time.Parse("2006-01-02", event.Start.Date) } return time.Parse(time.RFC3339, event.Start.DateTime) } sort.Slice(events.Items, func(i, j int) bool { dateA, _ := timeDateChooser(events.Items[i]) dateB, _ := timeDateChooser(events.Items[j]) return dateA.Before(dateB) }) // Wrap the calendar events in our custom CalEvent calEvents := []*CalEvent{} for _, event := range events.Items { calEvents = append(calEvents, NewCalEvent(event)) } return calEvents, err } /* -------------------- Unexported Functions -------------------- */ func fromMidnight() time.Time { now := time.Now() return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) } // getClient uses a Context and Config to retrieve a Token // then generate a Client. It returns the generated Client. func getClient(ctx context.Context, config *oauth2.Config, name string) *http.Client { cacheFile, err := tokenCacheFile(name) if err != nil { log.Fatalf("Unable to get path to cached credential file. %v", err) } tok, err := tokenFromFile(cacheFile) if err != nil { tok = getTokenFromWeb(config) saveToken(cacheFile, tok) } return config.Client(ctx, tok) } func isAuthenticated(name string) bool { cacheFile, err := tokenCacheFile(name) if err != nil { log.Fatalf("Unable to get path to cached credential file. %v", err) } _, err = tokenFromFile(cacheFile) return err == nil } func (widget *Widget) authenticate() { secretPath, _ := utils.ExpandHomeDir(filepath.Clean(widget.settings.secretFile)) b, err := os.ReadFile(filepath.Clean(secretPath)) if err != nil { log.Fatalf("Unable to read secret file. %v", widget.settings.secretFile) } config, _ := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope) tok := getTokenFromWeb(config) cacheFile, _ := tokenCacheFile(widget.settings.email) saveToken(cacheFile, tok) } // getTokenFromWeb uses Config to request a Token. // It returns the retrieved Token. func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) fmt.Printf("Go to the following link in your browser then type the "+ "authorization code: \n%v (press 'return' before inserting the code)", authURL) var code string if _, err := fmt.Scan(&code); err != nil { log.Fatalf("Unable to read authorization code %v", err) } tok, err := config.Exchange(context.Background(), code) if err != nil { log.Fatalf("Unable to retrieve token from web %v", err) } return tok } // tokenCacheFile generates credential file path/filename. // It returns the generated credential path/filename. func tokenCacheFile(name string) (string, error) { configDir, err := cfg.WtfConfigDir() if err != nil { return "", err } oldFile := configDir + "/gcal-auth.json" newFileName := fmt.Sprintf("%s-gcal-auth.json", name) if _, err := os.Stat(oldFile); err == nil { renamedFile := configDir + "/" + newFileName err := os.Rename(oldFile, renamedFile) if err != nil { return "", err } return renamedFile, nil } return cfg.CreateFile(newFileName) } // tokenFromFile retrieves a Token from a given file path. // It returns the retrieved Token and any read error encountered. func tokenFromFile(file string) (*oauth2.Token, error) { f, err := os.Open(filepath.Clean(file)) if err != nil { return nil, err } t := &oauth2.Token{} err = json.NewDecoder(f).Decode(t) defer func() { _ = f.Close() }() return t, err } // saveToken uses a file path to create a file and store the // token in it. func saveToken(file string, token *oauth2.Token) { fmt.Printf("Saving credential file to: %s\n", file) f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { log.Fatalf("unable to cache oauth token: %v", err) } defer func() { _ = f.Close() }() err = json.NewEncoder(f).Encode(token) if err != nil { log.Fatalf("unable to encode oauth token: %v", err) } } func (widget *Widget) getCalendarIdList(srv *calendar.Service) ([]string, error) { // Return single calendar if settings specify we should if !widget.settings.multiCalendar { id, err := srv.CalendarList.Get("primary").Do() if err != nil { return nil, err } return []string{id.Id}, nil } // Get all user calendars with at the least writing access var calendarIds []string var pageToken string for { calendarList, err := srv.CalendarList.List().ShowHidden(false).MinAccessRole(widget.settings.calendarReadLevel).PageToken(pageToken).Do() if err != nil { return nil, err } for _, calendarListItem := range calendarList.Items { calendarIds = append(calendarIds, calendarListItem.Id) } pageToken = calendarList.NextPageToken if pageToken == "" { break } } return calendarIds, nil } ================================================ FILE: modules/gcal/display.go ================================================ package gcal import ( "fmt" "regexp" "strings" "time" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := widget.settings.Title calEvents := widget.calEvents if widget.err != nil { return title, widget.err.Error(), true } if len(calEvents) == 0 { return title, "No calendar events", false } var str string var prevEvent *CalEvent if !widget.settings.showDeclined { calEvents = widget.removeDeclined(calEvents) } for _, calEvent := range calEvents { if calEvent.AllDay() && !widget.settings.showAllDay { continue } ts := calEvent.Timestamp(widget.settings.hourFormat, widget.settings.showEndTime) timestamp := fmt.Sprintf("[%s]%s", widget.eventTimeColor(), ts) if calEvent.AllDay() { timestamp = "" } eventTitle := fmt.Sprintf("[%s]%s", widget.titleColor(calEvent), widget.eventSummary(calEvent, calEvent.ConflictsWith(calEvents)), ) lineOne := fmt.Sprintf( "%s %s %s %s[white]\n", widget.dayDivider(calEvent, prevEvent), widget.responseIcon(calEvent), timestamp, eventTitle, ) str += fmt.Sprintf("%s %s%s\n", lineOne, widget.location(calEvent), widget.timeUntil(calEvent), ) if (widget.location(calEvent) != "") || (widget.timeUntil(calEvent) != "") { str += "\n" } prevEvent = calEvent } return title, str, false } func (widget *Widget) dayDivider(event, prevEvent *CalEvent) string { var prevStartTime time.Time if prevEvent != nil { prevStartTime = prevEvent.Start() } // round times to midnight for comparison toMidnight := func(t time.Time) time.Time { t = t.Local() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } prevStartDay := toMidnight(prevStartTime) eventStartDay := toMidnight(event.Start()) if !eventStartDay.Equal(prevStartDay) { return fmt.Sprintf("[%s]", widget.settings.day) + event.Start().Format(utils.FullDateFormat) + "\n" } return "" } func (widget *Widget) descriptionColor(calEvent *CalEvent) string { if calEvent.Past() { return widget.settings.past } return widget.settings.description } func (widget *Widget) eventTimeColor() string { return widget.settings.eventTime } func (widget *Widget) eventSummary(calEvent *CalEvent, conflict bool) string { summary := calEvent.event.Summary if calEvent.Now() { summary = fmt.Sprintf( "%s %s", widget.settings.currentIcon, summary, ) } if conflict { return fmt.Sprintf("%s %s", widget.settings.conflictIcon, summary) } return summary } // timeUntil returns the number of hours or days until the event // If the event is in the past, returns nil func (widget *Widget) timeUntil(calEvent *CalEvent) string { duration := time.Until(calEvent.Start()).Round(time.Minute) if duration < 0 { return "" } days := duration / (24 * time.Hour) duration -= days * (24 * time.Hour) hours := duration / time.Hour duration -= hours * time.Hour mins := duration / time.Minute untilStr := "" color := "[lightblue]" switch { case days > 0: untilStr = fmt.Sprintf("%dd", days) case hours > 0: untilStr = fmt.Sprintf("%dh", hours) default: untilStr = fmt.Sprintf("%dm", mins) if mins < 30 { color = "[red]" } } return color + untilStr + "[white]" } func (widget *Widget) titleColor(calEvent *CalEvent) string { color := widget.settings.title for _, untypedArr := range widget.settings.highlights { highlightElements := utils.ToStrs(untypedArr.([]interface{})) match, _ := regexp.MatchString( strings.ToLower(highlightElements[0]), strings.ToLower(calEvent.event.Summary), ) if match { color = highlightElements[1] } } if calEvent.Past() { color = widget.settings.past } return color } func (widget *Widget) location(calEvent *CalEvent) string { if !widget.settings.withLocation { return "" } if calEvent.event.Location == "" { return "" } return fmt.Sprintf( "[%s]%s ", widget.descriptionColor(calEvent), calEvent.event.Location, ) } func (widget *Widget) responseIcon(calEvent *CalEvent) string { if !widget.settings.displayResponseStatus { return "" } icon := "[gray]" switch calEvent.ResponseFor(widget.settings.email) { case "accepted": return icon + "✔" case "declined": return icon + "✘" case "needsAction": return icon + "?" case "tentative": return icon + "~" default: return icon + " " } } func (widget *Widget) removeDeclined(events []*CalEvent) []*CalEvent { var ret []*CalEvent for _, e := range events { if e.ResponseFor(widget.settings.email) != "declined" { ret = append(ret, e) } } return ret } ================================================ FILE: modules/gcal/display_test.go ================================================ package gcal import ( "testing" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" "google.golang.org/api/calendar/v3" ) func Test_display_content(t *testing.T) { startTime := &calendar.EventDateTime{DateTime: "1986-04-19T01:00:00.00Z"} endTime := &calendar.EventDateTime{DateTime: "1986-04-19T02:00:00.00Z"} event := &calendar.Event{Summary: "Foo", Start: startTime, End: endTime} testCases := []struct { descriptionWanted string events []*CalEvent name string settings *Settings }{ { name: "Event content without any events", settings: &Settings{Common: &cfg.Common{}}, events: nil, descriptionWanted: "No calendar events", }, { name: "Event content with a single event, without end times displayed", settings: &Settings{Common: &cfg.Common{}, showEndTime: false}, events: []*CalEvent{NewCalEvent(event)}, descriptionWanted: "[]Saturday, Apr 19\n []01:00 []Foo[white]\n \n", }, { name: "Event content with a single event without showEndTime explicitly set in settings", settings: &Settings{Common: &cfg.Common{}}, events: []*CalEvent{NewCalEvent(event)}, descriptionWanted: "[]Saturday, Apr 19\n []01:00 []Foo[white]\n \n", }, { name: "Event content with a single event with end times displayed", settings: &Settings{Common: &cfg.Common{}, showEndTime: true}, events: []*CalEvent{NewCalEvent(event)}, descriptionWanted: "[]Saturday, Apr 19\n []01:00-02:00 []Foo[white]\n \n", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { w := &Widget{calEvents: tt.events, settings: tt.settings, err: nil} _, description, err := w.content() assert.Equal(t, false, err, tt.name) assert.Equal(t, tt.descriptionWanted, description, tt.name) }) } } ================================================ FILE: modules/gcal/settings.go ================================================ package gcal import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Calendar" ) type colors struct { day string description string `help:"The default color for calendar event descriptions." values:"Any X11 color name." optional:"true"` eventTime string `help:"The default color for calendar event times." values:"Any X11 color name." optional:"true"` past string `help:"The color for calendar events that have passed." values:"Any X11 color name." optional:"true"` title string `help:"The default colour for calendar event titles." values:"Any X11 color name." optional:"true"` highlights []interface{} `help:"A list of arrays that define a regular expression pattern and a color. If a calendar event title matches a regular expression, the title will be drawn in that colour. Over-rides the default title colour." values:"An array of a valid regular expression, any X11 color name." optional:"true"` } // Settings defines the configuration options for this module type Settings struct { colors *cfg.Common conflictIcon string `help:"The icon displayed beside calendar events that have conflicting times (they intersect or overlap in some way)." values:"Any displayable unicode character." optional:"true"` currentIcon string `help:"The icon displayed beside the current calendar event." values:"Any displayable unicode character." optional:"true"` displayResponseStatus bool `help:"Whether or not to display your response status to the calendar event." values:"true or false" optional:"true"` email string `help:"The email address associated with your Google account. Necessary for determining 'responseStatus'." values:"A valid email address string."` eventCount int `help:"The number of calendar events to display." values:"A positive integer, 0..n." optional:"true"` hourFormat string `help:"The format of the clock." values:"12 or 24"` multiCalendar bool `help:"Whether or not to display your primary calendar or all calendars you have access to." values:"true or false" optional:"true"` secretFile string `help:"Your Google client secret JSON file." values:"A string representing a file path to the JSON secret file."` showAllDay bool `help:"Whether or not to display all-day events" values:"true or false" optional:"true" default:"true"` showDeclined bool `help:"Whether or not to display events you’ve declined to attend." values:"true or false" optional:"true"` showEndTime bool `help:"Display the end time of events, in addition to start time." values:"true or false" optional:"true" default:"false"` withLocation bool `help:"Whether or not to show the location of the appointment." values:"true or false"` timezone string `help:"The time zone used to display calendar event times." values:"A valid TZ database time zone string" optional:"true"` calendarReadLevel string `help:"The calender read level specifies level you want to read events. Default: writer " values:"reader, writer" optional:"true"` } // NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), conflictIcon: ymlConfig.UString("conflictIcon", "🚨"), currentIcon: ymlConfig.UString("currentIcon", "🔸"), displayResponseStatus: ymlConfig.UBool("displayResponseStatus", true), email: ymlConfig.UString("email", ""), eventCount: ymlConfig.UInt("eventCount", 10), hourFormat: ymlConfig.UString("hourFormat", "24"), multiCalendar: ymlConfig.UBool("multiCalendar", false), secretFile: ymlConfig.UString("secretFile", ""), showAllDay: ymlConfig.UBool("showAllDay", true), showEndTime: ymlConfig.UBool("showEndTime", false), showDeclined: ymlConfig.UBool("showDeclined", false), withLocation: ymlConfig.UBool("withLocation", true), timezone: ymlConfig.UString("timezone", ""), calendarReadLevel: ymlConfig.UString("calendarReadLevel", "writer"), } settings.day = ymlConfig.UString("colors.day", settings.Colors.Subheading) settings.description = ymlConfig.UString("colors.description", "white") // settings.colors.eventTime is a new feature introduced via issue #638. Prior to this, the color of the event // time was (unintentionally) customized via settings.colors.description. To maintain backwards compatibility // for users who might be already using this to set the color of the event time, we try to determine the default // from settings.colors.description. If it is not set, then the default value of "white" is used. Finally, if a // user sets a value for colors.eventTime, it overrides the defaults. // // PS: We should have a deprecation plan for supporting this backwards compatibility feature. settings.eventTime = ymlConfig.UString("colors.eventTime", settings.description) settings.highlights = ymlConfig.UList("colors.highlights") settings.past = ymlConfig.UString("colors.past", "gray") settings.title = ymlConfig.UString("colors.title", "white") settings.SetDocumentationPath("google/gcal") return &settings } ================================================ FILE: modules/gcal/widget.go ================================================ package gcal import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget calEvents []*CalEvent err error settings *Settings tviewApp *tview.Application } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), tviewApp: tviewApp, settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Disable() { widget.TextWidget.Disable() } func (widget *Widget) Refresh() { if isAuthenticated(widget.settings.email) { widget.fetchAndDisplayEvents() return } widget.tviewApp.Suspend(widget.authenticate) widget.Refresh() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) fetchAndDisplayEvents() { calEvents, err := widget.Fetch() if err != nil { widget.err = err widget.calEvents = []*CalEvent{} } else { widget.err = nil widget.calEvents = calEvents } widget.display() } ================================================ FILE: modules/gerrit/display.go ================================================ package gerrit import ( "fmt" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } project := widget.currentGerritProject() if project == nil { return title, "Gerrit project data is unavailable", true } title = fmt.Sprintf("%s- %s", widget.CommonSettings().Title, widget.title(project)) _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.GerritProjects), widget.Idx, width) + "\n" str += fmt.Sprintf(" [%s]Stats[white]\n", widget.settings.Colors.Subheading) str += widget.displayStats(project) str += "\n" str += fmt.Sprintf(" [%s]Open Incoming Reviews[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyIncomingReviews(project) str += "\n" str += fmt.Sprintf(" [%s]My Outgoing Reviews[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyOutgoingReviews(project) return title, str, false } func (widget *Widget) displayMyIncomingReviews(project *GerritProject) string { if len(project.IncomingReviews) == 0 { return " [grey]none[white]\n" } str := "" for idx, r := range project.IncomingReviews { str += fmt.Sprintf(" [%s] [green]%d[white] [%s] %s\n", widget.rowColor(idx), r.Number, widget.rowColor(idx), r.Subject) } return str } func (widget *Widget) displayMyOutgoingReviews(project *GerritProject) string { if len(project.OutgoingReviews) == 0 { return " [grey]none[white]\n" } str := "" for idx, r := range project.OutgoingReviews { str += fmt.Sprintf(" [%s] [green]%d[white] [%s] %s\n", widget.rowColor(idx+len(project.IncomingReviews)), r.Number, widget.rowColor(idx+len(project.IncomingReviews)), r.Subject) } return str } func (widget *Widget) displayStats(project *GerritProject) string { str := fmt.Sprintf( " Reviews: %d\n", project.ReviewCount, ) return str } func (widget *Widget) rowColor(idx int) string { if widget.View.HasFocus() && (idx == widget.selected) { return widget.settings.DefaultFocusedRowColor() } return widget.settings.RowColor(idx) } func (widget *Widget) title(project *GerritProject) string { return fmt.Sprintf("[green]%s [white]", project.Path) } ================================================ FILE: modules/gerrit/gerrit_repo.go ================================================ package gerrit import ( "context" glb "github.com/andygrunwald/go-gerrit" ) type GerritProject struct { gerrit *glb.Client Path string Changes *[]glb.ChangeInfo ReviewCount int IncomingReviews []glb.ChangeInfo OutgoingReviews []glb.ChangeInfo } func NewGerritProject(path string, gerrit *glb.Client) *GerritProject { project := GerritProject{ gerrit: gerrit, Path: path, } return &project } // Refresh reloads the gerrit data via the Gerrit API func (project *GerritProject) Refresh(username string) { project.Changes, _ = project.loadChanges() project.ReviewCount = project.countReviews(project.Changes) project.IncomingReviews = project.myIncomingReviews(project.Changes, username) project.OutgoingReviews = project.myOutgoingReviews(project.Changes, username) } /* -------------------- Counts -------------------- */ func (project *GerritProject) countReviews(changes *[]glb.ChangeInfo) int { if changes == nil { return 0 } return len(*changes) } /* -------------------- Unexported Functions -------------------- */ // myOutgoingReviews returns a list of my outgoing reviews created by username on this project func (project *GerritProject) myOutgoingReviews(changes *[]glb.ChangeInfo, username string) []glb.ChangeInfo { var ors []glb.ChangeInfo if changes == nil { return ors } for _, change := range *changes { user := change.Owner if user.Username == username { ors = append(ors, change) } } return ors } // myIncomingReviews returns a list of merge requests for which username has been requested to ChangeInfo func (project *GerritProject) myIncomingReviews(changes *[]glb.ChangeInfo, username string) []glb.ChangeInfo { var irs []glb.ChangeInfo if changes == nil { return irs } for _, change := range *changes { reviewers := change.Reviewers for _, reviewer := range reviewers["REVIEWER"] { if reviewer.Username == username { irs = append(irs, change) } } } return irs } func (project *GerritProject) loadChanges() (*[]glb.ChangeInfo, error) { opt := &glb.QueryChangeOptions{} opt.Query = []string{"(projects:" + project.Path + "+ is:open + owner:self) " + " OR " + "(projects:" + project.Path + " + is:open + ((reviewer:self + -owner:self + -star:ignore) + OR + assignee:self))"} opt.AdditionalFields = []string{"DETAILED_LABELS", "DETAILED_ACCOUNTS"} changes, _, err := project.gerrit.Changes.QueryChanges(context.Background(), opt) if err != nil { return nil, err } return changes, err } ================================================ FILE: modules/gerrit/keyboard.go ================================================ package gerrit import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("h", widget.prevProject, "Select previous project") widget.SetKeyboardChar("l", widget.nextProject, "Select next project") widget.SetKeyboardChar("j", widget.nextReview, "Select next review") widget.SetKeyboardChar("k", widget.prevReview, "Select previous review") widget.SetKeyboardKey(tcell.KeyLeft, widget.prevProject, "Select previous project") widget.SetKeyboardKey(tcell.KeyRight, widget.nextProject, "Select next project") widget.SetKeyboardKey(tcell.KeyDown, widget.nextReview, "Select next review") widget.SetKeyboardKey(tcell.KeyUp, widget.prevReview, "Select previous review") widget.SetKeyboardKey(tcell.KeyEsc, widget.unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyEnter, widget.openReview, "Open review in browser") } ================================================ FILE: modules/gerrit/settings.go ================================================ package gerrit import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Gerrit" ) type colors struct { rows struct { even string `help:"Define the foreground color for even-numbered rows." values:"Any X11 color name." optional:"true"` odd string `help:"Define the foreground color for odd-numbered rows." values:"Any X11 color name." optional:"true"` } } type Settings struct { colors *cfg.Common domain string `help:"Your Gerrit corporate domain."` password string `help:"Your Gerrit HTTP Password."` projects []interface{} `help:"A list of Gerrit project names to fetch data for."` username string `help:"Your Gerrit username."` verifyServerCertificate bool `help:"Determines whether or not the server’s certificate chain and host name are verified." values:"true or false" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), domain: ymlConfig.UString("domain", ""), password: ymlConfig.UString("password", os.Getenv("WTF_GERRIT_PASSWORD")), projects: ymlConfig.UList("projects"), username: ymlConfig.UString("username", ""), verifyServerCertificate: ymlConfig.UBool("verifyServerCertificate", true), } cfg.ModuleSecret(name, globalConfig, &settings.password). Service(settings.domain).Load() settings.rows.even = ymlConfig.UString("colors.rows.even", "white") settings.rows.odd = ymlConfig.UString("colors.rows.odd", "blue") return &settings } ================================================ FILE: modules/gerrit/widget.go ================================================ package gerrit import ( "context" "crypto/tls" "fmt" "net/http" "regexp" glb "github.com/andygrunwald/go-gerrit" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget gerrit *glb.Client GerritProjects []*GerritProject Idx int selected int settings *Settings err error } var ( GerritURLPattern = regexp.MustCompile(`^(http|https)://(.*)$`) ) func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), Idx: 0, settings: settings, } widget.initializeKeyboardControls() widget.unselect() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !widget.settings.verifyServerCertificate, }, Proxy: http.ProxyFromEnvironment, }, } gerritUrl := widget.settings.domain submatches := GerritURLPattern.FindAllStringSubmatch(widget.settings.domain, -1) if len(submatches) > 0 && len(submatches[0]) > 2 { submatch := submatches[0] gerritUrl = fmt.Sprintf( "%s://%s:%s@%s", submatch[1], widget.settings.username, widget.settings.password, submatch[2], ) } gerrit, err := glb.NewClient(context.Background(), gerritUrl, httpClient) if err != nil { widget.err = err widget.gerrit = nil widget.GerritProjects = nil } else { widget.err = nil widget.gerrit = gerrit widget.GerritProjects = widget.buildProjectCollection(widget.settings.projects) for _, project := range widget.GerritProjects { project.Refresh(widget.settings.username) } } widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) nextProject() { widget.Idx++ widget.unselect() if widget.Idx == len(widget.GerritProjects) { widget.Idx = 0 } widget.unselect() } func (widget *Widget) prevProject() { widget.Idx-- if widget.Idx < 0 { widget.Idx = len(widget.GerritProjects) - 1 } widget.unselect() } func (widget *Widget) nextReview() { widget.selected++ project := widget.GerritProjects[widget.Idx] if widget.selected >= project.ReviewCount { widget.selected = 0 } widget.display() } func (widget *Widget) prevReview() { widget.selected-- project := widget.GerritProjects[widget.Idx] if widget.selected < 0 { widget.selected = project.ReviewCount - 1 } widget.display() } func (widget *Widget) openReview() { sel := widget.selected project := widget.GerritProjects[widget.Idx] if sel >= 0 && sel < project.ReviewCount { change := glb.ChangeInfo{} if sel < len(project.IncomingReviews) { change = project.IncomingReviews[sel] } else { change = project.OutgoingReviews[sel-len(project.IncomingReviews)] } utils.OpenFile(fmt.Sprintf("%s/%s/%d", widget.settings.domain, "#/c", change.Number)) } } func (widget *Widget) unselect() { widget.selected = -1 widget.display() } func (widget *Widget) buildProjectCollection(projectData []interface{}) []*GerritProject { gerritProjects := []*GerritProject{} for _, name := range projectData { project := NewGerritProject(name.(string), widget.gerrit) gerritProjects = append(gerritProjects, project) } return gerritProjects } func (widget *Widget) currentGerritProject() *GerritProject { if len(widget.GerritProjects) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.GerritProjects) { return nil } return widget.GerritProjects[widget.Idx] } ================================================ FILE: modules/git/display.go ================================================ package git import ( "fmt" "strings" "unicode/utf8" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { repoData := widget.currentData() if repoData == nil { return widget.CommonSettings().Title, " Git repo data is unavailable ", false } widgetTitle := "" if widget.settings.lastFolderTitle { pathParts := strings.Split(repoData.Repository, "/") widgetTitle += pathParts[len(pathParts)-1] } else { widgetTitle = repoData.Repository } if widget.settings.branchInTitle { widgetTitle += fmt.Sprintf(" <%s>", repoData.Branch) } title := "" if widget.settings.showModuleName { title = fmt.Sprintf( "%s - %s[white]", widget.CommonSettings().Title, widgetTitle, ) } else { title = fmt.Sprintf( "%s[white]", widgetTitle, ) } _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.GitRepos), widget.Idx, width) + "\n" for _, v := range widget.settings.sections { if v == "branch" { str += fmt.Sprintf(" [%s]Branch[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf(" %s", repoData.Branch) } else if v == "files" && (widget.settings.showFilesIfEmpty || len(repoData.ChangedFiles) > 1) { str += widget.formatChanges(repoData.ChangedFiles) } else if v == "commits" { str += widget.formatCommits(repoData.Commits) } str += "\n" } return title, str, false } func (widget *Widget) formatChanges(data []string) string { str := fmt.Sprintf(" [%s]Changed Files[white]\n", widget.settings.Colors.Subheading) if len(data) == 1 { str += " [grey]none[white]\n" } else { for _, line := range data { str += widget.formatChange(line) } } return str } func (widget *Widget) formatChange(line string) string { if line == "" { return "" } line = strings.TrimSpace(line) firstChar, _ := utf8.DecodeRuneInString(line) // Revisit this and kill the ugly duplication switch firstChar { case 'A': line = strings.Replace(line, "A", "[green]A[white]", 1) case 'D': line = strings.Replace(line, "D", "[red]D[white]", 1) case 'M': line = strings.Replace(line, "M", "[yellow]M[white]", 1) case 'R': line = strings.Replace(line, "R", "[purple]R[white]", 1) } return fmt.Sprintf(" %s\n", strings.ReplaceAll(line, "\"", "")) } func (widget *Widget) formatCommits(data []string) string { str := fmt.Sprintf(" [%s]Recent Commits[white]\n", widget.settings.Colors.Subheading) for _, line := range data { str += widget.formatCommit(line) } return str } func (widget *Widget) formatCommit(line string) string { return fmt.Sprintf(" %s\n", strings.ReplaceAll(line, "\"", "")) } ================================================ FILE: modules/git/git_repo.go ================================================ package git import ( "fmt" "os/exec" "strings" "github.com/wtfutil/wtf/utils" ) type GitRepo struct { Branch string ChangedFiles []string Commits []string Repository string Path string } func NewGitRepo(repoPath string, commitCount int, commitFormat, dateFormat string) *GitRepo { repo := GitRepo{Path: repoPath} repo.Branch = repo.branch() repo.ChangedFiles = repo.changedFiles() repo.Commits = repo.commits(commitCount, commitFormat, dateFormat) repo.Repository = strings.TrimSpace(repo.repository()) return &repo } /* -------------------- Unexported Functions -------------------- */ func (repo *GitRepo) branch() string { arg := []string{repo.gitDir(), repo.workTree(), "rev-parse", "--abbrev-ref", "HEAD"} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *GitRepo) changedFiles() []string { arg := []string{repo.gitDir(), repo.workTree(), "status", "--porcelain"} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) data := strings.Split(str, "\n") return data } func (repo *GitRepo) commits(commitCount int, commitFormat, dateFormat string) []string { dateStr := fmt.Sprintf("--date=format:\"%s\"", dateFormat) numStr := fmt.Sprintf("-n %d", commitCount) commitStr := fmt.Sprintf("--pretty=format:\"%s\"", commitFormat) arg := []string{repo.gitDir(), repo.workTree(), "log", dateStr, numStr, commitStr} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) data := strings.Split(str, "\n") return data } func (repo *GitRepo) repository() string { arg := []string{repo.gitDir(), repo.workTree(), "rev-parse", "--show-toplevel"} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *GitRepo) pull() string { arg := []string{repo.gitDir(), repo.workTree(), "pull"} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *GitRepo) checkout(branch string) string { arg := []string{repo.gitDir(), repo.workTree(), "checkout", branch} cmd := exec.Command(__go_cmd, arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *GitRepo) gitDir() string { return fmt.Sprintf("--git-dir=%s/.git", repo.Path) } func (repo *GitRepo) workTree() string { return fmt.Sprintf("--work-tree=%s", repo.Path) } ================================================ FILE: modules/git/keyboard.go ================================================ package git import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("p", widget.Pull, "Pull repo") widget.SetKeyboardChar("c", widget.Checkout, "Checkout branch") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") } ================================================ FILE: modules/git/settings.go ================================================ package git import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "Git" ) type Settings struct { *cfg.Common commitCount int `help:"The number of past commits to display." values:"A positive integer, 0..n." optional:"true"` sections []interface{} `help:"Sections to show" optional:"true"` showModuleName bool `help:"Whether to show 'Git - ' before information in title" optional:"true" default:"true"` branchInTitle bool `help:"Whether to show branch name in title instead of the widget body itself" optional:"true" default:"false"` showFilesIfEmpty bool `help:"Whether to show Changed Files section if no changed files" optional:"true" default:"true"` lastFolderTitle bool `help:"Whether to show only last part of directory path instead of full path" optional:"true" default:"false"` commitFormat string `help:"The string format for the commit message." optional:"true"` dateFormat string `help:"The string format for the date/time in the commit message." optional:"true"` repositories []interface{} `help:"Defines which git repositories to watch." values:"A list of zero or more local file paths pointing to valid git repositories."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), commitCount: ymlConfig.UInt("commitCount", 10), sections: ymlConfig.UList("sections"), showModuleName: ymlConfig.UBool("showModuleName", true), branchInTitle: ymlConfig.UBool("branchInTitle", false), showFilesIfEmpty: ymlConfig.UBool("showFilesIfEmpty", true), lastFolderTitle: ymlConfig.UBool("lastFolderTitle", false), commitFormat: ymlConfig.UString("commitFormat", "[forestgreen]%h [white]%s [grey]%an on %cd[white]"), dateFormat: ymlConfig.UString("dateFormat", "%b %d, %Y"), repositories: ymlConfig.UList("repositories"), } if len(settings.sections) == 0 { for _, v := range []string{"branch", "files", "commits"} { settings.sections = append(settings.sections, v) } } return &settings } func (widget *Widget) ConfigText() string { return utils.HelpFromInterface(Settings{}) } ================================================ FILE: modules/git/variables.go ================================================ //go:build !windows package git const ( __go_cmd = "git" ) ================================================ FILE: modules/git/variables_win.go ================================================ //go:build windows package git const ( __go_cmd = "git.exe" ) ================================================ FILE: modules/git/widget.go ================================================ package git import ( "log" "os" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( modalHeight = 7 modalWidth = 80 offscreen = -1000 ) type Widget struct { view.MultiSourceWidget view.TextWidget GitRepos []*GitRepo pages *tview.Pages settings *Settings tviewApp *tview.Application } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "repository", "repositories"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), tviewApp: tviewApp, pages: pages, settings: settings, } widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.display) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Checkout() { form := widget.modalForm("Branch to checkout:", "") checkoutFctn := func() { text := form.GetFormItem(0).(*tview.InputField).GetText() repoToCheckout := widget.GitRepos[widget.Idx] repoToCheckout.checkout(text) widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() widget.Refresh() } widget.addButtons(form, checkoutFctn) widget.modalFocus(form) } func (widget *Widget) Pull() { repoToPull := widget.GitRepos[widget.Idx] repoToPull.pull() widget.Refresh() } func (widget *Widget) Refresh() { repoPaths := utils.ToStrs(widget.settings.repositories) widget.GitRepos = widget.gitRepos(repoPaths) widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) addCheckoutButton(form *tview.Form, fctn func()) { form.AddButton("Checkout", fctn) } func (widget *Widget) addButtons(form *tview.Form, checkoutFctn func()) { widget.addCheckoutButton(form, checkoutFctn) widget.addCancelButton(form) } func (widget *Widget) addCancelButton(form *tview.Form) { cancelFn := func() { widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() } form.AddButton("Cancel", cancelFn) form.SetCancelFunc(cancelFn) } func (widget *Widget) modalFocus(form *tview.Form) { frame := widget.modalFrame(form) widget.pages.AddPage("modal", frame, false, true) widget.tviewApp.SetFocus(frame) } func (widget *Widget) modalForm(lbl, text string) *tview.Form { form := tview.NewForm() form.SetButtonsAlign(tview.AlignCenter) form.SetButtonTextColor(tview.Styles.PrimaryTextColor) form.AddInputField(lbl, text, 60, nil, nil) return form } func (widget *Widget) modalFrame(form *tview.Form) *tview.Frame { frame := tview.NewFrame(form) frame.SetBorders(0, 0, 0, 0, 0, 0) frame.SetRect(offscreen, offscreen, modalWidth, modalHeight) frame.SetBorder(true) frame.SetBorders(1, 1, 0, 0, 1, 1) drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { w, h := screen.Size() frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height) return x, y, width, height } frame.SetDrawFunc(drawFunc) return frame } func (widget *Widget) currentData() *GitRepo { if len(widget.GitRepos) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.GitRepos) { return nil } return widget.GitRepos[widget.Idx] } func (widget *Widget) gitRepos(repoPaths []string) []*GitRepo { repos := []*GitRepo{} for _, repoPath := range repoPaths { if strings.HasSuffix(repoPath, string(os.PathSeparator)) { repos = append(repos, widget.findGitRepositories(make([]*GitRepo, 0), repoPath)...) } else { repo := NewGitRepo( repoPath, widget.settings.commitCount, widget.settings.commitFormat, widget.settings.dateFormat, ) repos = append(repos, repo) } } return repos } func (widget *Widget) findGitRepositories(repositories []*GitRepo, directory string) []*GitRepo { directory = strings.TrimSuffix(directory, string(os.PathSeparator)) files, err := os.ReadDir(directory) if err != nil { log.Fatal(err) } var path string for _, file := range files { if file.IsDir() { path = directory + string(os.PathSeparator) + file.Name() if file.Name() == ".git" { path = strings.TrimSuffix(path, string(os.PathSeparator)+".git") repo := NewGitRepo( path, widget.settings.commitCount, widget.settings.commitFormat, widget.settings.dateFormat, ) repositories = append(repositories, repo) continue } if file.Name() == "vendor" || file.Name() == "node_modules" { continue } repositories = widget.findGitRepositories(repositories, path) } } return repositories } ================================================ FILE: modules/github/display.go ================================================ package github import ( "fmt" ghb "github.com/google/go-github/v32/github" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { repo := widget.currentGithubRepo() username := widget.settings.username // Choses the correct place to scroll to when changing sources if len(widget.View.GetHighlights()) > 0 { widget.View.ScrollToHighlight() } else { widget.View.ScrollToBeginning() } // initial maxItems count widget.Items = make([]int, 0) widget.SetItemCount(len(repo.myReviewRequests((username)))) title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.title(repo)) if repo == nil { return title, " GitHub repo data is unavailable ", false } else if repo.Err != nil { return title, repo.Err.Error(), true } _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.GithubRepos), widget.Idx, width) if widget.settings.showStats { str += fmt.Sprintf("\n [%s]Stats[white]\n", widget.settings.Colors.Subheading) str += widget.displayStats(repo) } if widget.settings.showOpenReviewRequests { str += fmt.Sprintf("\n [%s]Open Review Requests[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyReviewRequests(repo, username) } if widget.settings.showMyPullRequests { str += fmt.Sprintf("\n [%s]My Pull Requests[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyPullRequests(repo, username) } for _, customQuery := range widget.settings.customQueries { str += fmt.Sprintf("\n [%s]%s[white]\n", widget.settings.Colors.Subheading, customQuery.title) str += widget.displayCustomQuery(repo, customQuery.filter, customQuery.perPage) } return title, str, false } func (widget *Widget) displayMyPullRequests(repo *Repo, username string) string { prs := repo.myPullRequests(username, widget.settings.enableStatus) prLength := len(prs) if prLength == 0 { return " [grey]none[white]\n" } maxItems := widget.GetItemCount() str := "" for idx, pr := range prs { str += fmt.Sprintf(` %s[green]["%d"]%4d[""][white] %s`, widget.mergeString(pr), maxItems+idx, *pr.Number, *pr.Title) str += "\n" widget.Items = append(widget.Items, *pr.Number) } widget.SetItemCount(maxItems + prLength) return str } func (widget *Widget) displayCustomQuery(repo *Repo, filter string, perPage int) string { res := repo.customIssueQuery(filter, perPage) if res == nil { return " [grey]Invalid Query[white]\n" } issuesLength := len(res.Issues) if issuesLength == 0 { return " [grey]none[white]\n" } maxItems := widget.GetItemCount() str := "" for idx, issue := range res.Issues { str += fmt.Sprintf(` [green]["%d"]%4d[""][white] %s`, maxItems+idx, *issue.Number, *issue.Title) str += "\n" widget.Items = append(widget.Items, *issue.Number) } widget.SetItemCount(maxItems + issuesLength) return str } func (widget *Widget) displayMyReviewRequests(repo *Repo, username string) string { prs := repo.myReviewRequests(username) if len(prs) == 0 { return " [grey]none[white]\n" } str := "" for idx, pr := range prs { str += fmt.Sprintf(` [green]["%d"]%4d[""][white] %s`, idx, *pr.Number, *pr.Title) str += "\n" widget.Items = append(widget.Items, *pr.Number) } return str } func (widget *Widget) displayStats(repo *Repo) string { locPrinter, err := widget.settings.LocalizedPrinter() if err != nil { return err.Error() } str := fmt.Sprintf( " PRs: %s Issues: %s Stars: %s\n", locPrinter.Sprintf("%d", repo.PullRequestCount()), locPrinter.Sprintf("%d", repo.IssueCount()), locPrinter.Sprintf("%d", repo.StarCount()), ) return str } func (widget *Widget) title(repo *Repo) string { return fmt.Sprintf( "[%s]%s - %s[white]", widget.settings.Colors.Title, repo.Owner, repo.Name, ) } var mergeIcons = map[string]string{ "dirty": "[red]\u0021[white] ", "clean": "[green]\u2713[white] ", "unstable": "[red]\u2717[white] ", "blocked": "[red]\u2717[white] ", } func (widget *Widget) mergeString(pr *ghb.PullRequest) string { if !widget.settings.enableStatus { return "" } if str, ok := mergeIcons[pr.GetMergeableState()]; ok { return str } return "? " } ================================================ FILE: modules/github/github_repo.go ================================================ package github import ( "context" "fmt" "net/http" ghb "github.com/google/go-github/v32/github" "github.com/wtfutil/wtf/utils" "golang.org/x/oauth2" ) const ( pullRequestsPath = "/pulls" issuesPath = "/issues" ) // Repo defines a new GitHub Repo structure type Repo struct { apiKey string baseURL string uploadURL string Name string Owner string PullRequests []*ghb.PullRequest RemoteRepo *ghb.Repository Err error } // NewGithubRepo returns a new Github Repo with a name, owner, apiKey, baseURL and uploadURL func NewGithubRepo(name, owner, apiKey, baseURL, uploadURL string) *Repo { repo := Repo{ Name: name, Owner: owner, apiKey: apiKey, baseURL: baseURL, uploadURL: uploadURL, } return &repo } // Open will open the GitHub Repo URL using the utils helper func (repo *Repo) Open() { utils.OpenFile(*repo.RemoteRepo.HTMLURL) } // OpenPulls will open the GitHub Pull Requests URL using the utils helper func (repo *Repo) OpenPulls() { utils.OpenFile(*repo.RemoteRepo.HTMLURL + pullRequestsPath) } // OpenIssues will open the GitHub Issues URL using the utils helper func (repo *Repo) OpenIssues() { utils.OpenFile(*repo.RemoteRepo.HTMLURL + issuesPath) } // Refresh reloads the github data via the Github API func (repo *Repo) Refresh() { prs, err := repo.loadPullRequests() repo.Err = err repo.PullRequests = prs if err != nil { return } remote, err := repo.loadRemoteRepository() repo.Err = err repo.RemoteRepo = remote } /* -------------------- Counts -------------------- */ // IssueCount return the total amount of issues as an int func (repo *Repo) IssueCount() int { if repo.RemoteRepo == nil { return 0 } issuesLessPulls := *repo.RemoteRepo.OpenIssuesCount - len(repo.PullRequests) return issuesLessPulls } // PullRequestCount returns the total amount of pull requests as an int func (repo *Repo) PullRequestCount() int { return len(repo.PullRequests) } // StarCount returns the total amount of stars this repo has gained as an int func (repo *Repo) StarCount() int { if repo.RemoteRepo == nil { return 0 } return *repo.RemoteRepo.StargazersCount } /* -------------------- Unexported Functions -------------------- */ func (repo *Repo) isGitHubEnterprise() bool { if len(repo.baseURL) > 0 { if repo.uploadURL == "" { repo.uploadURL = repo.baseURL } return true } return false } func (repo *Repo) oauthClient() *http.Client { tokenService := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: repo.apiKey}, ) return oauth2.NewClient(context.Background(), tokenService) } func (repo *Repo) githubClient() (*ghb.Client, error) { oauthClient := repo.oauthClient() if repo.isGitHubEnterprise() { return ghb.NewEnterpriseClient(repo.baseURL, repo.uploadURL, oauthClient) } return ghb.NewClient(oauthClient), nil } // myPullRequests returns a list of pull requests created by username on this repo func (repo *Repo) myPullRequests(username string, showStatus bool) []*ghb.PullRequest { prs := []*ghb.PullRequest{} for _, pr := range repo.PullRequests { user := *pr.User if *user.Login == username { prs = append(prs, pr) } } if showStatus { prs = repo.individualPRs(prs) } return prs } // individualPRs takes a list of pull requests (presumably returned from // github.PullRequests.List) and fetches them individually to get more detailed // status info on each. see: https://developer.github.com/v3/git/#checking-mergeability-of-pull-requests func (repo *Repo) individualPRs(prs []*ghb.PullRequest) []*ghb.PullRequest { github, err := repo.githubClient() if err != nil { return prs } var ret []*ghb.PullRequest for i := range prs { pr, _, err := github.PullRequests.Get(context.Background(), repo.Owner, repo.Name, prs[i].GetNumber()) if err != nil { // worst case, just keep the original one ret = append(ret, prs[i]) } else { ret = append(ret, pr) } } return ret } // myReviewRequests returns a list of pull requests for which username has been // requested to do a code review func (repo *Repo) myReviewRequests(username string) []*ghb.PullRequest { prs := []*ghb.PullRequest{} for _, pr := range repo.PullRequests { for _, reviewer := range pr.RequestedReviewers { if *reviewer.Login == username { prs = append(prs, pr) } } } return prs } func (repo *Repo) customIssueQuery(filter string, perPage int) *ghb.IssuesSearchResult { github, err := repo.githubClient() if err != nil { return nil } opts := &ghb.SearchOptions{} if perPage != 0 { opts.PerPage = perPage } prs, _, _ := github.Search.Issues(context.Background(), fmt.Sprintf("%s repo:%s/%s", filter, repo.Owner, repo.Name), opts) return prs } func (repo *Repo) loadPullRequests() ([]*ghb.PullRequest, error) { github, err := repo.githubClient() if err != nil { return nil, err } opts := &ghb.PullRequestListOptions{} opts.PerPage = 100 prs, _, err := github.PullRequests.List(context.Background(), repo.Owner, repo.Name, opts) if err != nil { return nil, err } return prs, nil } func (repo *Repo) loadRemoteRepository() (*ghb.Repository, error) { github, err := repo.githubClient() if err != nil { return nil, err } repository, _, err := github.Repositories.Get(context.Background(), repo.Owner, repo.Name) if err != nil { return nil, err } return repository, nil } ================================================ FILE: modules/github/keyboard.go ================================================ package github import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("o", widget.openRepo, "Open item in browser") widget.SetKeyboardChar("p", widget.openPulls, "Open pull requests in browser") widget.SetKeyboardChar("i", widget.openIssues, "Open issues in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") widget.SetKeyboardKey(tcell.KeyEnter, widget.openPr, "Open PR in browser") widget.SetKeyboardKey(tcell.KeyInsert, widget.openRepo, "Open item in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/github/settings.go ================================================ package github import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "GitHub" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"Your GitHub API token."` baseURL string `help:"Your GitHub Enterprise API URL." optional:"true"` customQueries []customQuery `help:"Custom queries allow you to filter pull requests and issues however you like. Give the query a title and a filter. Filters can be copied directly from GitHub’s UI." optional:"true"` enableStatus bool `help:"Display pull request mergeability status (‘dirty’, ‘clean’, ‘unstable’, ‘blocked’)." optional:"true"` repositories []string `help:"A list of github repositories." values:"Example: wtfutil/wtf"` showMyPullRequests bool `help:"Show my pull requests section" optional:"true"` showOpenReviewRequests bool `help:"Show open review requests section" optional:"true"` showStats bool `help:"Show repository stats section" optional:"true"` uploadURL string `help:"Your GitHub Enterprise upload URL (often the same as API URL)." optional:"true"` username string `help:"Your GitHub username. Used to figure out which review requests you’ve been added to."` } type customQuery struct { title string `help:"Display title for this query"` filter string `help:"Github query filter"` perPage int `help:"Number of issues to show"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_GITHUB_TOKEN"))), baseURL: ymlConfig.UString("baseURL", os.Getenv("WTF_GITHUB_BASE_URL")), enableStatus: ymlConfig.UBool("enableStatus", false), showMyPullRequests: ymlConfig.UBool("showMyPullRequests", true), showOpenReviewRequests: ymlConfig.UBool("showOpenReviewRequests", true), showStats: ymlConfig.UBool("showStats", true), uploadURL: ymlConfig.UString("uploadURL", os.Getenv("WTF_GITHUB_UPLOAD_URL")), username: ymlConfig.UString("username"), } settings.repositories = cfg.ParseAsMapOrList(ymlConfig, "repositories") settings.customQueries = parseCustomQueries(ymlConfig) cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.baseURL).Load() return &settings } /* -------------------- Unexported Functions -------------------- */ func parseCustomQueries(ymlConfig *config.Config) []customQuery { result := []customQuery{} if customQueries, err := ymlConfig.Map("customQueries"); err == nil { for _, query := range customQueries { c := customQuery{} for key, value := range query.(map[string]interface{}) { switch key { case "title": c.title = value.(string) case "filter": c.filter = value.(string) case "perPage": c.perPage = value.(int) } } if c.title != "" && c.filter != "" { result = append(result, c) } } } return result } ================================================ FILE: modules/github/widget.go ================================================ package github import ( "strconv" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { view.MultiSourceWidget view.TextWidget GithubRepos []*Repo settings *Settings Selected int maxItems int Items []int } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "repository", "repositories"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.GithubRepos = widget.buildRepoCollection(widget.settings.repositories) widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.SetDisplayFunction(widget.display) widget.Unselect() widget.Sources = widget.settings.repositories return &widget } /* -------------------- Exported Functions -------------------- */ // SetItemCount sets the amount of PRs RRs and other PRs throughout the widgets display creation func (widget *Widget) SetItemCount(items int) { widget.maxItems = items } // GetItemCount returns the amount of PRs RRs and other PRs calculated so far as an int func (widget *Widget) GetItemCount() int { return widget.maxItems } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } // Refresh reloads the github data via the Github API and reruns the display func (widget *Widget) Refresh() { for _, repo := range widget.GithubRepos { repo.Refresh() } widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) buildRepoCollection(repoData []string) []*Repo { githubRepos := []*Repo{} for _, repo := range repoData { split := strings.Split(repo, "/") owner, name := split[0], split[1] repo := NewGithubRepo( name, owner, widget.settings.apiKey, widget.settings.baseURL, widget.settings.uploadURL, ) githubRepos = append(githubRepos, repo) } return githubRepos } func (widget *Widget) currentGithubRepo() *Repo { if len(widget.GithubRepos) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.GithubRepos) { return nil } return widget.GithubRepos[widget.Idx] } func (widget *Widget) openPr() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && len(widget.Items) > 0 && currentSelection[0] != "" { url := (*widget.currentGithubRepo().RemoteRepo.HTMLURL + "/pull/" + strconv.Itoa(widget.Items[widget.Selected])) utils.OpenFile(url) } } func (widget *Widget) openRepo() { repo := widget.currentGithubRepo() if repo != nil { repo.Open() } } func (widget *Widget) openPulls() { repo := widget.currentGithubRepo() if repo != nil { repo.OpenPulls() } } func (widget *Widget) openIssues() { repo := widget.currentGithubRepo() if repo != nil { repo.OpenIssues() } } ================================================ FILE: modules/gitlab/display.go ================================================ package gitlab import ( "fmt" glab "gitlab.com/gitlab-org/api/client-go" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) displayError() { widget.Redraw(widget.contentError) } func (widget *Widget) contentError() (string, string, bool) { title := fmt.Sprintf("%s - Error", widget.CommonSettings().Title) if widget.configError != nil { return title, fmt.Sprintf("Error: \n [red]%v[white]", widget.configError), false } return title, "Error", false } func (widget *Widget) content() (string, string, bool) { project := widget.currentGitlabProject() if project == nil { return widget.CommonSettings().Title, " Gitlab project data is unavailable ", true } // initial maxItems count widget.Items = make([]ContentItem, 0) widget.SetItemCount(0) title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.title(project)) _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.GitlabProjects), widget.Idx, width) + "\n" str += fmt.Sprintf(" [%s]Stats[white]\n", widget.settings.Colors.Subheading) str += widget.displayStats(project) str += "\n" str += fmt.Sprintf(" [%s]Open Assigned Merge Requests[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyAssignedMergeRequests(project, widget.settings.username) str += "\n" str += fmt.Sprintf(" [%s]My Merge Requests[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyMergeRequests(project, widget.settings.username) str += "\n" str += fmt.Sprintf(" [%s]Open Assigned Issues[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyAssignedIssues(project, widget.settings.username) str += "\n" str += fmt.Sprintf(" [%s]My Issues[white]\n", widget.settings.Colors.Subheading) str += widget.displayMyIssues(project, widget.settings.username) return title, str, false } func (widget *Widget) displayMyMergeRequests(project *GitlabProject, username string) string { mrs := project.myMergeRequests() return widget.renderMergeRequests(mrs) } func (widget *Widget) displayMyAssignedMergeRequests(project *GitlabProject, username string) string { mrs := project.myAssignedMergeRequests() return widget.renderMergeRequests(mrs) } func (widget *Widget) displayMyAssignedIssues(project *GitlabProject, username string) string { issues := project.myAssignedIssues() return widget.renderIssues(issues) } func (widget *Widget) displayMyIssues(project *GitlabProject, username string) string { issues := project.myIssues() return widget.renderIssues(issues) } func (widget *Widget) renderMergeRequests(mrs []*glab.BasicMergeRequest) string { length := len(mrs) if length == 0 { return " [grey]none[white]\n" } maxItems := widget.GetItemCount() str := "" for idx, issue := range mrs { str += fmt.Sprintf(` [green]["%d"]%4d[""][white] %s`, maxItems+idx, issue.IID, issue.Title) str += "\n" widget.Items = append(widget.Items, ContentItem{Type: "MR", ID: issue.IID}) } widget.SetItemCount(maxItems + length) return str } func (widget *Widget) renderIssues(issues []*glab.Issue) string { length := len(issues) if length == 0 { return " [grey]none[white]\n" } maxItems := widget.GetItemCount() str := "" for idx, issue := range issues { str += fmt.Sprintf(` [green]["%d"]%4d[""][white] %s`, maxItems+idx, issue.IID, issue.Title) str += "\n" widget.Items = append(widget.Items, ContentItem{Type: "ISSUE", ID: issue.IID}) } widget.SetItemCount(maxItems + length) return str } func (widget *Widget) displayStats(project *GitlabProject) string { str := fmt.Sprintf( " MRs: %d Issues: %d Stars: %d\n", project.MergeRequestCount(), project.IssueCount(), project.StarCount(), ) return str } func (widget *Widget) title(project *GitlabProject) string { return fmt.Sprintf("[green]%s [white]", project.path) } ================================================ FILE: modules/gitlab/gitlab_project.go ================================================ package gitlab import ( glab "gitlab.com/gitlab-org/api/client-go" ) type context struct { client *glab.Client user *glab.User } func newContext(settings *Settings) (*context, error) { baseURL := settings.domain gitlabClient, _ := glab.NewClient(settings.apiKey, glab.WithBaseURL(baseURL)) user, _, err := gitlabClient.Users.CurrentUser() if err != nil { return nil, err } ctx := &context{ client: gitlabClient, user: user, } return ctx, nil } type GitlabProject struct { context *context path string MergeRequests []*glab.BasicMergeRequest AssignedMergeRequests []*glab.BasicMergeRequest AuthoredMergeRequests []*glab.BasicMergeRequest AssignedIssues []*glab.Issue AuthoredIssues []*glab.Issue RemoteProject *glab.Project } func NewGitlabProject(context *context, projectPath string) *GitlabProject { project := GitlabProject{ context: context, path: projectPath, } return &project } // Refresh reloads the gitlab data via the Gitlab API func (project *GitlabProject) Refresh() { project.MergeRequests, _ = project.loadMergeRequests() project.AssignedMergeRequests, _ = project.loadAssignedMergeRequests() project.AuthoredMergeRequests, _ = project.loadAuthoredMergeRequests() project.AssignedIssues, _ = project.loadAssignedIssues() project.AuthoredIssues, _ = project.loadAuthoredIssues() project.RemoteProject, _ = project.loadRemoteProject() } /* -------------------- Counts -------------------- */ func (project *GitlabProject) IssueCount() int { if project.RemoteProject == nil { return 0 } return project.RemoteProject.OpenIssuesCount } func (project *GitlabProject) MergeRequestCount() int { return len(project.MergeRequests) } func (project *GitlabProject) StarCount() int { if project.RemoteProject == nil { return 0 } return project.RemoteProject.StarCount } /* -------------------- Unexported Functions -------------------- */ // myMergeRequests returns a list of merge requests func (project *GitlabProject) myMergeRequests() []*glab.BasicMergeRequest { return project.AuthoredMergeRequests } // myAssignedMergeRequests returns a list of merge requests // assigned func (project *GitlabProject) myAssignedMergeRequests() []*glab.BasicMergeRequest { return project.AssignedMergeRequests } // myAssignedIssues returns a list of issues func (project *GitlabProject) myAssignedIssues() []*glab.Issue { return project.AssignedIssues } // myIssues returns a list of issues func (project *GitlabProject) myIssues() []*glab.Issue { return project.AuthoredIssues } func (project *GitlabProject) loadMergeRequests() ([]*glab.BasicMergeRequest, error) { state := "opened" opts := glab.ListProjectMergeRequestsOptions{ State: &state, } mrs, _, err := project.context.client.MergeRequests.ListProjectMergeRequests(project.path, &opts) if err != nil { return nil, err } return mrs, nil } func (project *GitlabProject) loadAssignedMergeRequests() ([]*glab.BasicMergeRequest, error) { state := "opened" opts := glab.ListProjectMergeRequestsOptions{ State: &state, AssigneeID: glab.AssigneeID(project.context.user.ID), } mrs, _, err := project.context.client.MergeRequests.ListProjectMergeRequests(project.path, &opts) if err != nil { return nil, err } return mrs, nil } func (project *GitlabProject) loadAuthoredMergeRequests() ([]*glab.BasicMergeRequest, error) { state := "opened" opts := glab.ListProjectMergeRequestsOptions{ State: &state, AuthorID: &project.context.user.ID, } mrs, _, err := project.context.client.MergeRequests.ListProjectMergeRequests(project.path, &opts) if err != nil { return nil, err } return mrs, nil } func (project *GitlabProject) loadAssignedIssues() ([]*glab.Issue, error) { state := "opened" opts := glab.ListProjectIssuesOptions{ State: &state, AssigneeID: &project.context.user.ID, } issues, _, err := project.context.client.Issues.ListProjectIssues(project.path, &opts) if err != nil { return nil, err } return issues, nil } func (project *GitlabProject) loadAuthoredIssues() ([]*glab.Issue, interface{}) { state := "opened" opts := glab.ListProjectIssuesOptions{ State: &state, AuthorID: &project.context.user.ID, } issues, _, err := project.context.client.Issues.ListProjectIssues(project.path, &opts) if err != nil { return nil, err } return issues, nil } func (project *GitlabProject) loadRemoteProject() (*glab.Project, error) { projectsitory, _, err := project.context.client.Projects.GetProject(project.path, nil) if err != nil { return nil, err } return projectsitory, nil } ================================================ FILE: modules/gitlab/keyboard.go ================================================ package gitlab import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("l", widget.NextSource, "Select next project") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous project") widget.SetKeyboardChar("o", widget.openRepo, "Open item in browser") widget.SetKeyboardChar("p", widget.openPulls, "Open merge requests in browser") widget.SetKeyboardChar("i", widget.openIssues, "Open issues in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next project") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous project") widget.SetKeyboardKey(tcell.KeyEnter, widget.openItemInBrowser, "Open item in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/gitlab/settings.go ================================================ package gitlab import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "GitLab" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"A GitLab personal access token. Requires at least api access."` domain string `help:"Your GitLab corporate domain."` projects []string `help:"A list of key/value pairs each describing a GitLab project to fetch data for." values:"Key: The name of the project. Value: The namespace of the project."` username string `help:"Your GitLab username. Used to figure out which requests require your approval"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_GITLAB_TOKEN"))), domain: ymlConfig.UString("domain", "https://gitlab.com"), username: ymlConfig.UString("username"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.domain).Load() settings.projects = cfg.ParseAsMapOrList(ymlConfig, "projects") return &settings } ================================================ FILE: modules/gitlab/widget.go ================================================ package gitlab import ( "strconv" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type ContentItem struct { Type string ID int } type Widget struct { view.MultiSourceWidget view.TextWidget GitlabProjects []*GitlabProject context *context settings *Settings Selected int maxItems int Items []ContentItem configError error } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { context, err := newContext(settings) widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "project", "projects"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), context: context, settings: settings, configError: err, } widget.GitlabProjects = widget.buildProjectCollection(context, settings.projects) widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.SetDisplayFunction(widget.display) widget.Unselect() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.context == nil || widget.configError != nil { widget.displayError() return } for _, project := range widget.GitlabProjects { project.Refresh() } widget.display() } // SetItemCount sets the amount of PRs RRs and other PRs throughout the widgets display creation func (widget *Widget) SetItemCount(items int) { widget.maxItems = items } // GetItemCount returns the amount of PRs RRs and other PRs calculated so far as an int func (widget *Widget) GetItemCount() int { return widget.maxItems } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) buildProjectCollection(context *context, projectData []string) []*GitlabProject { gitlabProjects := []*GitlabProject{} for _, projectPath := range projectData { project := NewGitlabProject(context, projectPath) gitlabProjects = append(gitlabProjects, project) } return gitlabProjects } func (widget *Widget) currentGitlabProject() *GitlabProject { if len(widget.GitlabProjects) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.GitlabProjects) { return nil } return widget.GitlabProjects[widget.Idx] } func (widget *Widget) openItemInBrowser() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && currentSelection[0] != "" { item := widget.Items[widget.Selected] url := "" project := widget.currentGitlabProject() if project == nil { // This is a problem. We will just bail out for now return } switch item.Type { case "MR": url = (project.RemoteProject.WebURL + "/merge_requests/" + strconv.Itoa(item.ID)) case "ISSUE": url = (project.RemoteProject.WebURL + "/issues/" + strconv.Itoa(item.ID)) } utils.OpenFile(url) } } func (widget *Widget) openRepo() { project := widget.currentGitlabProject() if project == nil { return } url := project.RemoteProject.WebURL utils.OpenFile(url) } func (widget *Widget) openPulls() { project := widget.currentGitlabProject() if project == nil { return } url := project.RemoteProject.WebURL + "/merge_requests/" utils.OpenFile(url) } func (widget *Widget) openIssues() { project := widget.currentGitlabProject() if project == nil { return } url := project.RemoteProject.WebURL + "/issues/" utils.OpenFile(url) } ================================================ FILE: modules/gitlabtodo/keyboard.go ================================================ package gitlabtodo import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openTodo, "Open todo in browser") widget.SetKeyboardChar("x", widget.markAsDone, "Mark todo as done") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openTodo, "Open todo in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/gitlabtodo/settings.go ================================================ package gitlabtodo import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "GitLab Todos" ) type Settings struct { *cfg.Common numberOfTodos int `help:"Defines number of stories to be displayed. Default is 10" optional:"true"` apiKey string `help:"A GitLab personal access token. Requires at least api access."` domain string `help:"Your GitLab corporate domain."` showProject bool `help:"Determines whether or not to show the project a given todo is for."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), numberOfTodos: ymlConfig.UInt("numberOfTodos", 10), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_GITLAB_TOKEN")), domain: ymlConfig.UString("domain", "https://gitlab.com"), showProject: ymlConfig.UBool("showProject", true), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.domain).Load() return &settings } ================================================ FILE: modules/gitlabtodo/widget.go ================================================ package gitlabtodo import ( "fmt" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "github.com/rivo/tview" glab "gitlab.com/gitlab-org/api/client-go" ) type Widget struct { view.ScrollableWidget todos []*glab.Todo gitlabClient *glab.Client settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.gitlabClient, _ = glab.NewClient(settings.apiKey, glab.WithBaseURL(settings.domain)) widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } todos, err := widget.getTodos() widget.todos = todos widget.err = err widget.SetItemCount(len(todos)) widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("GitLab ToDos (%d)", len(widget.todos)) if widget.err != nil { return title, widget.err.Error(), true } if widget.todos == nil { return title, "No ToDos to display", false } str := widget.contentFrom(widget.todos) return title, str, false } func (widget *Widget) getTodos() ([]*glab.Todo, error) { opts := glab.ListTodosOptions{} todos, _, err := widget.gitlabClient.Todos.ListTodos(&opts) if err != nil { return nil, err } return todos, nil } // trim the todo body so it fits on a single line func (widget *Widget) trimTodoBody(body string) string { r := []rune(body) // Cut at first occurrence of a newline for i, a := range r { if a == '\n' { return string(r[:i]) } } return body } func (widget *Widget) contentFrom(todos []*glab.Todo) string { var str string for idx, todo := range todos { row := fmt.Sprintf(`[%s]%2d. `, widget.RowColor(idx), idx+1) if widget.settings.showProject { row = fmt.Sprintf(`%s%s `, row, todo.Project.Path) } row = fmt.Sprintf(`%s[mediumpurple](%s)[%s] %s`, row, todo.Author.Username, widget.RowColor(idx), widget.trimTodoBody(todo.Body), ) str += utils.HighlightableHelper(widget.View, row, idx, len(todo.Body)) } return str } func (widget *Widget) markAsDone() { sel := widget.GetSelected() if sel >= 0 && widget.todos != nil && sel < len(widget.todos) { todo := widget.todos[sel] _, err := widget.gitlabClient.Todos.MarkTodoAsDone(todo.ID) if err == nil { widget.Refresh() } } } func (widget *Widget) openTodo() { sel := widget.GetSelected() if sel >= 0 && widget.todos != nil && sel < len(widget.todos) { todo := widget.todos[sel] utils.OpenFile(todo.TargetURL) } } ================================================ FILE: modules/gitter/client.go ================================================ package gitter import ( "fmt" "net/http" "strconv" "github.com/wtfutil/wtf/utils" ) func GetMessages(roomId string, numberOfMessages int, apiToken string) ([]Message, error) { var messages []Message resp, err := apiRequest("rooms/"+roomId+"/chatMessages?limit="+strconv.Itoa(numberOfMessages), apiToken) if err != nil { return nil, err } err = utils.ParseJSON(&messages, resp.Body) if err != nil { return nil, err } return messages, nil } func GetRoom(roomUri, apiToken string) (*Room, error) { var rooms Rooms resp, err := apiRequest("rooms?q="+roomUri, apiToken) if err != nil { return nil, err } err = utils.ParseJSON(&rooms, resp.Body) if err != nil { return nil, err } for _, room := range rooms.Results { if room.URI == roomUri { return &room, nil } } return nil, nil } /* -------------------- Unexported Functions -------------------- */ var ( apiBaseURL = "https://api.gitter.im/v1/" ) func apiRequest(path, apiToken string) (*http.Response, error) { req, err := http.NewRequest("GET", apiBaseURL+path, http.NoBody) if err != nil { return nil, err } bearer := fmt.Sprintf("Bearer %s", apiToken) req.Header.Add("Authorization", bearer) httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } return resp, nil } ================================================ FILE: modules/gitter/gitter.go ================================================ package gitter import "time" type Rooms struct { Results []Room `json:"results"` } type Room struct { ID string `json:"id"` Name string `json:"name"` URI string `json:"uri"` } type User struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` } type Message struct { ID string `json:"id"` Text string `json:"text"` HTML string `json:"html"` Sent time.Time `json:"sent"` From User `json:"fromUser"` Unread bool `json:"unread"` } ================================================ FILE: modules/gitter/keyboard.go ================================================ package gitter import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/gitter/settings.go ================================================ package gitter import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Gitter" ) type Settings struct { *cfg.Common apiToken string `help:"Your Gitter Personal Access Token."` numberOfMessages int `help:"Maximum number of (newest) messages to be displayed. Default is 10" optional:"true"` roomURI string `help:"The room you want to display." values:"Example: wtfutil/Lobby"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiToken: ymlConfig.UString("apiToken", os.Getenv("WTF_GITTER_API_TOKEN")), numberOfMessages: ymlConfig.UInt("numberOfMessages", 10), roomURI: ymlConfig.UString("roomUri", "wtfutil/Lobby"), } cfg.ModuleSecret(name, globalConfig, &settings.apiToken).Load() return &settings } ================================================ FILE: modules/gitter/widget.go ================================================ package gitter import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // A Widget represents a Gitter widget type Widget struct { view.ScrollableWidget messages []Message settings *Settings } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Refresh) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } room, err := GetRoom(widget.settings.roomURI, widget.settings.apiToken) if err != nil { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, err.Error(), true }) return } if room == nil { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, "No room", true }) return } messages, err := GetMessages(room.ID, widget.settings.numberOfMessages, widget.settings.apiToken) if err != nil { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, err.Error(), true }) return } widget.messages = messages widget.SetItemCount(len(messages)) widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.settings.roomURI) if len(widget.messages) == 0 { return title, "No Messages To Display", false } var str string for idx, message := range widget.messages { row := fmt.Sprintf( `[%s] [blue]%s [lightslategray]%s: [%s]%s [aqua]%s`, widget.RowColor(idx), message.From.DisplayName, message.From.Username, widget.RowColor(idx), message.Text, message.Sent.Format("Jan 02, 15:04 MST"), ) str += utils.HighlightableHelper(widget.View, row, idx, len(message.Text)) } return title, str, true } ================================================ FILE: modules/googleanalytics/client.go ================================================ package googleanalytics import ( "context" "fmt" "log" "net/http" "os" "path/filepath" "time" "github.com/wtfutil/wtf/utils" "golang.org/x/oauth2/google" gaV3 "google.golang.org/api/analytics/v3" gaV4 "google.golang.org/api/analyticsreporting/v4" "google.golang.org/api/option" ) type websiteReport struct { Name string Report *gaV4.GetReportsResponse RealtimeReport *gaV3.RealtimeData } func (widget *Widget) fetch() []websiteReport { secretPath, err := utils.ExpandHomeDir(widget.settings.secretFile) if err != nil { log.Fatalf("Unable to parse secretFile path") } serviceV4, err := makeReportServiceV4(secretPath) if err != nil { log.Fatalf("Unable to create v3 Google Analytics Reporting Service") } var serviceV3 *gaV3.Service if widget.settings.enableRealtime { serviceV3, err = makeReportServiceV3(secretPath) if err != nil { log.Fatalf("Unable to create v3 Google Analytics Reporting Service") } } visitorsDataArray := getReports( serviceV4, widget.settings.viewIds, widget.settings.months, serviceV3, ) return visitorsDataArray } func buildNetClient(secretPath string) *http.Client { clientSecret, err := os.ReadFile(filepath.Clean(secretPath)) if err != nil { log.Fatalf("Unable to read secretPath. %v", err) } jwtConfig, err := google.JWTConfigFromJSON(clientSecret, gaV4.AnalyticsReadonlyScope) if err != nil { log.Fatalf("Unable to get config from JSON. %v", err) } return jwtConfig.Client(context.Background()) } func makeReportServiceV3(secretPath string) (*gaV3.Service, error) { client := buildNetClient(secretPath) svc, err := gaV3.NewService(context.Background(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Failed to create v3 Google Analytics Reporting Service") } return svc, err } func makeReportServiceV4(secretPath string) (*gaV4.Service, error) { client := buildNetClient(secretPath) svc, err := gaV4.NewService(context.Background(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Failed to create v4 Google Analytics Reporting Service") } return svc, err } func getReports( serviceV4 *gaV4.Service, viewIds map[string]interface{}, displayedMonths int, serviceV3 *gaV3.Service, ) []websiteReport { startDate := fmt.Sprintf("%s-01", time.Now().AddDate(0, -displayedMonths+1, 0).Format("2006-01")) var websiteReports []websiteReport for website, viewID := range viewIds { // For custom queries: https://ga-dev-tools.appspot.com/dimensions-metrics-explorer/ req := &gaV4.GetReportsRequest{ ReportRequests: []*gaV4.ReportRequest{ { ViewId: viewID.(string), DateRanges: []*gaV4.DateRange{ {StartDate: startDate, EndDate: "today"}, }, Metrics: []*gaV4.Metric{ {Expression: "ga:sessions"}, }, Dimensions: []*gaV4.Dimension{ {Name: "ga:month"}, }, }, }, } response, err := serviceV4.Reports.BatchGet(req).Do() if err != nil { log.Fatalf("GET request to analyticsreporting/v4 returned error with viewID: %s", viewID) } if response.HTTPStatusCode != 200 { log.Fatalf("Did not get expected HTTP response code") } report := websiteReport{Name: website, Report: response} if serviceV3 != nil { report.RealtimeReport = getLiveCount(serviceV3, viewID.(string)) } websiteReports = append(websiteReports, report) } return websiteReports } func getLiveCount(service *gaV3.Service, viewID string) *gaV3.RealtimeData { res, err := service.Data.Realtime.Get("ga:"+viewID, "rt:activeUsers").Do() if err != nil { log.Fatalf("Failed to fetch real time data for view ID %s: %v. Have you enrolled in the real time beta? If not, do so here: https://docs.google.com/forms/d/1qfRFysCikpgCMGqgF3yXdUyQW4xAlLyjKuOoOEFN2Uw/viewform", viewID, err) } return res } ================================================ FILE: modules/googleanalytics/display.go ================================================ package googleanalytics import ( "fmt" "strings" "time" ) func (widget *Widget) createTable(websiteReports []websiteReport) string { content := "" if len(websiteReports) == 0 { return content } if websiteReports[0].RealtimeReport != nil { content += "Realtime Visitor Counts\n" for _, websiteReport := range websiteReports { websiteRow := fmt.Sprintf(" %-20s", websiteReport.Name) if websiteReport.RealtimeReport == nil { websiteRow += "No data found for given ViewId" } else { if len(websiteReport.RealtimeReport.Rows) == 0 { websiteRow += "-" } else { websiteRow += fmt.Sprintf("%-10s", websiteReport.RealtimeReport.Rows[0][0]) } } content += websiteRow + "\n" } content += "\n" content += "Historical Visitor Counts\n" } content += widget.createHeader() for _, websiteReport := range websiteReports { websiteRow := "" for _, report := range websiteReport.Report.Reports { websiteRow += fmt.Sprintf(" %-20s", websiteReport.Name) reportRows := report.Data.Rows noDataMonth := widget.settings.months - len(reportRows) // Fill in requested months with no data from query if noDataMonth > 0 { websiteRow += strings.Repeat("- ", noDataMonth) } if reportRows == nil { websiteRow += "No data found for given ViewId" } else { for _, row := range reportRows { metrics := row.Metrics for _, metric := range metrics { websiteRow += fmt.Sprintf("%-10s", metric.Values[0]) } } } content += websiteRow + "\n" } } return content } func (widget *Widget) createHeader() string { // Creates the table header of consisting of Months currentMonth := int(time.Now().Month()) widgetStartMonth := currentMonth - widget.settings.months + 1 header := " " for i := widgetStartMonth; i < currentMonth+1; i++ { header += fmt.Sprintf("%-10s", time.Month(i)) } header += "\n" return header } ================================================ FILE: modules/googleanalytics/settings.go ================================================ package googleanalytics import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Google Analytics" ) type Settings struct { *cfg.Common months int secretFile string `help:"Your Google client secret JSON file." values:"A string representing a file path to the JSON secret file."` viewIds map[string]interface{} enableRealtime bool } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), months: ymlConfig.UInt("months"), secretFile: ymlConfig.UString("secretFile"), viewIds: ymlConfig.UMap("viewIds"), enableRealtime: ymlConfig.UBool("enableRealtime", false), } settings.SetDocumentationPath("google/analytics") return &settings } ================================================ FILE: modules/googleanalytics/widget.go ================================================ package googleanalytics import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } func (widget *Widget) Refresh() { websiteReports := widget.fetch() contentTable := widget.createTable(websiteReports) widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, contentTable, false }) } ================================================ FILE: modules/grafana/client.go ================================================ package grafana import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "sort" "github.com/wtfutil/wtf/utils" ) type AlertState int const ( Alerting AlertState = iota Pending NoData Paused Ok ) var toString = map[AlertState]string{ Alerting: "alerting", Pending: "pending", NoData: "no_data", Paused: "paused", Ok: "ok", } var toID = map[string]AlertState{ "alerting": Alerting, "pending": Pending, "no_data": NoData, "paused": Paused, "ok": Ok, } // MarshalJSON marshals the enum as a quoted json string func (s AlertState) MarshalJSON() ([]byte, error) { buffer := bytes.NewBufferString(`"`) buffer.WriteString(toString[s]) buffer.WriteString(`"`) return buffer.Bytes(), nil } // UnmarshalJSON unmashals a quoted json string to the enum value func (s *AlertState) UnmarshalJSON(b []byte) error { var j string err := json.Unmarshal(b, &j) if err != nil { return err } // if we somehow get an invalid value we'll end up in the alerting state *s = toID[j] return nil } type Alert struct { Name string `json:"name"` State AlertState `json:"state"` URL string `json:"url"` } type Client struct { apiKey string baseURI string } func NewClient(settings *Settings) *Client { return &Client{ apiKey: settings.apiKey, baseURI: settings.baseURI, } } func (client *Client) Alerts() ([]Alert, error) { // query the alerts API of Grafana https://grafana.com/docs/grafana/latest/http_api/alerting/ req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/alerts", client.baseURI), http.NoBody) if err != nil { return nil, err } if client.apiKey != "" { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.apiKey)) } res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer func() { _ = res.Body.Close() }() if res.StatusCode != 200 { msg := struct { Msg string `json:"message"` }{} err = utils.ParseJSON(&msg, res.Body) if err != nil { return nil, err } return nil, errors.New(msg.Msg) } var out []Alert err = utils.ParseJSON(&out, res.Body) if err != nil { return nil, err } sort.SliceStable(out, func(i, j int) bool { return out[i].State < out[j].State }) return out, nil } ================================================ FILE: modules/grafana/display.go ================================================ package grafana import "fmt" func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title var out string if widget.Err != nil { return title, widget.Err.Error(), false } else { for idx, alert := range widget.Alerts { out += fmt.Sprintf(` ["%d"][%s]%s - %s[""]`, idx, stateColor(alert.State), stateToEmoji(alert.State), alert.Name, ) out += "\n" } } return title, out, false } func stateColor(state AlertState) string { switch state { case Ok: return "green" case Paused: return "yellow" case Alerting: return "red" case Pending: return "orange" case NoData: return "yellow" default: return "white" } } func stateToEmoji(state AlertState) string { switch state { case Ok: return "✔" case Paused: return "⏸" case Alerting: return "✘" case Pending: return "?" case NoData: return "?" } return "" } ================================================ FILE: modules/grafana/keyboard.go ================================================ package grafana import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous alert") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next alert") widget.SetKeyboardKey(tcell.KeyEnter, widget.openAlert, "Open alert in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/grafana/settings.go ================================================ package grafana import ( "log" "os" "strings" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Grafana" ) type Settings struct { *cfg.Common apiKey string `help:"Your Grafana API token."` baseURI string `help:"Base url of your grafana instance"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_GRAFANA_API_KEY")), baseURI: ymlConfig.UString("baseUri", ""), } if settings.baseURI == "" { log.Fatal("baseUri for grafana is empty, but is required") } settings.baseURI = strings.TrimSuffix(settings.baseURI, "/") return &settings } ================================================ FILE: modules/grafana/widget.go ================================================ package grafana import ( "fmt" "strconv" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget Client *Client Alerts []Alert Err error Selected int settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), Client: NewClient(settings), Selected: -1, settings: settings, } widget.initializeKeyboardControls() widget.View.SetRegions(true) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { alerts, err := widget.Client.Alerts() if err != nil { widget.Err = err widget.Alerts = nil } else { widget.Err = nil widget.Alerts = alerts } widget.Redraw(widget.content) } // GetSelected returns the index of the currently highlighted item as an int func (widget *Widget) GetSelected() int { if widget.Selected < 0 { return 0 } return widget.Selected } // Next cycles the currently highlighted text down func (widget *Widget) Next() { widget.Selected++ if widget.Selected >= len(widget.Alerts) { widget.Selected = 0 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Prev cycles the currently highlighted text up func (widget *Widget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = len(widget.Alerts) - 1 } widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } // Unselect stops highlighting the text and jumps the scroll position to the top func (widget *Widget) Unselect() { widget.Selected = -1 widget.View.Highlight() widget.View.ScrollToBeginning() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) openAlert() { currentSelection := widget.View.GetHighlights() if widget.Selected >= 0 && currentSelection[0] != "" { url := widget.Alerts[widget.GetSelected()].URL if url[0] == '/' { url = fmt.Sprintf("%s%s", widget.settings.baseURI, url) } utils.OpenFile(url) } } ================================================ FILE: modules/gspreadsheets/client.go ================================================ /* * This butt-ugly code is direct from Google itself * https://developers.google.com/sheets/api/quickstart/go */ package gspreadsheets import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "os/user" "path/filepath" "github.com/wtfutil/wtf/utils" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/option" sheets "google.golang.org/api/sheets/v4" ) /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Fetch() ([]*sheets.ValueRange, error) { ctx := context.Background() secretPath, _ := utils.ExpandHomeDir(widget.settings.secretFile) b, err := os.ReadFile(filepath.Clean(secretPath)) if err != nil { log.Fatalf("Unable to read secretPath. %v", err) return nil, err } config, err := google.ConfigFromJSON(b, "https://www.googleapis.com/auth/spreadsheets.readonly") if err != nil { return nil, err } client := getClient(ctx, config) srv, err := sheets.NewService(context.Background(), option.WithHTTPClient(client)) if err != nil { return nil, err } cells := utils.ToStrs(widget.settings.cellAddresses) responses := make([]*sheets.ValueRange, len(cells)) for i := 0; i < len(cells); i++ { resp, getErr := srv.Spreadsheets.Values.Get(widget.settings.sheetID, cells[i]).Do() if getErr != nil { return nil, getErr } responses[i] = resp } return responses, err } /* -------------------- Unexported Functions -------------------- */ // getClient uses a Context and Config to retrieve a Token // then generate a Client. It returns the generated Client. func getClient(ctx context.Context, config *oauth2.Config) *http.Client { cacheFile, err := tokenCacheFile() if err != nil { log.Fatalf("Unable to get path to cached credential file. %v", err) } tok, err := tokenFromFile(cacheFile) if err != nil { tok = getTokenFromWeb(config) saveToken(cacheFile, tok) } return config.Client(ctx, tok) } // getTokenFromWeb uses Config to request a Token. // It returns the retrieved Token. func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) fmt.Printf("Go to the following link in your browser then type the "+ "authorization code: \n%v\n", authURL) var code string if _, err := fmt.Scan(&code); err != nil { log.Fatalf("Unable to read authorization code %v", err) } tok, err := config.Exchange(context.Background(), code) if err != nil { log.Fatalf("Unable to retrieve token from web %v", err) } return tok } // tokenCacheFile generates credential file path/filename. // It returns the generated credential path/filename. func tokenCacheFile() (string, error) { usr, err := user.Current() if err != nil { return "", err } tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials") err = os.MkdirAll(tokenCacheDir, 0700) if err != nil { return "", err } return filepath.Join(tokenCacheDir, url.QueryEscape("spreadsheets-go-quickstart.json")), err } // tokenFromFile retrieves a Token from a given file path. // It returns the retrieved Token and any read error encountered. func tokenFromFile(file string) (*oauth2.Token, error) { f, err := os.Open(filepath.Clean(file)) if err != nil { return nil, err } t := &oauth2.Token{} err = json.NewDecoder(f).Decode(t) defer func() { _ = f.Close() }() return t, err } // saveToken uses a file path to create a file and store the // token in it. func saveToken(file string, token *oauth2.Token) { fmt.Printf("Saving credential file to: %s\n", file) f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { log.Fatalf("Unable to cache oauth token: %v", err) } defer func() { _ = f.Close() }() err = json.NewEncoder(f).Encode(token) if err != nil { log.Fatalf("Unable to encode oauth token: %v", err) } } ================================================ FILE: modules/gspreadsheets/settings.go ================================================ package gspreadsheets import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Google Spreadsheets" ) type colors struct { values string } type Settings struct { colors *cfg.Common cellAddresses []interface{} cellNames []interface{} secretFile string sheetID string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), cellNames: ymlConfig.UList("cells.names"), secretFile: ymlConfig.UString("secretFile"), sheetID: ymlConfig.UString("sheetId"), } settings.values = ymlConfig.UString("colors.values", "green") settings.SetDocumentationPath("google/spreadsheet") return &settings } ================================================ FILE: modules/gspreadsheets/widget.go ================================================ package gspreadsheets import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" sheets "google.golang.org/api/sheets/v4" ) type Widget struct { view.TextWidget settings *Settings cells []*sheets.ValueRange err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { cells, err := widget.Fetch() widget.err = err widget.cells = cells widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } if widget.cells == nil { return title, "No cells", false } res := "" cells := utils.ToStrs(widget.settings.cellNames) for i := 0; i < len(widget.cells); i++ { res += fmt.Sprintf("%s\t[%s]%s\n", cells[i], widget.settings.values, widget.cells[i].Values[0][0]) } return title, res, false } ================================================ FILE: modules/hackernews/client.go ================================================ package hackernews import ( "bytes" "fmt" "io" "net/http" "strconv" "strings" "github.com/wtfutil/wtf/utils" ) func GetStories(storyType string) ([]int, error) { var storyIds []int switch strings.ToLower(storyType) { case "new", "top", "job", "ask": resp, err := apiRequest(storyType + "stories") if err != nil { return storyIds, err } err = utils.ParseJSON(&storyIds, bytes.NewReader(resp)) if err != nil { return storyIds, err } } return storyIds, nil } func GetStory(id int) (Story, error) { var story Story resp, err := apiRequest("item/" + strconv.Itoa(id)) if err != nil { return story, err } err = utils.ParseJSON(&story, bytes.NewReader(resp)) if err != nil { return story, err } return story, nil } /* -------------------- Unexported Functions -------------------- */ var ( apiEndpoint = "https://hacker-news.firebaseio.com/v0/" ) func apiRequest(path string) ([]byte, error) { req, err := http.NewRequest("GET", apiEndpoint+path+".json", http.NoBody) if err != nil { return nil, err } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } ================================================ FILE: modules/hackernews/keyboard.go ================================================ package hackernews import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openStory, "Open story in browser") widget.SetKeyboardChar("c", widget.openComments, "Open comments in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openStory, "Open story in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/hackernews/settings.go ================================================ package hackernews import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "HackerNews" ) type Settings struct { *cfg.Common numberOfStories int `help:"Defines number of stories to be displayed. Default is 10" optional:"true"` storyType string `help:"Category of story to see" values:"new, top, job, ask" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), numberOfStories: ymlConfig.UInt("numberOfStories", 10), storyType: ymlConfig.UString("storyType", "top"), } return &settings } ================================================ FILE: modules/hackernews/story.go ================================================ package hackernews import "fmt" const ( hnStoryPath = "https://news.ycombinator.com/item?id=" ) // Story represents a story submission on HackerNews type Story struct { By string `json:"by"` Descendants int `json:"descendants"` ID int `json:"id"` Kids []int `json:"kids"` Score int `json:"score"` Time int `json:"time"` Title string `json:"title"` Type string `json:"type"` URL string `json:"url"` } // CommentLink return the link to the HackerNews story comments page func (story *Story) CommentLink() string { return fmt.Sprintf("%s%d", hnStoryPath, story.ID) } // Link returns the link to a story. If the story has an external link, that is returned // If the story has no external link, the HackerNews comments link is returned instead func (story *Story) Link() string { if story.URL != "" { return story.URL } // Fall back to the HackerNews comment link return story.CommentLink() } ================================================ FILE: modules/hackernews/story_test.go ================================================ package hackernews import ( "testing" "gotest.tools/assert" ) func Test_CommentLink(t *testing.T) { story := Story{ ID: 3, } assert.Equal(t, "https://news.ycombinator.com/item?id=3", story.CommentLink()) } func Test_Link(t *testing.T) { tests := []struct { name string id int url string expected string }{ { name: "no external link", id: 1, url: "", expected: "https://news.ycombinator.com/item?id=1", }, { name: "with external link", id: 1, url: "https://www.link.ca", expected: "https://www.link.ca", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { story := Story{ ID: tt.id, URL: tt.url, } actual := story.Link() assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: modules/hackernews/widget.go ================================================ package hackernews import ( "fmt" "net/url" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget stories []Story settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } storyIds, err := GetStories(widget.settings.storyType) if err != nil { widget.err = err widget.stories = nil widget.SetItemCount(0) } else { var stories []Story for idx := 0; idx < widget.settings.numberOfStories; idx++ { story, e := GetStory(storyIds[idx]) if e == nil { stories = append(stories, story) } } widget.stories = stories widget.SetItemCount(len(stories)) } widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - %s stories", widget.CommonSettings().Title, widget.settings.storyType) if widget.err != nil { return title, widget.err.Error(), true } if len(widget.stories) == 0 { return title, "No stories to display", false } var str string for idx, story := range widget.stories { u, _ := url.Parse(story.URL) row := fmt.Sprintf( `[%s]%2d. %s [lightblue](%s)[white]`, widget.RowColor(idx), idx+1, story.Title, strings.TrimPrefix(u.Host, "www."), ) str += utils.HighlightableHelper(widget.View, row, idx, len(story.Title)) } return title, str, false } func (widget *Widget) openComments() { story := widget.selectedStory() if story != nil { utils.OpenFile(story.CommentLink()) } } func (widget *Widget) openStory() { story := widget.selectedStory() if story != nil { utils.OpenFile(story.Link()) } } func (widget *Widget) selectedStory() *Story { var story *Story sel := widget.GetSelected() if sel >= 0 && widget.stories != nil && sel < len(widget.stories) { story = &widget.stories[sel] } return story } ================================================ FILE: modules/healthchecks/keyboard.go ================================================ package healthchecks func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) } ================================================ FILE: modules/healthchecks/settings.go ================================================ package healthchecks import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "Healthchecks.io" ) type Settings struct { *cfg.Common apiKey string `help:"An healthchecks API key." optional:"false"` apiURL string `help:"Base URL for API" optional:"true"` tags []string `help:"Filters the checks and returns only the checks that are tagged with the specified value"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_HEALTHCHECKS_APIKEY")), apiURL: ymlConfig.UString("apiURL", "https://hc-ping.com/"), tags: utils.ToStrs(ymlConfig.UList("tags")), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.apiURL).Load() return &settings } ================================================ FILE: modules/healthchecks/widget.go ================================================ package healthchecks import ( "fmt" "net/http" "net/url" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( userAgent = "WTFUtil" ) type Widget struct { view.ScrollableWidget checks []Checks settings *Settings err error } type Health struct { Checks []Checks `json:"checks"` } type Checks struct { Name string `json:"name"` Tags string `json:"tags"` Desc string `json:"desc"` Grace int `json:"grace"` NPings int `json:"n_pings"` Status string `json:"status"` LastPing time.Time `json:"last_ping"` NextPing time.Time `json:"next_ping"` ManualResume bool `json:"manual_resume"` Methods string `json:"methods"` PingURL string `json:"ping_url"` UpdateURL string `json:"update_url"` PauseURL string `json:"pause_url"` Channels string `json:"channels"` Timeout int `json:"timeout,omitempty"` Schedule string `json:"schedule,omitempty"` Tz string `json:"tz,omitempty"` } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } checks, err := widget.getExistingChecks() widget.checks = checks widget.err = err widget.SetItemCount(len(checks)) widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { numUp := 0 for _, check := range widget.checks { if check.Status == "up" { numUp++ } } title := fmt.Sprintf("%v (%d/%d)", widget.CommonSettings().Title, numUp, len(widget.checks)) if widget.err != nil { return title, widget.err.Error(), true } if widget.checks == nil { return title, "No checks to display", false } str := widget.contentFrom(widget.checks) return title, str, false } func (widget *Widget) contentFrom(checks []Checks) string { var str string for _, check := range checks { prefix := "" switch check.Status { case "up": prefix += "[green] + " case "down": prefix += "[red] - " case "paused", "new": prefix += "[lightgray] × " default: prefix += "[yellow] ~ " } str += fmt.Sprintf(`%s%s [gray](%s|%d)[white]%s`, prefix, check.Name, timeSincePing(check.LastPing), check.NPings, "\n", ) } return str } func timeSincePing(ts time.Time) string { dur := time.Since(ts) return dur.Truncate(time.Second).String() } func makeURL(baseurl string, path string, tags []string) (string, error) { u, err := url.Parse(baseurl) if err != nil { return "", err } u.Path = path q := u.Query() // If we have several tags if len(tags) > 0 { for _, tag := range tags { q.Add("tag", tag) } u.RawQuery = q.Encode() } return u.String(), nil } func (widget *Widget) getExistingChecks() ([]Checks, error) { // See: https://healthchecks.io/docs/api/#list-checks u, err := makeURL(widget.settings.apiURL, "/api/v1/checks/", widget.settings.tags) if err != nil { return nil, err } req, err := http.NewRequest("GET", u, http.NoBody) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Api-Key", widget.settings.apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } defer func() { _ = resp.Body.Close() }() var health Health err = utils.ParseJSON(&health, resp.Body) if err != nil { return nil, err } return health.Checks, nil } ================================================ FILE: modules/hibp/client.go ================================================ package hibp import ( "encoding/json" "errors" "fmt" "io" "net/http" "time" ) const ( apiURL = "https://haveibeenpwned.com/api/v3/breachedaccount/" clientTimeoutSecs = 2 userAgent = "WTFUtil" ) type hibpError struct { StatusCode int `json:"statusCode"` Message string `json:"message"` } func (widget *Widget) fullURL(account string, truncated bool) string { truncStr := "false" if truncated { truncStr = "true" } return apiURL + account + fmt.Sprintf("?truncateResponse=%s", truncStr) } func (widget *Widget) fetchForAccount(account string, since string) (*Status, error) { if account == "" { return nil, nil } hibpClient := http.Client{ Timeout: time.Second * clientTimeoutSecs, } asTruncated := since == "" request, err := http.NewRequest(http.MethodGet, widget.fullURL(account, asTruncated), http.NoBody) if err != nil { return nil, err } request.Header.Set("User-Agent", userAgent) request.Header.Set("hibp-api-key", widget.settings.apiKey) response, getErr := hibpClient.Do(request) if getErr != nil { return nil, err } body, readErr := io.ReadAll(response.Body) if readErr != nil { return nil, err } hibpErr := widget.validateHTTPResponse(response.StatusCode, body) if hibpErr != nil { return nil, errors.New(hibpErr.Message) } stat, err := widget.parseResponseBody(account, body) if err != nil { return nil, err } return stat, nil } func (widget *Widget) parseResponseBody(account string, body []byte) (*Status, error) { breaches := []Breach{} stat := NewStatus(account, breaches) if len(body) == 0 { // If the body is empty then there's no breaches return stat, nil } jsonErr := json.Unmarshal(body, &breaches) if jsonErr != nil { return stat, jsonErr } breaches = widget.filterBreaches(breaches) stat.Breaches = breaches return stat, nil } func (widget *Widget) filterBreaches(breaches []Breach) []Breach { // If there's no valid since value in the settings, there's no point in trying to filter // the breaches on that value, they'll all pass if !widget.settings.HasSince() { return breaches } sinceDate, err := widget.settings.SinceDate() if err != nil { return breaches } latestBreaches := []Breach{} for _, breach := range breaches { breachDate, err := breach.BreachDate() if err != nil { // Append the erring breach here because a failing breach date doesn't mean that // the breach itself isn't applicable. The date could be missing or malformed, // in which case we err on the side of caution and assume that the breach is valid latestBreaches = append(latestBreaches, breach) continue } if breachDate.After(sinceDate) { latestBreaches = append(latestBreaches, breach) } } return latestBreaches } func (widget *Widget) validateHTTPResponse(responseCode int, body []byte) *hibpError { hibpErr := &hibpError{} switch responseCode { case 401, 402: err := json.Unmarshal(body, hibpErr) if err != nil { return nil } default: hibpErr = nil } return hibpErr } ================================================ FILE: modules/hibp/hibp_breach.go ================================================ package hibp import "time" // Breach represents a breach in the HIBP system type Breach struct { Date string `json:"BreachDate"` Name string `json:"Name"` } // BreachDate returns the date of the breach func (br *Breach) BreachDate() (time.Time, error) { dt, err := time.Parse("2006-01-02", br.Date) if err != nil { // I would much rather return (nil, err) err but that doesn't seem possible // Not sure what a better value would be return time.Now(), err } return dt, nil } ================================================ FILE: modules/hibp/hibp_status.go ================================================ package hibp // Status represents the status of an account in the HIBP system type Status struct { Account string Breaches []Breach } // NewStatus creates and returns an instance of Status func NewStatus(acct string, breaches []Breach) *Status { stat := Status{ Account: acct, Breaches: breaches, } return &stat } // HasBeenCompromised returns TRUE if the specified account has any breaches associated // with it, FALSE if no breaches are associated with it func (stat *Status) HasBeenCompromised() bool { return stat.Len() > 0 } // Len returns the number of breaches found for the specified account func (stat *Status) Len() int { if stat == nil || stat.Breaches == nil { return 0 } return len(stat.Breaches) } ================================================ FILE: modules/hibp/settings.go ================================================ package hibp import ( "os" "time" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = false defaultTitle = "HIBP" minRefreshInterval = 6 * time.Hour ) type colors struct { ok string pwned string } // Settings defines the configuration properties for this module type Settings struct { colors *cfg.Common accounts []string `help:"A list of the accounts to check the HIBP database for."` apiKey string `help:"Your HIBP API v3 API key"` since string `help:"Only check for breaches after this date. Set this if you’ve been breached in the past, have taken steps to mitigate that (changing passwords, cancelling accounts, etc.) and now only want to know about future breaches." values:"A date string in the format 'yyyy-mm-dd', ie. '2019-06-22'" optional:"true"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := &Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_HIBP_TOKEN"))), accounts: utils.ToStrs(ymlConfig.UList("accounts")), since: ymlConfig.UString("since", ""), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() settings.ok = ymlConfig.UString("colors.ok", "white") settings.pwned = ymlConfig.UString("colors.pwned", "red") // HIBP data doesn't need to be reloaded very often so to be gentle on this API we // enforce a minimum refresh interval if settings.RefreshInterval < minRefreshInterval { settings.RefreshInterval = minRefreshInterval } return settings } // HasSince returns TRUE if there's a valid "since" value setting, FALSE if there is not func (sett *Settings) HasSince() bool { if sett.since == "" { return false } _, err := sett.SinceDate() return err == nil } // SinceDate returns the "since" settings as a proper Time instance func (sett *Settings) SinceDate() (time.Time, error) { dt, err := time.Parse("2006-01-02", sett.since) if err != nil { return time.Now(), err } return dt, nil } ================================================ FILE: modules/hibp/widget.go ================================================ package hibp import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for hibp data type Widget struct { view.TextWidget settings *Settings statuses []*Status err error } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := &Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return widget } /* -------------------- Exported Functions -------------------- */ // Fetch retrieves HIBP data from the HIBP API func (widget *Widget) Fetch(accounts []string) ([]*Status, error) { data := []*Status{} for _, account := range accounts { stat, err := widget.fetchForAccount(account, widget.settings.since) if err != nil { return nil, err } data = append(data, stat) } return data, nil } // Refresh updates the data for this widget and displays it onscreen func (widget *Widget) Refresh() { statuses, err := widget.Fetch(widget.settings.accounts) if err != nil { widget.err = err widget.statuses = nil } else { widget.err = nil widget.statuses = statuses } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } title += widget.sinceDateForTitle() str := "" for _, status := range widget.statuses { color := widget.settings.ok if status.HasBeenCompromised() { color = widget.settings.pwned } if status != nil { str += fmt.Sprintf(" [%s]%s[white]\n", color, status.Account) } } return title, str, false } func (widget *Widget) sinceDateForTitle() string { dateStr := "" if widget.settings.HasSince() { sinceStr := "" dt, err := widget.settings.SinceDate() if err != nil { sinceStr = widget.settings.since } else { sinceStr = dt.Format("Jan _2, 2006") } dateStr = dateStr + " since " + sinceStr } return dateStr } ================================================ FILE: modules/ipaddresses/ipapi/settings.go ================================================ package ipapi import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "IP API" ) type colors struct { name string value string } type Settings struct { colors *cfg.Common args []interface{} `help:"Defines what data to display and the order." values:"'ip', 'isp', 'as', 'asName', 'district', 'city', 'region', 'regionName', 'country', 'countryCode', 'continent', 'continentCode', 'coordinates', 'postalCode', 'currency', 'organization', 'timezone' and/or 'reverseDNS'"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), args: ymlConfig.UList("args"), } settings.name = ymlConfig.UString("colors.name", "red") settings.value = ymlConfig.UString("colors.value", "white") settings.SetDocumentationPath("ipaddress/ipapi") return &settings } ================================================ FILE: modules/ipaddresses/ipapi/widget.go ================================================ package ipapi import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "strings" "text/template" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget widget struct type Widget struct { view.TextWidget result string settings *Settings } type ipinfo struct { Query string `json:"query"` ISP string `json:"isp"` AS string `json:"as"` ASName string `json:"asname"` District string `json:"district"` City string `json:"city"` Region string `json:"region"` RegionName string `json:"regionName"` Country string `json:"country"` CountryCode string `json:"countryCode"` Continent string `json:"continent"` ContinentCode string `json:"continentCode"` Latitude float64 `json:"lat"` Longitude float64 `json:"lon"` PostalCode string `json:"zip"` Currency string `json:"currency"` Organization string `json:"org"` Timezone string `json:"timezone"` ReverseDNS string `json:"reverse"` } var argLookup = map[string]string{ "ip": "IP Address", "isp": "ISP", "as": "AS", "asname": "AS Name", "district": "District", "city": "City", "region": "Region", "regionname": "Region Name", "country": "Country", "countrycode": "Country Code", "continent": "Continent", "continentcode": "Continent Code", "coordinates": "Coordinates", "postalcode": "Postal Code", "currency": "Currency", "organization": "Organization", "timezone": "Timezone", "reversedns": "Reverse DNS", } // NewWidget constructor func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } widget.View.SetWrap(false) return &widget } // Refresh refresh the module func (widget *Widget) Refresh() { widget.ipinfo() widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.result, false }) } // this method reads the config and calls ipinfo for ip information func (widget *Widget) ipinfo() { client := &http.Client{} req, err := http.NewRequest("GET", "http://ip-api.com/json?fields=66846719", http.NoBody) if err != nil { widget.result = err.Error() return } req.Header.Set("User-Agent", "curl") response, err := client.Do(req) if err != nil { widget.result = err.Error() return } defer func() { _ = response.Body.Close() }() var info ipinfo err = json.NewDecoder(response.Body).Decode(&info) if err != nil { widget.result = err.Error() return } widget.setResult(&info) } func (widget *Widget) setResult(info *ipinfo) { args := utils.ToStrs(widget.settings.args) // if no arguments are defined set default if len(args) == 0 { args = []string{"ip", "isp", "as", "city", "region", "country", "coordinates", "postalCode", "organization", "timezone"} } format := "" for _, arg := range args { if val, ok := argLookup[strings.ToLower(arg)]; ok { format = format + formatableText(val, strings.ToLower(arg)) } } resultTemplate, _ := template.New("ipinfo_result").Parse(format) resultBuffer := new(bytes.Buffer) err := resultTemplate.Execute(resultBuffer, map[string]string{ "nameColor": widget.settings.name, "valueColor": widget.settings.value, "ip": info.Query, "isp": info.ISP, "as": info.AS, "asname": info.ASName, "district": info.District, "city": info.City, "region": info.Region, "regionname": info.RegionName, "country": info.Country, "countrycode": info.CountryCode, "continent": info.Continent, "continentcode": info.ContinentCode, "coordinates": strconv.FormatFloat(info.Latitude, 'f', 6, 64) + "," + strconv.FormatFloat(info.Longitude, 'f', 6, 64), "postalcode": info.PostalCode, "currency": info.Currency, "organization": info.Organization, "timezone": info.Timezone, "reversedns": info.ReverseDNS, }) if err != nil { widget.result = err.Error() } widget.result = resultBuffer.String() } func formatableText(key, value string) string { return fmt.Sprintf(" [{{.nameColor}}]%s: [{{.valueColor}}]{{.%s}}\n", key, value) } ================================================ FILE: modules/ipaddresses/ipinfo/settings.go ================================================ package ipinfo import ( "fmt" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" log "github.com/wtfutil/wtf/logger" ) const ( defaultFocusable = false defaultTitle = "IPInfo" ipV4 protocolVersion = "v4" ipV6 protocolVersion = "v6" auto protocolVersion = "auto" ) type protocolVersion string func (pv protocolVersion) String() string { switch pv { case ipV4: return "v4" case ipV6: return "v6" default: return "auto" } } func newProtocolVersion(str string) (protocolVersion, error) { switch str { case "v4": return ipV4, nil case "v6": return ipV6, nil case "auto": return auto, nil default: return "", fmt.Errorf("%s module: Unsupported protocol version: '%s'", defaultTitle, str) } } type Settings struct { *cfg.Common apiToken string `help:"An api token" optional:"true"` protocolVersion protocolVersion `help:"IP protocol version to display. Possible options are: 'v4' to show only IpV4 address, 'v6' to show only IpV6 address and 'auto' (default) to show the address preferred by OS." optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiToken: ymlConfig.UString("apiToken", ""), protocolVersion: auto, } pv, err := newProtocolVersion(ymlConfig.UString("protocolVersion", auto.String())) if err != nil { log.Log(err.Error()) log.Log(fmt.Sprintf("%s module: Use '%s' protocol version as a default", defaultTitle, auto)) } else { settings.protocolVersion = pv } settings.SetDocumentationPath("ipaddress/ipinfo") return &settings } ================================================ FILE: modules/ipaddresses/ipinfo/widget.go ================================================ package ipinfo import ( "bytes" "encoding/json" "fmt" log "github.com/wtfutil/wtf/logger" "net" "net/http" "text/template" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget result string settings *Settings } type ipinfo struct { Ip string `json:"ip"` Hostname string `json:"hostname"` City string `json:"city"` Region string `json:"region"` Country string `json:"country"` Coordinates string `json:"loc"` PostalCode string `json:"postal"` Organization string `json:"org"` } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } widget.View.SetWrap(false) return &widget } func (widget *Widget) Refresh() { widget.ipinfo() widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.result, false }) } // this method reads the config and calls ipinfo for ip information func (widget *Widget) ipinfo() { client := &http.Client{} var url string ip, ipv6 := getMyIP(widget.settings.protocolVersion) if ipv6 { url = fmt.Sprintf("https://ipinfo.io/%s", ip.String()) } else { url = "https://ipinfo.io/" } req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { widget.result = err.Error() return } req.Header.Set("User-Agent", "curl") if widget.settings.apiToken != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", widget.settings.apiToken)) } response, err := client.Do(req) if err != nil { widget.result = err.Error() return } defer func() { _ = response.Body.Close() }() var info ipinfo err = json.NewDecoder(response.Body).Decode(&info) if err != nil { widget.result = err.Error() return } widget.setResult(&info) } func (widget *Widget) setResult(info *ipinfo) { resultTemplate, _ := template.New("ipinfo_result").Parse( formatableText("IP", "Ip") + formatableText("Hostname", "Hostname") + formatableText("City", "City") + formatableText("Region", "Region") + formatableText("Country", "Country") + formatableText("Loc", "Coordinates") + formatableText("Org", "Organization"), ) resultBuffer := new(bytes.Buffer) err := resultTemplate.Execute(resultBuffer, map[string]string{ "subheadingColor": widget.settings.Colors.Subheading, "valueColor": widget.settings.Colors.Text, "Ip": info.Ip, "Hostname": info.Hostname, "City": info.City, "Region": info.Region, "Country": info.Country, "Coordinates": info.Coordinates, "PostalCode": info.PostalCode, "Organization": info.Organization, }) if err != nil { widget.result = err.Error() } widget.result = resultBuffer.String() } func formatableText(key, value string) string { return fmt.Sprintf(" [{{.subheadingColor}}]%8s[-:-:-] [{{.valueColor}}]{{.%s}}\n", key, value) } // getMyIP provides this system's default IPv4 or IPv6 IP address for routing WAN requests. // It does so by dialing out to a site known to have both an A and AAAA DNS records (IPv6) // The 'net' package is allowed to decide how to connect, connecting to both IPv4 or IPv6 address // depending on the availbility of IP protocols. func getMyIP(version protocolVersion) (ip net.IP, v6 bool) { log.Log(fmt.Sprintf("Protocol version: %s", version)) log.Log(fmt.Sprintf("Network: %s", version.toNetwork())) //fmt.Println("Protocol version: ", version) conn, err := net.Dial(version.toNetwork(), "fast.com:80") if err != nil { return } defer func() { _ = conn.Close() }() addr := conn.LocalAddr().(*net.TCPAddr) ip = addr.IP v6 = ip.To4() == nil return } func (pv protocolVersion) toNetwork() string { switch pv { case ipV4: return "tcp4" case ipV6: return "tcp6" default: return "tcp" } } ================================================ FILE: modules/jenkins/client.go ================================================ package jenkins import ( "crypto/tls" "net/http" "net/url" "regexp" "strings" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) Create(jenkinsURL string, username string, apiKey string) (*View, error) { const apiSuffix = "api/json?pretty=true" view := &View{} parsedSuffix, err := url.Parse(apiSuffix) if err != nil { return view, err } parsedJenkinsURL, err := url.Parse(ensureLastSlash(jenkinsURL)) if err != nil { return view, err } jenkinsAPIURL := parsedJenkinsURL.ResolveReference(parsedSuffix) req, _ := http.NewRequest("GET", jenkinsAPIURL.String(), http.NoBody) req.SetBasicAuth(username, apiKey) httpClient := &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !widget.settings.verifyServerCertificate, }, Proxy: http.ProxyFromEnvironment, }, } resp, err := httpClient.Do(req) if err != nil { return view, err } defer func() { _ = resp.Body.Close() }() err = utils.ParseJSON(view, resp.Body) if err != nil { return view, err } respJobs := make([]Job, 0, len(view.Jobs)+len(view.ActiveConfigurations)) respJobs = append(append(respJobs, view.Jobs...), view.ActiveConfigurations...) jobs := make([]Job, 0) var validID = regexp.MustCompile(widget.settings.jobNameRegex) for _, job := range respJobs { if validID.MatchString(job.Name) { jobs = append(jobs, job) } } view.Jobs = jobs return view, nil } func ensureLastSlash(url string) string { return strings.TrimRight(url, "/") + "/" } ================================================ FILE: modules/jenkins/job.go ================================================ package jenkins type Job struct { Name string `json:"name"` Url string `json:"url"` Color string `json:"color"` } ================================================ FILE: modules/jenkins/keyboard.go ================================================ package jenkins import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openJob, "Open job in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openJob, "Open job in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/jenkins/settings.go ================================================ package jenkins import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Jenkins" ) type Settings struct { *cfg.Common apiKey string `help:"Your Jenkins API key."` jobNameRegex string `help:"A regex that filters the jobs shown in the widget." optional:"true"` successBallColor string `help:"Changes the default color of successful Jenkins jobs to the color of your choosing." values:"blue, green, purple, yellow, etc." optional:"true"` url string `help:"The url to your Jenkins project or view."` user string `help:"Your Jenkins username."` verifyServerCertificate bool `help:"Determines whether or not the server’s certificate chain and host name are verified." values:"true or false" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_JENKINS_API_KEY"))), jobNameRegex: ymlConfig.UString("jobNameRegex", ".*"), successBallColor: ymlConfig.UString("successBallColor", "blue"), url: ymlConfig.UString("url"), user: ymlConfig.UString("user"), verifyServerCertificate: ymlConfig.UBool("verifyServerCertificate", true), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.url).Load() return &settings } ================================================ FILE: modules/jenkins/view.go ================================================ package jenkins type View struct { Description string `json:"description"` Jobs []Job `json:"jobs"` ActiveConfigurations []Job `json:"activeConfigurations"` Name string `json:"name"` Url string `json:"url"` } ================================================ FILE: modules/jenkins/widget.go ================================================ package jenkins import ( "fmt" "net/url" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget settings *Settings view *View err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } view, err := widget.Create( widget.settings.url, widget.settings.user, widget.settings.apiKey, ) widget.view = view if err != nil { widget.err = err widget.SetItemCount(0) } else { widget.SetItemCount(len(widget.view.Jobs)) } widget.Render() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s: [red]%s", widget.CommonSettings().Title, widget.view.Name) if widget.err != nil { return title, widget.err.Error(), true } if widget.view == nil || len(widget.view.Jobs) == 0 { return title, "No content to display", false } var str string jobs := widget.view.Jobs for idx, job := range jobs { jobName, _ := url.QueryUnescape(job.Name) row := fmt.Sprintf( `[%s] [%s]%-6s[white]`, widget.RowColor(idx), widget.jobColor(job), jobName, ) str += utils.HighlightableHelper(widget.View, row, idx, len(job.Name)) } return title, str, false } func (widget *Widget) jobColor(job Job) string { switch job.Color { case "blue": // Override color if successBallColor boolean param provided in config return widget.settings.successBallColor case "red": return "red" default: return "white" } } func (widget *Widget) openJob() { sel := widget.GetSelected() if sel >= 0 && widget.view != nil && sel < len(widget.view.Jobs) { job := &widget.view.Jobs[sel] utils.OpenFile(job.Url) } } ================================================ FILE: modules/jira/client.go ================================================ package jira import ( "bytes" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/wtfutil/wtf/utils" ) // UserIDCache represent a cached username to account ID mapping type UserIDCache struct { AccountID string ExpiresAt time.Time } // UserIDCacheMap holds the cache with thread safety type UserIDCacheMap struct { cache map[string]UserIDCache mutex sync.RWMutex } // Global cache instance var userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // JQLConversionRequest represents the request body for the JQL conversion API type JQLConversionRequest struct { QueryStrings []string `json:"queryStrings"` } // JQLConversionResponse represents the response from the JQL conversion API type JQLConversionResponse struct { QueryStrings []ConvertedQuery `json:"queryStrings"` } // ConvertedQuery represents a single converted JQL query type ConvertedQuery struct { Query string `json:"query"` ConvertedQuery string `json:"convertedQuery"` UserMessages []UserMessage `json:"userMessages"` } // UserMessage represents messages about the conversion type UserMessage struct { MessageKey string `json:"messageKey"` MessageArgs map[string]string `json:"messageArgs"` } // Get retrieves a cache account ID for a username func (c *UserIDCacheMap) Get(username string) (string, bool) { c.mutex.RLock() entry, exists := c.cache[username] if !exists { c.mutex.RUnlock() return "", false } // Check if cache entry has expired if time.Now().After(entry.ExpiresAt) { c.mutex.RUnlock() // Remove expired entry - upgrade to write lock c.mutex.Lock() delete(c.cache, username) c.mutex.Unlock() return "", false } accountID := entry.AccountID c.mutex.RUnlock() return accountID, true } // Set stores a username to account ID mapping with expiration func (c *UserIDCacheMap) Set(username, accountID string, duration time.Duration) { c.mutex.Lock() defer c.mutex.Unlock() c.cache[username] = UserIDCache{ AccountID: accountID, ExpiresAt: time.Now().Add(duration), } } // Clear removes all expired entries from the cache func (c *UserIDCacheMap) Clear() { c.mutex.Lock() defer c.mutex.Unlock() now := time.Now() for username, entry := range c.cache { if now.After(entry.ExpiresAt) { delete(c.cache, username) } } } // ConvertJQLWithUsername converts a JQL query containing username to account ID func (widget *Widget) ConvertJQLWithUsername(username string) (string, error) { // Check cache first if accountID, found := userIDCache.Get(username); found { return fmt.Sprintf("assignee = \"%s\"", accountID), nil } // Create a JQL query with the username that needs conversion originalJQL := fmt.Sprintf("assignee = \"%s\"", username) // Prepare the request body requestBody := JQLConversionRequest{ QueryStrings: []string{originalJQL}, } // Convert to JSON jsonData, err := json.Marshal(requestBody) if err != nil { return "", fmt.Errorf("failed to marshal request: %v", err) } // Make the POST request to the JQL conversion API resp, err := widget.jiraPostRequest("/rest/api/3/jql/pdcleaner", jsonData) if err != nil { return "", err } var conversionResult JQLConversionResponse err = utils.ParseJSON(&conversionResult, bytes.NewReader(resp)) if err != nil { return "", err } if len(conversionResult.QueryStrings) == 0 { return "", fmt.Errorf("no conversion result for username: %s", username) } // Return the converted JQL query part (just the assignee part) convertedQuery := conversionResult.QueryStrings[0].ConvertedQuery // Extract account ID properly accountID := extractAccountIDFromJQL(convertedQuery) if accountID == "" { return "", fmt.Errorf("failed to extract account ID from converted query: %s", convertedQuery) } // Cache the result for 10 minutes userIDCache.Set(username, accountID, 10*time.Minute) return convertedQuery, nil } // extractAccountIDFromJQL extracts the account ID from a converted JQL query func extractAccountIDFromJQL(jql string) string { // Example: "assignee = \"account:5b10ac8d82e05b22cc7d4ef5\"" // We want to extract: "account:5b10ac8d82e05b22cc7d4ef5" start := strings.Index(jql, "\"") if start == -1 { return "" } end := strings.LastIndex(jql, "\"") if end == -1 || end <= start { return "" } return jql[start+1 : end] } // IssuesFor returns a collection of issues for a given collection of projects. // If username is provided, it scopes the issues to that person func (widget *Widget) IssuesFor(username string, projects []string, jql string) (*SearchResult, error) { query := []string{} var projQuery = getProjectQuery(projects) if projQuery != "" { query = append(query, projQuery) } if username != "" { // Convert JQL with username to account ID convertedJQL, err := widget.ConvertJQLWithUsername(username) if err != nil { return &SearchResult{}, fmt.Errorf("failed to convert username %s to account ID: %v", username, err) } query = append(query, convertedJQL) } if jql != "" { query = append(query, jql) } // Try the new API v3 search/jql endpoint jqlQuery := strings.Join(query, " AND ") searchResult, err := widget.searchWithNewAPI(jqlQuery) if err != nil { // If new API fails, return the error return &SearchResult{}, fmt.Errorf("JIRA search failed: %v", err) } return searchResult, nil } // searchWithNewAPI uses the new /rest/api/3/search/jql endpoint func (widget *Widget) searchWithNewAPI(jql string) (*SearchResult, error) { // First, get issue IDs using the new endpoint v := url.Values{} v.Set("jql", jql) v.Set("maxResults", "20") // Limit to avoid too many API calls jqlURL := fmt.Sprintf("/rest/api/3/search/jql?%s", v.Encode()) resp, err := widget.jiraRequest(jqlURL) if err != nil { return nil, err } // Parse the JQL response which contains issue IDs type JQLSearchResult struct { Issues []struct { ID string `json:"id"` } `json:"issues"` } jqlResult := &JQLSearchResult{} err = utils.ParseJSON(jqlResult, bytes.NewReader(resp)) if err != nil { return nil, fmt.Errorf("failed to parse JQL search response: %v", err) } if len(jqlResult.Issues) == 0 { // Return empty result if no issues found return &SearchResult{Issues: []Issue{}}, nil } // Now get full issue details for each ID searchResult := &SearchResult{Issues: []Issue{}} for i, issue := range jqlResult.Issues { // Limit to prevent too many API calls if i >= 20 { break } fullIssue, err := widget.getIssueByID(issue.ID) if err != nil { // Log error but continue with other issues fmt.Printf("Error fetching issue %s: %v\n", issue.ID, err) continue } searchResult.Issues = append(searchResult.Issues, *fullIssue) } return searchResult, nil } // getIssueByID fetches full issue details by ID func (widget *Widget) getIssueByID(issueID string) (*Issue, error) { url := fmt.Sprintf("/rest/api/3/issue/%s", issueID) resp, err := widget.jiraRequest(url) if err != nil { return nil, err } issue := &Issue{} err = utils.ParseJSON(issue, bytes.NewReader(resp)) if err != nil { return nil, fmt.Errorf("failed to parse issue %s: %v", issueID, err) } return issue, nil } func buildJql(key string, value string) string { return fmt.Sprintf("%s = \"%s\"", key, value) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) jiraRequest(path string) ([]byte, error) { url := fmt.Sprintf("%s%s", widget.settings.domain, path) req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } if widget.settings.personalAccessToken != "" { req.Header.Set("Authorization", "Bearer "+widget.settings.personalAccessToken) } else { req.SetBasicAuth(widget.settings.email, widget.settings.apiKey) } httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !widget.settings.verifyServerCertificate, }, Proxy: http.ProxyFromEnvironment, }, } resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("JIRA API error - %s: %s (URL: %s)", resp.Status, string(body), url) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } func (widget *Widget) jiraPostRequest(path string, data []byte) ([]byte, error) { url := fmt.Sprintf("%s%s", widget.settings.domain, path) req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if widget.settings.personalAccessToken != "" { req.Header.Set("Authorization", "Bearer "+widget.settings.personalAccessToken) } else { req.SetBasicAuth(widget.settings.email, widget.settings.apiKey) } httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !widget.settings.verifyServerCertificate, }, Proxy: http.ProxyFromEnvironment, }, } resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("JIRA API POST error - %s: %s (URL: %s)", resp.Status, string(body), url) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } func getProjectQuery(projects []string) string { singleEmptyProject := len(projects) == 1 && projects[0] == "" if len(projects) == 0 || singleEmptyProject { return "" } else if len(projects) == 1 { return buildJql("project", projects[0]) } quoted := make([]string, len(projects)) for i := range projects { quoted[i] = fmt.Sprintf("\"%s\"", projects[i]) } return fmt.Sprintf("project in (%s)", strings.Join(quoted, ", ")) } ================================================ FILE: modules/jira/client_test.go ================================================ package jira import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "gotest.tools/assert" ) func TestUserIDCacheMap_SetAndGet(t *testing.T) { cache := &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test setting and getting a value username := "testuser" accountID := "account:123456789" duration := 5 * time.Minute cache.Set(username, accountID, duration) // Test successful retrieval retrievedID, found := cache.Get(username) assert.Equal(t, true, found) assert.Equal(t, accountID, retrievedID) } func TestUserIDCacheMap_GetNonExistent(t *testing.T) { cache := &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test getting non-existent value retrievedID, found := cache.Get("nonexistent") assert.Equal(t, false, found) assert.Equal(t, "", retrievedID) } func TestUserIDCacheMap_GetExpired(t *testing.T) { cache := &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Set an entry that expires immediately username := "expireduser" accountID := "account:987654321" cache.Set(username, accountID, -1*time.Second) // Already expired // Test that expired entry is not returned and is cleaned up retrievedID, found := cache.Get(username) assert.Equal(t, false, found) assert.Equal(t, "", retrievedID) // Verify the expired entry was removed from cache cache.mutex.RLock() _, exists := cache.cache[username] cache.mutex.RUnlock() assert.Equal(t, false, exists) } func TestUserIDCacheMap_Clear(t *testing.T) { cache := &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Add a valid entry and an expired entry cache.Set("validuser", "account:111", 5*time.Minute) cache.Set("expireduser", "account:222", -1*time.Second) // Clear expired entries cache.Clear() // Valid entry should still exist _, found := cache.Get("validuser") assert.Equal(t, true, found) // Expired entry should be gone cache.mutex.RLock() _, exists := cache.cache["expireduser"] cache.mutex.RUnlock() assert.Equal(t, false, exists) } func TestExtractAccountIDFromJQL(t *testing.T) { tests := []struct { name string jql string expected string }{ { name: "valid account ID", jql: `assignee = "account:5b10ac8d82e05b22cc7d4ef5"`, expected: "account:5b10ac8d82e05b22cc7d4ef5", }, { name: "single quotes", jql: `assignee = 'account:123456789'`, expected: "", // Our function only handles double quotes }, { name: "no quotes", jql: `assignee = account:123456789`, expected: "", }, { name: "empty string", jql: "", expected: "", }, { name: "malformed JQL", jql: `assignee = "incomplete`, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := extractAccountIDFromJQL(tt.jql) assert.Equal(t, tt.expected, result) }) } } func TestConvertJQLWithUsername_CacheHit(t *testing.T) { // Setup a mock widget (minimal setup for testing) widget := &Widget{} // Clear and setup cache userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Pre-populate cache username := "cacheduser" accountID := "account:cached123" userIDCache.Set(username, accountID, 5*time.Minute) // Test that cached value is returned without API call result, err := widget.ConvertJQLWithUsername(username) assert.NilError(t, err) assert.Equal(t, `assignee = "account:cached123"`, result) } func TestConvertJQLWithUsername_APICalls(t *testing.T) { // Create a mock JIRA server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify it's a POST request to the right endpoint assert.Equal(t, "POST", r.Method) assert.Equal(t, "/rest/api/3/jql/pdcleaner", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) // Mock response response := JQLConversionResponse{ QueryStrings: []ConvertedQuery{ { Query: `assignee = "testuser"`, ConvertedQuery: `assignee = "account:5b10ac8d82e05b22cc7d4ef5"`, UserMessages: []UserMessage{}, }, }, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(response) })) defer server.Close() // Setup widget with mock server widget := &Widget{ settings: &Settings{ domain: server.URL, }, } // Clear cache userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test API call result, err := widget.ConvertJQLWithUsername("testuser") assert.NilError(t, err) assert.Equal(t, `assignee = "account:5b10ac8d82e05b22cc7d4ef5"`, result) // Verify it was cached cachedID, found := userIDCache.Get("testuser") assert.Equal(t, true, found) assert.Equal(t, "account:5b10ac8d82e05b22cc7d4ef5", cachedID) } func TestConvertJQLWithUsername_APIError(t *testing.T) { // Create a mock server that returns an error server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Internal Server Error")) })) defer server.Close() // Setup widget with mock server widget := &Widget{ settings: &Settings{ domain: server.URL, }, } // Clear cache userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test API error handling result, err := widget.ConvertJQLWithUsername("testuser") assert.ErrorContains(t, err, "500 Internal Server Error") assert.Equal(t, "", result) } func TestConvertJQLWithUsername_EmptyResponse(t *testing.T) { // Create a mock server that returns empty response server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := JQLConversionResponse{ QueryStrings: []ConvertedQuery{}, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(response) })) defer server.Close() // Setup widget with mock server widget := &Widget{ settings: &Settings{ domain: server.URL, }, } // Clear cache userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test empty response handling result, err := widget.ConvertJQLWithUsername("testuser") assert.Error(t, err, "no conversion result for username: testuser") assert.Equal(t, "", result) } func TestConvertJQLWithUsername_InvalidAccountID(t *testing.T) { // Create a mock server that returns malformed JQL server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := JQLConversionResponse{ QueryStrings: []ConvertedQuery{ { Query: `assignee = "testuser"`, ConvertedQuery: `assignee = malformed_without_quotes`, UserMessages: []UserMessage{}, }, }, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(response) })) defer server.Close() // Setup widget with mock server widget := &Widget{ settings: &Settings{ domain: server.URL, }, } // Clear cache userIDCache = &UserIDCacheMap{ cache: make(map[string]UserIDCache), } // Test invalid account ID handling result, err := widget.ConvertJQLWithUsername("testuser") assert.ErrorContains(t, err, "failed to extract account ID from converted query") assert.Equal(t, "", result) } ================================================ FILE: modules/jira/issues.go ================================================ package jira type Issue struct { Expand string `json:"expand"` ID string `json:"id"` Self string `json:"self"` Key string `json:"key"` IssueFields *IssueFields `json:"fields"` } type IssueFields struct { Summary string `json:"summary"` IssueType *IssueType `json:"issuetype"` IssueStatus *IssueStatus `json:"status"` } type IssueType struct { Self string `json:"self"` ID string `json:"id"` Description string `json:"description"` IconURL string `json:"iconUrl"` Name string `json:"name"` Subtask bool `json:"subtask"` } type IssueStatus struct { ISelf string `json:"self"` IDescription string `json:"description"` IName string `json:"name"` } ================================================ FILE: modules/jira/keyboard.go ================================================ package jira import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openItem, "Open item in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openItem, "Open item in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/jira/search_result.go ================================================ package jira type SearchResult struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Total int `json:"total"` Issues []Issue `json:"issues"` } ================================================ FILE: modules/jira/settings.go ================================================ package jira import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Jira" ) type colors struct { rows struct { even string odd string } } type Settings struct { colors *cfg.Common apiKey string `help:"Your Jira API key (or password for basic auth)."` personalAccessToken string `help:"Access Token to use instead of username / password auth"` domain string `help:"Your Jira corporate domain."` email string `help:"The email address associated with your Jira account (or username for basic auth)."` jql string `help:"Custom JQL to be appended to the search query." values:"See Search Jira like a boss with JQL for details." optional:"true"` projects []string `help:"An array of projects to get data from"` username string `help:"Your Jira username. If provided, will filter issues by this username." optional:"true"` verifyServerCertificate bool `help:"Determines whether or not the server’s certificate chain and host name are verified." values:"true or false" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_JIRA_API_KEY"))), personalAccessToken: ymlConfig.UString("personalAccessToken"), domain: ymlConfig.UString("domain"), email: ymlConfig.UString("email"), jql: ymlConfig.UString("jql"), username: ymlConfig.UString("username"), verifyServerCertificate: ymlConfig.UBool("verifyServerCertificate", true), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.domain).Load() settings.rows.even = ymlConfig.UString("colors.even", "lightblue") settings.rows.odd = ymlConfig.UString("colors.odd", "white") settings.projects = settings.arrayifyProjects(ymlConfig) return &settings } /* -------------------- Unexported functions -------------------- */ // arrayifyProjects figures out if we're dealing with a single project or an array of projects func (settings *Settings) arrayifyProjects(ymlConfig *config.Config) []string { projects := []string{} // Single project project, err := ymlConfig.String("project") if err == nil { projects = append(projects, project) return projects } // Array of projects projectList := ymlConfig.UList("project") for _, projectName := range projectList { if project, ok := projectName.(string); ok { projects = append(projects, project) } } return projects } ================================================ FILE: modules/jira/widget.go ================================================ package jira import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget result *SearchResult settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { searchResult, err := widget.IssuesFor( widget.settings.username, widget.settings.projects, widget.settings.jql, ) if err != nil { widget.err = err widget.result = nil widget.SetItemCount(0) } else { widget.err = nil widget.result = searchResult widget.SetItemCount(len(searchResult.Issues)) } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) openItem() { sel := widget.GetSelected() if sel >= 0 && widget.result != nil && sel < len(widget.result.Issues) { issue := &widget.result.Issues[sel] utils.OpenFile(widget.settings.domain + "/browse/" + issue.Key) } } const MaxIssueTypeLength = 7 const MaxStatusNameLength = 14 func (widget *Widget) content() (string, string, bool) { if widget.err != nil { return widget.CommonSettings().Title, widget.err.Error(), true } title := widget.CommonSettings().Title str := fmt.Sprintf(" [%s]Assigned Issues[white]\n", widget.settings.Colors.Subheading) if widget.result == nil || len(widget.result.Issues) == 0 { return title, "No results to display", false } longestIssueTypeLength, longestKeyLength, longestStatusNameLength := getLongestColumnLengths(widget.result.Issues) for idx, issue := range widget.result.Issues { row := fmt.Sprintf( `[%s] [%s]%-*s[white] [green]%-*s[white] [yellow]%-*s[white] [%s]%s`, widget.RowColor(idx), widget.issueTypeColor(&issue), longestIssueTypeLength+1, trimToMaxLength(issue.IssueFields.IssueType.Name, MaxIssueTypeLength), longestKeyLength+1, issue.Key, longestStatusNameLength+1, trimToMaxLength(issue.IssueFields.IssueStatus.IName, MaxStatusNameLength), widget.RowColor(idx), tview.Escape(issue.IssueFields.Summary), ) str += utils.HighlightableHelper(widget.View, row, idx, len(issue.IssueFields.Summary)) } return title, str, false } func getLongestColumnLengths(issues []Issue) (int, int, int) { longestIssueTypeLength := 0 longestKeyLength := 0 longestStatusNameLength := 0 for _, issue := range issues { issueTypeLength := len(issue.IssueFields.IssueType.Name) if issueTypeLength > longestIssueTypeLength { longestIssueTypeLength = issueTypeLength } issueKeyLength := len(issue.Key) if issueKeyLength > longestKeyLength { longestKeyLength = len("WTF-XXX") // issueKeyLength } statusNameLength := len(issue.IssueFields.IssueStatus.IName) if statusNameLength > longestStatusNameLength { longestStatusNameLength = statusNameLength } } if longestIssueTypeLength > MaxIssueTypeLength { longestIssueTypeLength = MaxIssueTypeLength } if longestStatusNameLength > MaxStatusNameLength { longestStatusNameLength = MaxStatusNameLength } return longestIssueTypeLength, longestKeyLength, longestStatusNameLength } func (*Widget) issueTypeColor(issue *Issue) string { switch issue.IssueFields.IssueType.Name { case "Bug": return "red" case "Story": return "blue" case "Task": return "orange" default: return "white" } } func trimToMaxLength(text string, maxLength int) string { if len(text) <= maxLength { return text } else { return text[:maxLength] } } ================================================ FILE: modules/krisinformation/client.go ================================================ package krisinformation import ( "fmt" "math" "net/http" "strconv" "strings" "time" "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/utils" ) const ( krisinformationAPI = "https://api.krisinformation.se/v2/feed?format=json" ) type Krisinformation []struct { Identifier string `json:"Identifier"` PushMessage string `json:"PushMessage"` Updated time.Time `json:"Updated"` Published time.Time `json:"Published"` Headline string `json:"Headline"` Preamble string `json:"Preamble"` BodyText string `json:"BodyText"` Area []struct { Type string `json:"Type"` Description string `json:"Description"` Coordinate string `json:"Coordinate"` GeometryInformation interface{} `json:"GeometryInformation"` } `json:"Area"` Web string `json:"Web"` Language string `json:"Language"` Event string `json:"Event"` SenderName string `json:"SenderName"` Push bool `json:"Push"` BodyLinks []interface{} `json:"BodyLinks"` SourceID int `json:"SourceID"` IsVma bool `json:"IsVma"` IsTestVma bool `json:"IsTestVma"` } // Client holds or configuration type Client struct { latitude float64 longitude float64 radius int county string country bool } // Item holds the interesting parts type Item struct { PushMessage string HeadLine string SenderName string Country bool County bool Distance float64 Updated time.Time } // NewClient returns a new Client func NewClient(latitude, longitude float64, radius int, county string, country bool) *Client { return &Client{ latitude: latitude, longitude: longitude, radius: radius, county: county, country: country, } } // getKrisinformation - return items that match either country, county or a radius // Priority: // - Country // - County // - Region func (c *Client) getKrisinformation() (items []Item, err error) { resp, err := http.Get(krisinformationAPI) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var data Krisinformation err = utils.ParseJSON(&data, resp.Body) if err != nil { return nil, err } for i := range data { for a := range data[i].Area { // Country wide events if c.country && data[i].Area[a].Type == "Country" { item := Item{ PushMessage: data[i].PushMessage, HeadLine: data[i].Headline, SenderName: data[i].SenderName, Country: true, Updated: data[i].Updated, } items = append(items, item) continue } // County specific events if c.county != "" && data[i].Area[a].Type == "County" { // We look for county in description if strings.Contains( strings.ToLower(data[i].Area[a].Description), strings.ToLower(c.county), ) { item := Item{ PushMessage: data[i].PushMessage, HeadLine: data[i].Headline, SenderName: data[i].SenderName, County: true, Updated: data[i].Updated, } items = append(items, item) continue } } if c.radius != -1 { coords := data[i].Area[a].Coordinate if coords == "" { continue } buf := strings.Split(coords, " ") latlon := strings.Split(buf[0], ",") kris_latitude, err := strconv.ParseFloat(latlon[0], 32) if err != nil { return nil, err } kris_longitude, err := strconv.ParseFloat(latlon[1], 32) if err != nil { return nil, err } distance := DistanceInMeters(kris_latitude, kris_longitude, c.latitude, c.longitude) logger.Log(fmt.Sprintf("Distance: %f", distance/1000)) // KM if distance < float64(c.radius) { item := Item{ PushMessage: data[i].PushMessage, HeadLine: data[i].Headline, SenderName: data[i].SenderName, Distance: distance, Updated: data[i].Updated, } items = append(items, item) } } } } return items, nil } // haversin(θ) function func hsin(theta float64) float64 { return math.Pow(math.Sin(theta/2), 2) } // Distance function returns the distance (in meters) between two points of // // a given longitude and latitude relatively accurately (using a spherical // approximation of the Earth) through the Haversin Distance Formula for // great arc distance on a sphere with accuracy for small distances // // point coordinates are supplied in degrees and converted into rad. in the func // // http://en.wikipedia.org/wiki/Haversine_formula func DistanceInMeters(lat1, lon1, lat2, lon2 float64) float64 { // convert to radians // must cast radius as float to multiply later var la1, lo1, la2, lo2, r float64 la1 = lat1 * math.Pi / 180 lo1 = lon1 * math.Pi / 180 la2 = lat2 * math.Pi / 180 lo2 = lon2 * math.Pi / 180 r = 6378100 // Earth radius in METERS // calculate h := hsin(la2-la1) + math.Cos(la1)*math.Cos(la2)*hsin(lo2-lo1) return 2 * r * math.Asin(math.Sqrt(h)) } ================================================ FILE: modules/krisinformation/settings.go ================================================ package krisinformation import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Krisinformation" defaultRadius = -1 defaultCountry = true defaultCounty = "" defaultMaxItems = -1 defaultMaxAge = 720 ) // Settings defines the configuration properties for this module type Settings struct { common *cfg.Common latitude float64 `help:"The latitude of the position from which the widget should look for messages." optional:"true"` longitude float64 `help:"The longitude of the position from which the widget should look for messages." optional:"true"` radius int `help:"The radius in km from your position that the widget should look for messages. need latitude/longitude setting,Default 10" optional:"true"` county string `help:"The county from where to display messages" optional:"true"` country bool `help:"Only display country wide messages" optional:"true"` maxitems int `help:"Only display X number of latest messages" optional:"true"` maxage int `help:"Only show messages younger than maxage" optional:"true"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), latitude: ymlConfig.UFloat64("latitude", -1), longitude: ymlConfig.UFloat64("longitude", -1), radius: ymlConfig.UInt("radius", defaultRadius), country: ymlConfig.UBool("country", defaultCountry), county: ymlConfig.UString("county", defaultCounty), maxitems: ymlConfig.UInt("maxitems", defaultMaxItems), maxage: ymlConfig.UInt("maxages", defaultMaxAge), } return &settings } ================================================ FILE: modules/krisinformation/widget.go ================================================ package krisinformation import ( "fmt" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget app *tview.Application settings *Settings err error client *Client } // NewWidget creates and returns an instance of Widget func NewWidget(app *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(app, redrawChan, nil, settings.common), app: app, settings: settings, client: NewClient( settings.latitude, settings.longitude, settings.radius, settings.county, settings.country), } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { if widget.Disabled() { return } // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { var title = defaultTitle if widget.CommonSettings().Title != "" { title = widget.CommonSettings().Title } now := time.Now() kriser, err := widget.client.getKrisinformation() if err != nil { handleError(widget, err) } var str string i := 0 for k := range kriser { diff := now.Sub(kriser[k].Updated) if widget.settings.maxage != -1 { // Skip if message is too old if int(diff.Hours()) > widget.settings.maxage { //logger.Log(fmt.Sprintf("Article to old: (%s) Days: %d", kriser[k].HeadLine, int(diff.Hours()))) continue } } i++ if i > widget.settings.maxitems && widget.settings.maxitems != -1 { break } str += fmt.Sprintf("- %s\n", kriser[k].HeadLine) } return title, str, true } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.content() }) } func handleError(widget *Widget, err error) { widget.err = err } ================================================ FILE: modules/kubernetes/client.go ================================================ package kubernetes import ( "k8s.io/client-go/kubernetes" // Includes authentication modules for various Kubernetes providers _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/client-go/tools/clientcmd" ) type clientInstance struct { Client kubernetes.Interface } // getInstance returns a Kubernetes interface for a clientset func (widget *Widget) getInstance() (*clientInstance, error) { var err error widget.clientOnce.Do(func() { widget.client = &clientInstance{} widget.client.Client, err = widget.getKubeClient() }) return widget.client, err } // getKubeClient returns a kubernetes clientset for the kubeconfig provided func (widget *Widget) getKubeClient() (kubernetes.Interface, error) { var overrides *clientcmd.ConfigOverrides if widget.context != "" { overrides = &clientcmd.ConfigOverrides{ CurrentContext: widget.context, } } config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( &clientcmd.ClientConfigLoadingRules{ExplicitPath: widget.kubeconfig}, overrides).ClientConfig() if err != nil { return nil, err } clientset, err := kubernetes.NewForConfig(config) if err != nil { return nil, err } return clientset, nil } ================================================ FILE: modules/kubernetes/settings.go ================================================ package kubernetes import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = false defaultTitle = "Kubernetes" ) type Settings struct { *cfg.Common objects []string `help:"Kubernetes objects to show. Options are: [nodes, pods, deployments]."` title string `help:"Override the title of widget."` kubeconfig string `help:"Location of a kubeconfig file."` namespaces []string `help:"List of namespaces to watch. If blank, defaults to all namespaces."` context string `help:"Kubernetes context to use. If blank, uses default context"` } func NewSettingsFromYAML(name string, moduleConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, moduleConfig, globalConfig), objects: utils.ToStrs(moduleConfig.UList("objects")), title: moduleConfig.UString("title"), kubeconfig: moduleConfig.UString("kubeconfig"), namespaces: utils.ToStrs(moduleConfig.UList("namespaces")), context: moduleConfig.UString("context"), } return &settings } ================================================ FILE: modules/kubernetes/widget.go ================================================ package kubernetes import ( "context" "fmt" "sync" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Widget contains all the config for the widget type Widget struct { view.TextWidget client *clientInstance clientOnce sync.Once objects []string title string kubeconfig string namespaces []string context string settings *Settings } // NewWidget creates a new instance of the widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), objects: settings.objects, title: settings.title, kubeconfig: settings.kubeconfig, namespaces: settings.namespaces, settings: settings, context: settings.context, } widget.View.SetWrap(true) return &widget } // Refresh executes the command and updates the view with the results func (widget *Widget) Refresh() { title := widget.generateTitle() client, err := widget.getInstance() if err != nil { widget.Redraw(func() (string, string, bool) { return title, err.Error(), true }) return } var content string if utils.Includes(widget.objects, "nodes") { nodeList, nodeError := client.getNodes() if nodeError != nil { widget.Redraw(func() (string, string, bool) { return title, "[red] Error getting node data [white]\n", true }) return } content += fmt.Sprintf("[%s]Nodes[white]\n", widget.settings.Colors.Subheading) for _, node := range nodeList { content += fmt.Sprintf("%s\n", node) } content += "\n" } if utils.Includes(widget.objects, "deployments") { deploymentList, deploymentError := client.getDeployments(widget.namespaces) if deploymentError != nil { widget.Redraw(func() (string, string, bool) { return title, "[red] Error getting deployment data [white]\n", true }) return } content += fmt.Sprintf("[%s]Deployments[white]\n", widget.settings.Colors.Subheading) for _, deployment := range deploymentList { content += fmt.Sprintf("%s\n", deployment) } content += "\n" } if utils.Includes(widget.objects, "pods") { podList, podError := client.getPods(widget.namespaces) if podError != nil { widget.Redraw(func() (string, string, bool) { return title, "[red] Error getting pod data [white]\n", false }) return } content += fmt.Sprintf("[%s]Pods[white]\n", widget.settings.Colors.Subheading) for _, pod := range podList { content += fmt.Sprintf("%s\n", pod) } content += "\n" } widget.Redraw(func() (string, string, bool) { return title, content, false }) } /* -------------------- Unexported Functions -------------------- */ // generateTitle generates a title for the widget func (widget *Widget) generateTitle() string { if widget.title != "" { return widget.title } title := "Kube" if widget.context != "" { title = fmt.Sprintf("%s (%s)", title, widget.context) } if len(widget.namespaces) == 1 { title += fmt.Sprintf(" - Namespace: %s", widget.namespaces[0]) } else if len(widget.namespaces) > 1 { title += fmt.Sprintf(" - Namespaces: %q", widget.namespaces) } return title } // getPods returns a slice of pod strings func (client *clientInstance) getPods(namespaces []string) ([]string, error) { var podList []string if len(namespaces) != 0 { for _, namespace := range namespaces { pods, err := client.Client.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } for _, pod := range pods.Items { var podString string status := pod.Status.Phase name := pod.Name if len(namespaces) == 1 { podString = fmt.Sprintf("%-50s %s", name, status) } else { podString = fmt.Sprintf("%-20s %-50s %s", namespace, name, status) } podList = append(podList, podString) } } } else { pods, err := client.Client.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } for _, pod := range pods.Items { podString := fmt.Sprintf("%-20s %-50s %s", pod.Namespace, pod.Name, pod.Status.Phase) podList = append(podList, podString) } } return podList, nil } // get Deployments returns a string slice of pod strings func (client *clientInstance) getDeployments(namespaces []string) ([]string, error) { var deploymentList []string if len(namespaces) != 0 { for _, namespace := range namespaces { deployments, err := client.Client.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } for _, deployment := range deployments.Items { var deployString string if len(namespaces) == 1 { deployString = fmt.Sprintf("%-50s (%d/%d)", deployment.Name, deployment.Status.ReadyReplicas, deployment.Status.Replicas) } else { deployString = fmt.Sprintf("%-20s %-50s (%d/%d)", deployment.Namespace, deployment.Name, deployment.Status.ReadyReplicas, deployment.Status.Replicas) } deploymentList = append(deploymentList, deployString) } } } else { deployments, err := client.Client.AppsV1().Deployments("").List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } for _, deployment := range deployments.Items { deployString := fmt.Sprintf("%-20s %-50s (%d/%d)", deployment.Namespace, deployment.Name, deployment.Status.ReadyReplicas, deployment.Status.Replicas) deploymentList = append(deploymentList, deployString) } } return deploymentList, nil } // getNodes returns a string slice of nodes func (client *clientInstance) getNodes() ([]string, error) { var nodeList []string nodes, err := client.Client.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } for _, node := range nodes.Items { var nodeStatus string for _, condition := range node.Status.Conditions { if condition.Reason == "KubeletReady" { switch { case condition.Status == "True": nodeStatus = "Ready" case condition.Reason == "False": nodeStatus = "NotReady" default: nodeStatus = "Unknown" } } } nodeString := fmt.Sprintf("%-50s %s", node.Name, nodeStatus) nodeList = append(nodeList, nodeString) } return nodeList, nil } ================================================ FILE: modules/kubernetes/widget_test.go ================================================ package kubernetes import ( "testing" "github.com/stretchr/testify/assert" ) func Test_generateTitle(t *testing.T) { type fields struct { title string namespaces []string context string } testCases := []struct { name string fields fields want string }{ { name: "No Namespaces", fields: fields{ namespaces: []string{}, }, want: "Kube", }, { name: "One Namespace", fields: fields{ namespaces: []string{"some-namespace"}, }, want: "Kube - Namespace: some-namespace", }, { name: "Multiple Namespaces", fields: fields{ namespaces: []string{"ns1", "ns2"}, }, want: `Kube - Namespaces: ["ns1" "ns2"]`, }, { name: "Explicit Title Set", fields: fields{ namespaces: []string{}, title: "Test Explicit Title", }, want: "Test Explicit Title", }, { name: "Context set", fields: fields{ namespaces: []string{}, context: "test-context", }, want: "Kube (test-context)", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { widget := &Widget{ title: tt.fields.title, namespaces: tt.fields.namespaces, context: tt.fields.context, } assert.Equal(t, tt.want, widget.generateTitle()) }) } } ================================================ FILE: modules/logger/settings.go ================================================ package logger import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Logger" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/logger/widget.go ================================================ package logger import ( "fmt" "os" "strings" "github.com/rivo/tview" log "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/view" ) const ( maxBufferSize int64 = 1024 ) type Widget struct { view.TextWidget filePath string settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), filePath: log.LogFilePath(), settings: settings, } return &widget } // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { if log.LogFileMissing() { return widget.CommonSettings().Title, "File missing", false } logLines := widget.tailFile() str := "" for _, line := range logLines { chunks := strings.Split(line, " ") if len(chunks) >= 4 { str += fmt.Sprintf( "[green]%s[white] [yellow]%s[white] %s\n", chunks[0], chunks[1], strings.Join(chunks[3:], " "), ) } } return widget.CommonSettings().Title, str, false } func (widget *Widget) tailFile() []string { file, err := os.Open(widget.filePath) if err != nil { return []string{} } defer func() { _ = file.Close() }() stat, err := file.Stat() if err != nil { return []string{} } bufferSize := maxBufferSize if maxBufferSize > stat.Size() { bufferSize = stat.Size() } startPos := stat.Size() - bufferSize buff := make([]byte, bufferSize) _, err = file.ReadAt(buff, startPos) if err != nil { return []string{} } logLines := strings.Split(string(buff), "\n") // Reverse the array of lines // Offset by two to account for the blank line at the end last := len(logLines) - 2 for i := 0; i < len(logLines)/2; i++ { logLines[i], logLines[last-i] = logLines[last-i], logLines[i] } return logLines } ================================================ FILE: modules/lunarphase/keyboard.go ================================================ package lunarphase import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("n", widget.NextDay, "Show next day lunar phase") widget.SetKeyboardChar("p", widget.PrevDay, "Show previous day lunar phase") widget.SetKeyboardChar("t", widget.Today, "Show today lunar phase") widget.SetKeyboardChar("N", widget.NextWeek, "Show next week lunar phase") widget.SetKeyboardChar("P", widget.PrevWeek, "Show previous week lunar phase") widget.SetKeyboardChar("o", widget.OpenMoonPhase, "Open 'Moon Phase for Today' in browser") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevDay, "Show previous day lunar phase") widget.SetKeyboardKey(tcell.KeyRight, widget.NextDay, "Show next day lunar phase") widget.SetKeyboardKey(tcell.KeyUp, widget.NextWeek, "Show next week lunar phase") widget.SetKeyboardKey(tcell.KeyDown, widget.PrevWeek, "Show previous week lunar phase") widget.SetKeyboardKey(tcell.KeyEnter, widget.OpenMoonPhase, "Open 'Moon Phase for Today' in browser") widget.SetKeyboardKey(tcell.KeyCtrlD, widget.DisableWidget, "Disable/Enable this widget instance") } ================================================ FILE: modules/lunarphase/settings.go ================================================ package lunarphase import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Phase of the Moon" dateFormat = "2006-01-02" phaseFormat = "01-02-2006" ) type Settings struct { *cfg.Common language string requestTimeout int } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), language: ymlConfig.UString("language", "en"), requestTimeout: ymlConfig.UInt("timeout", 30), } settings.SetDocumentationPath("lunarphase") return &settings } ================================================ FILE: modules/lunarphase/widget.go ================================================ package lunarphase import ( "io" "net/http" "strings" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "github.com/wtfutil/wtf/wtf" ) type Widget struct { view.ScrollableWidget current bool day string date time.Time last string result string settings *Settings timeout time.Duration titleBase string } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.current = true widget.date = time.Now() widget.day = widget.date.Format(dateFormat) widget.last = "" widget.timeout = time.Duration(widget.settings.requestTimeout) * time.Second widget.titleBase = widget.settings.Title widget.SetRenderFunction(widget.Refresh) widget.initializeKeyboardControls() return widget } func (widget *Widget) Refresh() { if widget.current { widget.date = time.Now() widget.day = widget.date.Format(dateFormat) } if widget.day != widget.last { widget.lunarPhase() } if !widget.settings.Enabled { widget.settings.Title = widget.titleBase + " " + widget.day + " [ Disabled ]" widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, "", false }) widget.View.Clear() return } widget.settings.Title = widget.titleBase + " " + widget.day widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.result, false }) } func (widget *Widget) RefreshTitle() { if !widget.settings.Enabled { widget.settings.Title = widget.titleBase + " " + widget.day + " [ Disabled ]" widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, "", false }) widget.View.Clear() return } widget.settings.Title = widget.titleBase + " [" + widget.day + "]" widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.result, false }) } // this method reads the config and calls wttr.in for lunar phase func (widget *Widget) lunarPhase() { client := &http.Client{ Timeout: widget.timeout, } language := widget.settings.language req, err := http.NewRequest("GET", "https://wttr.in/Moon@"+widget.day+"?AF&lang="+language, http.NoBody) if err != nil { widget.result = err.Error() return } req.Header.Set("Accept-Language", widget.settings.language) req.Header.Set("User-Agent", "curl") response, err := client.Do(req) if err != nil { widget.result = err.Error() return } defer func() { _ = response.Body.Close() }() contents, err := io.ReadAll(response.Body) if err != nil { widget.result = err.Error() return } widget.last = widget.day widget.result = strings.TrimSpace(wtf.ASCIItoTviewColors(string(contents))) } // NextDay shows the next day's lunar phase (KeyRight / 'n') func (widget *Widget) NextDay() { widget.current = false tomorrow := widget.date.AddDate(0, 0, 1) widget.setDay(tomorrow) } // NextWeek shows the next week's lunar phase (KeyUp / 'N') func (widget *Widget) NextWeek() { widget.current = false nextweek := widget.date.AddDate(0, 0, 7) widget.setDay(nextweek) } // PrevDay shows the previous day's lunar phase (KeyLeft / 'p') func (widget *Widget) PrevDay() { widget.current = false yesterday := widget.date.AddDate(0, 0, -1) widget.setDay(yesterday) } // Today shows the current day's lunar phase ('t') func (widget *Widget) Today() { widget.current = true widget.Refresh() } // PrevWeek shows the previous week's lunar phase (KeyDown / 'P') func (widget *Widget) PrevWeek() { widget.current = false lastweek := widget.date.AddDate(0, 0, -7) widget.setDay(lastweek) } func (widget *Widget) setDay(ts time.Time) { widget.date = ts widget.day = widget.date.Format(dateFormat) widget.RefreshTitle() } // Open nineplanets.org in a browser (Enter / 'o') func (widget *Widget) OpenMoonPhase() { phasedate := widget.date.Format(phaseFormat) utils.OpenFile("https://nineplanets.org/moon/phase/" + phasedate + "/") } // Disable/Enable the widget (Ctrl-D) func (widget *Widget) DisableWidget() { if widget.settings.Enabled { widget.settings.Enabled = false widget.RefreshTitle() } else { widget.settings.Enabled = true widget.Refresh() } } ================================================ FILE: modules/mercurial/display.go ================================================ package mercurial import ( "fmt" "strings" "unicode/utf8" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { repoData := widget.currentData() if repoData == nil { return widget.CommonSettings().Title, " Mercurial repo data is unavailable ", false } title := fmt.Sprintf( "%s - %s[white]", widget.settings.Colors.Title, repoData.Repository, ) _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.Data), widget.Idx, width) + "\n" str += fmt.Sprintf(" [%s]Branch:Bookmark[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf(" %s:%s\n", repoData.Branch, repoData.Bookmark) str += "\n" str += widget.formatChanges(repoData.ChangedFiles) str += "\n" str += widget.formatCommits(repoData.Commits) return title, str, false } func (widget *Widget) formatChanges(data []string) string { str := fmt.Sprintf(" [%s]Changed Files[white]\n", widget.settings.Colors.Subheading) if len(data) == 1 { str += " [grey]none[white]\n" } else { for _, line := range data { str += widget.formatChange(line) } } return str } func (widget *Widget) formatChange(line string) string { if line == "" { return "" } line = strings.TrimSpace(line) firstChar, _ := utf8.DecodeRuneInString(line) // Revisit this and kill the ugly duplication switch firstChar { case 'A': line = strings.Replace(line, "A", "[green]A[white]", 1) case 'D': line = strings.Replace(line, "D", "[red]D[white]", 1) case 'M': line = strings.Replace(line, "M", "[yellow]M[white]", 1) case 'R': line = strings.Replace(line, "R", "[purple]R[white]", 1) } return fmt.Sprintf(" %s\n", strings.ReplaceAll(line, "\"", "")) } func (widget *Widget) formatCommits(data []string) string { str := fmt.Sprintf(" [%s]Recent Commits[white]\n", widget.settings.Colors.Subheading) for _, line := range data { str += widget.formatCommit(line) } return str } func (widget *Widget) formatCommit(line string) string { return fmt.Sprintf(" %s\n", strings.ReplaceAll(line, "\"", "")) } ================================================ FILE: modules/mercurial/hg_repo.go ================================================ package mercurial import ( "fmt" "os" "os/exec" "path" "strings" "github.com/wtfutil/wtf/utils" ) type MercurialRepo struct { Branch string Bookmark string ChangedFiles []string Commits []string Repository string Path string } func NewMercurialRepo(repoPath string, commitCount int, commitFormat string) *MercurialRepo { repo := MercurialRepo{Path: repoPath} repo.Branch = strings.TrimSpace(repo.branch()) repo.Bookmark = strings.TrimSpace(repo.bookmark()) repo.ChangedFiles = repo.changedFiles() repo.Commits = repo.commits(commitCount, commitFormat) repo.Repository = strings.TrimSpace(repo.Path) return &repo } /* -------------------- Unexported Functions -------------------- */ func (repo *MercurialRepo) branch() string { arg := []string{"branch", repo.repoPath()} cmd := exec.Command("hg", arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *MercurialRepo) bookmark() string { bookmark, err := os.ReadFile(path.Join(repo.Path, ".hg", "bookmarks.current")) if err != nil { return "" } return string(bookmark) } func (repo *MercurialRepo) changedFiles() []string { arg := []string{"status", repo.repoPath()} cmd := exec.Command("hg", arg...) str := utils.ExecuteCommand(cmd) data := strings.Split(str, "\n") return data } func (repo *MercurialRepo) commits(commitCount int, commitFormat string) []string { numStr := fmt.Sprintf("-l %d", commitCount) commitStr := fmt.Sprintf("--template=\"%s\n\"", commitFormat) arg := []string{"log", repo.repoPath(), numStr, commitStr} cmd := exec.Command("hg", arg...) str := utils.ExecuteCommand(cmd) data := strings.Split(str, "\n") return data } func (repo *MercurialRepo) pull() string { arg := []string{"pull", repo.repoPath()} cmd := exec.Command("hg", arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *MercurialRepo) checkout(branch string) string { arg := []string{"checkout", repo.repoPath(), branch} cmd := exec.Command("hg", arg...) str := utils.ExecuteCommand(cmd) return str } func (repo *MercurialRepo) repoPath() string { return fmt.Sprintf("--repository=%s", repo.Path) } ================================================ FILE: modules/mercurial/keyboard.go ================================================ package mercurial import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("p", widget.Pull, "Pull repo") widget.SetKeyboardChar("c", widget.Checkout, "Checkout branch") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") } ================================================ FILE: modules/mercurial/settings.go ================================================ package mercurial import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Mercurial" ) type Settings struct { *cfg.Common commitCount int `help:"The number of past commits to display." optional:"true"` commitFormat string `help:"The string format for the commit message." optional:"true"` repositories []interface{} `help:"Defines which mercurial repositories to watch." values:"A list of zero or more local file paths pointing to valid mercurial repositories."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), commitCount: ymlConfig.UInt("commitCount", 10), commitFormat: ymlConfig.UString("commitFormat", "[forestgreen]{rev}:{phase} [white]{desc|firstline|strip} [grey]{author|person} {date|age}[white]"), repositories: ymlConfig.UList("repositories"), } return &settings } ================================================ FILE: modules/mercurial/widget.go ================================================ package mercurial import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( modalHeight = 7 modalWidth = 80 offscreen = -1000 ) // A Widget represents a Mercurial widget type Widget struct { view.MultiSourceWidget view.TextWidget Data []*MercurialRepo pages *tview.Pages settings *Settings tviewApp *tview.Application } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "repository", "repositories"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), tviewApp: tviewApp, pages: pages, settings: settings, } widget.SetDisplayFunction(widget.display) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Checkout() { form := widget.modalForm("Branch to checkout:", "") checkoutFctn := func() { text := form.GetFormItem(0).(*tview.InputField).GetText() repoToCheckout := widget.Data[widget.Idx] repoToCheckout.checkout(text) widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() widget.Refresh() } widget.addButtons(form, checkoutFctn) widget.modalFocus(form) } func (widget *Widget) Pull() { repoToPull := widget.Data[widget.Idx] repoToPull.pull() widget.Refresh() } func (widget *Widget) Refresh() { repoPaths := utils.ToStrs(widget.settings.repositories) widget.Data = widget.mercurialRepos(repoPaths) widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) addCheckoutButton(form *tview.Form, fctn func()) { form.AddButton("Checkout", fctn) } func (widget *Widget) addButtons(form *tview.Form, checkoutFctn func()) { widget.addCheckoutButton(form, checkoutFctn) widget.addCancelButton(form) } func (widget *Widget) addCancelButton(form *tview.Form) { cancelFn := func() { widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() } form.AddButton("Cancel", cancelFn) form.SetCancelFunc(cancelFn) } func (widget *Widget) modalFocus(form *tview.Form) { frame := widget.modalFrame(form) widget.pages.AddPage("modal", frame, false, true) widget.tviewApp.SetFocus(frame) } func (widget *Widget) modalForm(lbl, text string) *tview.Form { form := tview.NewForm() form.SetButtonsAlign(tview.AlignCenter) form.SetButtonTextColor(tview.Styles.PrimaryTextColor) form.AddInputField(lbl, text, 60, nil, nil) return form } func (widget *Widget) modalFrame(form *tview.Form) *tview.Frame { frame := tview.NewFrame(form) frame.SetBorders(0, 0, 0, 0, 0, 0) frame.SetRect(offscreen, offscreen, modalWidth, modalHeight) frame.SetBorder(true) frame.SetBorders(1, 1, 0, 0, 1, 1) drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { w, h := screen.Size() frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height) return x, y, width, height } frame.SetDrawFunc(drawFunc) return frame } func (widget *Widget) currentData() *MercurialRepo { if len(widget.Data) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.Data) { return nil } return widget.Data[widget.Idx] } func (widget *Widget) mercurialRepos(repoPaths []string) []*MercurialRepo { repos := []*MercurialRepo{} for _, repoPath := range repoPaths { repo := NewMercurialRepo(repoPath, widget.settings.commitCount, widget.settings.commitFormat) repos = append(repos, repo) } return repos } ================================================ FILE: modules/nbascore/keyboard.go ================================================ package nbascore import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("l", widget.next, "Select next item") widget.SetKeyboardChar("h", widget.prev, "Select previous item") widget.SetKeyboardChar("c", widget.center, "Center on item") widget.SetKeyboardKey(tcell.KeyRight, widget.next, "Select next item") widget.SetKeyboardKey(tcell.KeyLeft, widget.prev, "Select previous item") } func (widget *Widget) center() { offset = 0 widget.Refresh() } func (widget *Widget) next() { offset++ widget.Refresh() } func (widget *Widget) prev() { offset-- widget.Refresh() } ================================================ FILE: modules/nbascore/settings.go ================================================ package nbascore import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "NBA Score" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } settings.SetDocumentationPath("sports/nbascore") return &settings } ================================================ FILE: modules/nbascore/widget.go ================================================ package nbascore import ( "encoding/json" "fmt" "io" "net/http" "strconv" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) var offset = 0 // A Widget represents an NBA Score widget type Widget struct { view.TextWidget language string settings *Settings } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() widget.View.SetScrollable(true) return &widget } func (widget *Widget) Refresh() { widget.Redraw(widget.nbascore) } func (widget *Widget) nbascore() (string, string, bool) { title := widget.CommonSettings().Title cur := time.Now().AddDate(0, 0, offset) // Go back/forward offset days curString := cur.Format("20060102") // Need 20060102 format to feed to api client := &http.Client{} req, err := http.NewRequest("GET", "http://data.nba.net/10s/prod/v1/"+curString+"/scoreboard.json", http.NoBody) if err != nil { return title, err.Error(), true } req.Header.Set("Accept-Language", widget.language) req.Header.Set("User-Agent", "curl") response, err := client.Do(req) if err != nil { return title, err.Error(), true } defer func() { _ = response.Body.Close() }() if response.StatusCode != 200 { return title, err.Error(), true } // Get data from data.nba.net and check if successful contents, err := io.ReadAll(response.Body) if err != nil { return title, err.Error(), true } result := map[string]interface{}{} err = json.Unmarshal(contents, &result) if err != nil { return title, err.Error(), true } allGame := fmt.Sprintf(" [%s]", widget.settings.Colors.Subheading) + (cur.Format(utils.FriendlyDateFormat) + "\n\n") + "[white]" for _, game := range result["games"].([]interface{}) { vTeam, hTeam, vScore, hScore := "", "", "", "" quarter := 0. activate := false for keyGame, team := range game.(map[string]interface{}) { // assertion switch keyGame { case "vTeam", "hTeam": for keyTeam, stat := range team.(map[string]interface{}) { switch keyTeam { case "triCode": if keyGame == "vTeam" { vTeam = stat.(string) } else { hTeam = stat.(string) } case "score": if keyGame == "vTeam" { vScore = stat.(string) } else { hScore = stat.(string) } } } case "period": for keyTeam, stat := range team.(map[string]interface{}) { if keyTeam == "current" { quarter = stat.(float64) } } case "isGameActivated": activate = team.(bool) } } vNum, _ := strconv.Atoi(vScore) hNum, _ := strconv.Atoi(hScore) hColor := "" if quarter != 0 { // Compare the score switch { case vNum > hNum: vTeam = "[orange]" + vTeam case hNum > vNum: // hScore = "[orange]" + hScore hColor = "[orange]" // For correct padding hTeam += "[white]" default: vTeam = "[orange]" + vTeam hColor = "[orange]" hTeam += "[white]" } } qColor := "[white]" if activate { qColor = "[sandybrown]" } allGame += fmt.Sprintf("%s%5s%v[white] %s %3s [white]vs %s%-3s %s\n", qColor, "Q", quarter, vTeam, vScore, hColor, hScore, hTeam) // Format the score and store in allgame } return title, allGame, false } ================================================ FILE: modules/newrelic/client/README.md ================================================ [![GoDoc](http://img.shields.io/badge/godoc-reference-blue.svg)](http://godoc.org/github.com/yfronto/newrelic) [![Build status](https://travis-ci.org/yfronto/newrelic.svg)](https://travis-ci.org/yfronto/newrelic) # New Relic API library for Go This is a Go library that wraps the [New Relic][1] REST API. It provides the needed types to interact with the New Relic REST API. It's still in progress and I haven't finished the entirety of the API, yet. I plan to finish all GET (read) operations before any POST (create) operations, and then PUT (update) operations, and, finally, the DELETE operations. The API documentation can be found from [New Relic][1], and you'll need an API key (for some operations, an Admin API key is required). ## USAGE This library will provide a client object and any operations can be performed through it. Simply import this library and create a client to get started: ```go package main import ( "github.com/yfronto/newrelic" ) var api_key = "..." // Required func main() { // Create the client object client := newrelic.NewClient(api_key) // Get the applciation with ID 12345 myApp, err := client.GetApplication(12345) if err != nil { // Handle error } // Work with the object fmt.Println(myApp.Name) // Some operations accept options opts := &newrelic.AlertEventOptions{ // Only events with "MyProduct" as the product name Filter: newRelic.AlertEventFilter{ Product: "MyProduct", }, } // Get a list of recent events for my product events, err := client.GetAlertEvents(opts) if err != nil { // Handle error } // Display each event with some information for _, e := range events { fmt.Printf("%d -- %d (%s): %s\n", e.Timestamp, e.Id, e.Priority, e.Description) } } ``` ## Contributing As I work to populate all actions, bugs are bound to come up. Feel free to send me a pull request or just file an issue. Staying up to date with an API is hard work and I'm happy to accept contributors. **DISCLAIMER:** *I am in no way affiliated with New Relic and this work is merely a convenience project for myself with no guarantees. It should be considered "as-is" with no implication of responsibility. See the included LICENSE for more details.* [1]: http://www.newrelic.com ================================================ FILE: modules/newrelic/client/alert_conditions.go ================================================ package newrelic // AlertCondition describes what triggers an alert for a specific policy. type AlertCondition struct { Enabled bool `json:"enabled,omitempty"` Entities []string `json:"entities,omitempty"` ID int `json:"id,omitempty"` Metric string `json:"metric,omitempty"` Name string `json:"name,omitempty"` RunbookURL string `json:"runbook_url,omitempty"` Terms []AlertConditionTerm `json:"terms,omitempty"` Type string `json:"type,omitempty"` UserDefined AlertUserDefined `json:"user_defined,omitempty"` } // AlertConditionTerm defines thresholds that trigger an AlertCondition. type AlertConditionTerm struct { Duration string `json:"duration,omitempty"` Operator string `json:"operator,omitempty"` Priority string `json:"priority,omitempty"` Threshold string `json:"threshold,omitempty"` TimeFunction string `json:"time_function,omitempty"` } // AlertUserDefined describes user-defined behavior for an AlertCondition. type AlertUserDefined struct { Metric string `json:"metric,omitempty"` ValueFunction string `json:"value_function,omitempty"` } // AlertConditionOptions define filters for GetAlertConditions. type AlertConditionOptions struct { policyID int Page int } func (o *AlertConditionOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "policy_id": o.policyID, "page": o.Page, }) } // GetAlertConditions will return any AlertCondition defined for a given // policy, optionally filtered by AlertConditionOptions. func (c *Client) GetAlertConditions(policy int, options *AlertConditionOptions) ([]AlertCondition, error) { resp := &struct { Conditions []AlertCondition `json:"conditions,omitempty"` }{} options.policyID = policy err := c.doGet("alerts_conditions.json", options, resp) if err != nil { return nil, err } return resp.Conditions, nil } ================================================ FILE: modules/newrelic/client/alert_events.go ================================================ package newrelic // AlertEvent describes a triggered event. type AlertEvent struct { ID int `json:"id,omitempty"` EventType string `json:"event_type,omitempty"` Product string `json:"product,omitempty"` EntityType string `json:"entity_type,omitempty"` EntityGroupID int `json:"entity_group_id,omitempty"` EntityID int `json:"entity_id,omitempty"` Priority string `json:"priority,omitempty"` Description string `json:"description,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` IncidentID int `json:"incident_id"` } // AlertEventFilter provides filters for AlertEventOptions when calling // GetAlertEvents. type AlertEventFilter struct { // TODO: New relic restricts these options Product string EntityType string EntityGroupID int EntityID int EventType string } // AlertEventOptions is an optional means of filtering AlertEvents when // calling GetAlertEvents. type AlertEventOptions struct { Filter AlertEventFilter Page int } func (o *AlertEventOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[product]": o.Filter.Product, "filter[entity_type]": o.Filter.EntityType, "filter[entity_group_id]": o.Filter.EntityGroupID, "filter[entity_id]": o.Filter.EntityID, "filter[event_type]": o.Filter.EventType, "page": o.Page, }) } // GetAlertEvents will return a slice of recent AlertEvent items triggered, // optionally filtering by AlertEventOptions. func (c *Client) GetAlertEvents(options *AlertEventOptions) ([]AlertEvent, error) { resp := &struct { RecentEvents []AlertEvent `json:"recent_events,omitempty"` }{} err := c.doGet("alerts_events.json", options, resp) if err != nil { return nil, err } return resp.RecentEvents, nil } ================================================ FILE: modules/newrelic/client/application_deployments.go ================================================ package newrelic import ( "strconv" "time" ) // ApplicationDeploymentLinks represents links that apply to an // ApplicationDeployment. type ApplicationDeploymentLinks struct { Application int `json:"application,omitempty"` } // ApplicationDeploymentOptions provide a means to filter when calling // GetApplicationDeployments. type ApplicationDeploymentOptions struct { Page int } // ApplicationDeployment contains information about a New Relic Application // Deployment. type ApplicationDeployment struct { ID int `json:"id,omitempty"` Revision string `json:"revision,omitempty"` Changelog string `json:"changelog,omitempty"` Description string `json:"description,omitempty"` User string `json:"user,omitempty"` Timestamp time.Time `json:"timestamp,omitempty"` Links ApplicationDeploymentLinks `json:"links,omitempty"` } // GetApplicationDeployments returns a slice of New Relic Application // Deployments. func (c *Client) GetApplicationDeployments(id int, opt *ApplicationDeploymentOptions) ([]ApplicationDeployment, error) { resp := &struct { Deployments []ApplicationDeployment `json:"deployments,omitempty"` }{} path := "applications/" + strconv.Itoa(id) + "/deployments.json" err := c.doGet(path, opt, resp) if err != nil { return nil, err } return resp.Deployments, nil } func (o *ApplicationDeploymentOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/application_host_metrics.go ================================================ package newrelic import ( "fmt" ) // GetApplicationHostMetrics will return a slice of Metric items for a // particular Application ID's Host ID, optionally filtering by // MetricsOptions. func (c *Client) GetApplicationHostMetrics(appID, hostID int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "applications/%d/hosts/%d/metrics.json", appID, hostID, ), options, ) } // GetApplicationHostMetricData will return all metric data for a particular // application's host and slice of metric names, optionally filtered by // MetricDataOptions. func (c *Client) GetApplicationHostMetricData(appID, hostID int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "applications/%d/hosts/%d/metrics/data.json", appID, hostID, ), names, options, ) } ================================================ FILE: modules/newrelic/client/application_hosts.go ================================================ package newrelic import ( "strconv" ) // ApplicationHostSummary describes an Application's host. type ApplicationHostSummary struct { ApdexScore float64 `json:"apdex_score,omitempty"` ErrorRate float64 `json:"error_rate,omitempty"` InstanceCount int `json:"instance_count,omitempty"` ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` } // ApplicationHostEndUserSummary describes the end user summary component of // an ApplicationHost. type ApplicationHostEndUserSummary struct { ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` ApdexScore float64 `json:"apdex_score,omitempty"` } // ApplicationHostLinks list IDs associated with an ApplicationHost. type ApplicationHostLinks struct { Application int `json:"application,omitempty"` ApplicationInstances []int `json:"application_instances,omitempty"` Server int `json:"server,omitempty"` } // ApplicationHost describes a New Relic Application Host. type ApplicationHost struct { ApplicationName string `json:"application_name,omitempty"` ApplicationSummary ApplicationHostSummary `json:"application_summary,omitempty"` HealthStatus string `json:"health_status,omitempty"` Host string `json:"host,omitempty"` ID int `json:"idomitempty"` Language string `json:"language,omitempty"` Links ApplicationHostLinks `json:"links,omitempty"` EndUserSummary ApplicationHostEndUserSummary `json:"end_user_summary,omitempty"` } // ApplicationHostsFilter provides a means to filter requests through // ApplicationHostsOptions when calling GetApplicationHosts. type ApplicationHostsFilter struct { Hostname string IDs []int } // ApplicationHostsOptions provide a means to filter results when calling // GetApplicationHosts. type ApplicationHostsOptions struct { Filter ApplicationHostsFilter Page int } // GetApplicationHosts returns a slice of New Relic Application Hosts, // optionally filtering by ApplicationHostOptions. func (c *Client) GetApplicationHosts(id int, options *ApplicationHostsOptions) ([]ApplicationHost, error) { resp := &struct { ApplicationHosts []ApplicationHost `json:"application_hosts,omitempty"` }{} path := "applications/" + strconv.Itoa(id) + "/hosts.json" err := c.doGet(path, options, resp) if err != nil { return nil, err } return resp.ApplicationHosts, nil } // GetApplicationHost returns a single Application Host associated with the // given application host ID and host ID. func (c *Client) GetApplicationHost(appID, hostID int) (*ApplicationHost, error) { resp := &struct { ApplicationHost ApplicationHost `json:"application_host,omitempty"` }{} path := "applications/" + strconv.Itoa(appID) + "/hosts/" + strconv.Itoa(hostID) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return &resp.ApplicationHost, nil } func (o *ApplicationHostsOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[hostname]": o.Filter.Hostname, "filter[ids]": o.Filter.IDs, "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/application_instance_metrics.go ================================================ package newrelic import ( "fmt" ) // GetApplicationInstanceMetrics will return a slice of Metric items for a // particular Application ID's instance ID, optionally filtering by // MetricsOptions. func (c *Client) GetApplicationInstanceMetrics(appID, instanceID int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "applications/%d/instances/%d/metrics.json", appID, instanceID, ), options, ) } // GetApplicationInstanceMetricData will return all metric data for a // particular application's instance and slice of metric names, optionally // filtered by MetricDataOptions. func (c *Client) GetApplicationInstanceMetricData(appID, instanceID int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "applications/%d/instances/%d/metrics/data.json", appID, instanceID, ), names, options, ) } ================================================ FILE: modules/newrelic/client/application_instances.go ================================================ package newrelic import ( "strconv" ) // ApplicationInstanceSummary describes an Application's instance. type ApplicationInstanceSummary struct { ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` ErrorRate float64 `json:"error_rate,omitempty"` ApdexScore float64 `json:"apdex_score,omitempty"` InstanceCount int `json:"instance_count,omitempty"` } // ApplicationInstanceEndUserSummary describes the end user summary component // of an ApplicationInstance. type ApplicationInstanceEndUserSummary struct { ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` ApdexScore float64 `json:"apdex_score,omitempty"` } // ApplicationInstanceLinks lists IDs associated with an ApplicationInstances. type ApplicationInstanceLinks struct { Application int `json:"application,omitempty"` ApplicationHost int `json:"application_host,omitempty"` Server int `json:"server,omitempty"` } // ApplicationInstance describes a New Relic Application instance. type ApplicationInstance struct { ID int `json:"id,omitempty"` ApplicationName string `json:"application_name,omitempty"` Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` Language string `json:"language,omitempty"` HealthStatus string `json:"health_status,omitempty"` ApplicationSummary ApplicationInstanceSummary `json:"application_summary,omitempty"` EndUserSummary ApplicationInstanceEndUserSummary `json:"end_user_summary,omitempty"` Links ApplicationInstanceLinks `json:"links,omitempty"` } // ApplicationInstancesFilter provides a means to filter requests through // ApplicationInstancesOptions when calling GetApplicationInstances. type ApplicationInstancesFilter struct { Hostname string IDs []int } // ApplicationInstancesOptions provides a means to filter results when calling // GetApplicationInstances. type ApplicationInstancesOptions struct { Filter ApplicationInstancesFilter Page int } // GetApplicationInstances returns a slice of New Relic Application Instances, // optionall filtering by ApplicationInstancesOptions. func (c *Client) GetApplicationInstances(appID int, options *ApplicationInstancesOptions) ([]ApplicationInstance, error) { resp := &struct { ApplicationInstances []ApplicationInstance `json:"application_instances,omitempty"` }{} path := "applications/" + strconv.Itoa(appID) + "/instances.json" err := c.doGet(path, options, resp) if err != nil { return nil, err } return resp.ApplicationInstances, nil } // GetApplicationInstance returns a single Application Instance associated // with the given application ID and instance ID func (c *Client) GetApplicationInstance(appID, instanceID int) (*ApplicationInstance, error) { resp := &struct { ApplicationInstance ApplicationInstance `json:"application_instance,omitempty"` }{} path := "applications/" + strconv.Itoa(appID) + "/instances/" + strconv.Itoa(instanceID) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return &resp.ApplicationInstance, nil } func (o *ApplicationInstancesOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[hostname]": o.Filter.Hostname, "filter[ids]": o.Filter.IDs, "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/application_metrics.go ================================================ package newrelic import ( "fmt" ) // GetApplicationMetrics will return a slice of Metric items for a // particular Application ID, optionally filtering by // MetricsOptions. func (c *Client) GetApplicationMetrics(id int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "applications/%d/metrics.json", id, ), options, ) } // GetApplicationMetricData will return all metric data for a particular // application and slice of metric names, optionally filtered by // MetricDataOptions. func (c *Client) GetApplicationMetricData(id int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "applications/%d/metrics/data.json", id, ), names, options, ) } ================================================ FILE: modules/newrelic/client/applications.go ================================================ package newrelic import ( "strconv" "time" ) // ApplicationSummary describes the brief summary component of an Application. type ApplicationSummary struct { ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` ErrorRate float64 `json:"error_rate,omitempty"` ApdexTarget float64 `json:"apdex_target,omitempty"` ApdexScore float64 `json:"apdex_score,omitempty"` HostCount int `json:"host_count,omitempty"` InstanceCount int `json:"instance_count,omitempty"` ConcurrentInstanceCount int `json:"concurrent_instance_count,omitempty"` } // EndUserSummary describes the end user summary component of an Application. type EndUserSummary struct { ResponseTime float64 `json:"response_time,omitempty"` Throughput float64 `json:"throughput,omitempty"` ApdexTarget float64 `json:"apdex_target,omitempty"` ApdexScore float64 `json:"apdex_score,omitempty"` } // Settings describe settings for an Application. type Settings struct { AppApdexThreshold float64 `json:"app_apdex_threshold,omitempty"` EndUserApdexThreshold float64 `json:"end_user_apdex_threshold,omitempty"` EnableRealUserMonitoring bool `json:"enable_real_user_monitoring,omitempty"` UseServerSideConfig bool `json:"use_server_side_config,omitempty"` } // Links list IDs associated with an Application. type Links struct { Servers []int `json:"servers,omitempty"` ApplicationHosts []int `json:"application_hosts,omitempty"` ApplicationInstances []int `json:"application_instances,omitempty"` AlertPolicy int `json:"alert_policy,omitempty"` } // Application describes a New Relic Application. type Application struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Language string `json:"language,omitempty"` HealthStatus string `json:"health_status,omitempty"` Reporting bool `json:"reporting,omitempty"` LastReportedAt time.Time `json:"last_reported_at,omitempty"` ApplicationSummary ApplicationSummary `json:"application_summary,omitempty"` EndUserSummary EndUserSummary `json:"end_user_summary,omitempty"` Settings Settings `json:"settings,omitempty"` Links Links `json:"links,omitempty"` } // ApplicationFilter provides a means to filter requests through // ApplicaitonOptions when calling GetApplications. type ApplicationFilter struct { Name string Host string IDs []int Language string } // ApplicationOptions provides a means to filter results when calling // GetApplicaitons. type ApplicationOptions struct { Filter ApplicationFilter Page int } func (o *ApplicationOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[name]": o.Filter.Name, "filter[host]": o.Filter.Host, "filter[ids]": o.Filter.IDs, "filter[language]": o.Filter.Language, "page": o.Page, }) } // GetApplications returns a slice of New Relic Applications, optionally // filtering by ApplicationOptions. func (c *Client) GetApplications(options *ApplicationOptions) ([]Application, error) { resp := &struct { Applications []Application `json:"applications,omitempty"` }{} err := c.doGet("applications.json", options, resp) if err != nil { return nil, err } return resp.Applications, nil } // GetApplication returns a single Application associated with a given ID. func (c *Client) GetApplication(id int) (*Application, error) { resp := &struct { Application Application `json:"application,omitempty"` }{} path := "applications/" + strconv.Itoa(id) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return &resp.Application, nil } ================================================ FILE: modules/newrelic/client/array.go ================================================ package newrelic // An Array is a type expected by the NewRelic API that differs from a comma- // separated list. When passing GET params that expect an 'Array' type with // one to many values, the expected format is "key=val1&key=val2" but an // argument with zero to many values is of the form "key=val1,val2", and // neither can be used in the other's place, so we have to differentiate // somehow. type Array struct { arr []string } ================================================ FILE: modules/newrelic/client/browser_applications.go ================================================ package newrelic // BrowserApplicationsFilter is the filtering component of // BrowserApplicationsOptions type BrowserApplicationsFilter struct { Name string IDs []int } // BrowserApplicationsOptions provides a filtering mechanism for // GetBrowserApplications. type BrowserApplicationsOptions struct { Filter BrowserApplicationsFilter Page int } // BrowserApplication describes a New Relic Browser Application. type BrowserApplication struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` BrowserMonitoringKey string `json:"browser_monitoring_key,omitempty"` LoaderScript string `json:"loader_script,omitempty"` } // GetBrowserApplications will return a slice of New Relic Browser // Applications, optionally filtered by BrowserApplicationsOptions. func (c *Client) GetBrowserApplications(opt *BrowserApplicationsOptions) ([]BrowserApplication, error) { resp := &struct { BrowserApplications []BrowserApplication `json:"browser_applications,omitempty"` }{} path := "browser_applications.json" err := c.doGet(path, opt, resp) if err != nil { return nil, err } return resp.BrowserApplications, nil } func (o *BrowserApplicationsOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[name]": o.Filter.Name, "filter[ids]": o.Filter.IDs, "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/component_metrics.go ================================================ package newrelic import ( "fmt" ) // GetComponentMetrics will return a slice of Metric items for a // particular Component ID, optionally filtered by MetricsOptions. func (c *Client) GetComponentMetrics(id int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "components/%d/metrics.json", id, ), options, ) } // GetComponentMetricData will return all metric data for a particular // component, optionally filtered by MetricDataOptions. func (c *Client) GetComponentMetricData(id int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "components/%d/metrics/data.json", id, ), names, options, ) } ================================================ FILE: modules/newrelic/client/http_helper.go ================================================ package newrelic import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) func (c *Client) doGet(path string, params fmt.Stringer, out interface{}) error { var s string if params != nil { s = params.String() } r := strings.NewReader(s) req, err := http.NewRequest("GET", c.url.String()+path, r) if err != nil { return err } req.Header.Add("X-Api-Key", c.apiKey) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return c.doRequest(req, out) } func (c *Client) doRequest(req *http.Request, out interface{}) error { resp, err := c.httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() b, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != 200 { return fmt.Errorf("newrelic http error (%s): %s", resp.Status, b) } if len(b) == 0 { b = []byte{'{', '}'} } err = json.Unmarshal(b, &out) if err != nil { return err } return nil } func encodeGetParams(params map[string]interface{}) string { s := url.Values{} for k, v := range params { switch val := v.(type) { case string: if val != "" { s.Add(k, val) } case int: if val != 0 { s.Add(k, strconv.Itoa(val)) } case []string: if len(val) != 0 { s.Add(k, strings.Join(val, ",")) } case []int: arr := []string{} for _, v := range val { arr = append(arr, strconv.Itoa(v)) } if len(arr) != 0 { s.Add(k, strings.Join(arr, ",")) } case time.Time: if !val.IsZero() { s.Add(k, val.String()) } case Array: for _, v := range val.arr { s.Add(k, v) } case bool: s.Add(k, "true") default: s.Add(k, fmt.Sprintf("%v", v)) } } return s.Encode() } ================================================ FILE: modules/newrelic/client/key_transactions.go ================================================ package newrelic import ( "strconv" "time" ) // KeyTransactionsFilter is the filtering component of KeyTransactionsOptions. type KeyTransactionsFilter struct { Name string IDs []int } // KeyTransactionsOptions provides a filtering mechanism for GetKeyTransactions. type KeyTransactionsOptions struct { Filter KeyTransactionsFilter Page int } // KeyTransactionLinks link KeyTransactions to the objects to which they // pertain. type KeyTransactionLinks struct { Application int `json:"application,omitempty"` } // KeyTransaction represents a New Relic Key Transaction. type KeyTransaction struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` TransactionName string `json:"transaction_name,omitempty"` HealthStatus string `json:"health_status,omitempty"` Reporting bool `json:"reporting,omitempty"` LastReportedAt time.Time `json:"last_reported_at,omitempty"` ApplicationSummary ApplicationSummary `json:"application_summary,omitempty"` EndUserSummary EndUserSummary `json:"end_user_summary,omitempty"` Links KeyTransactionLinks `json:"links,omitempty"` } // GetKeyTransactions will return a slice of New Relic Key Transactions, // optionally filtered by KeyTransactionsOptions. func (c *Client) GetKeyTransactions(opt *KeyTransactionsOptions) ([]KeyTransaction, error) { resp := &struct { KeyTransactions []KeyTransaction `json:"key_transactions,omitempty"` }{} path := "key_transactions.json" err := c.doGet(path, opt, resp) if err != nil { return nil, err } return resp.KeyTransactions, nil } // GetKeyTransaction will return a single New Relic Key Transaction for the // given id. func (c *Client) GetKeyTransaction(id int) (*KeyTransaction, error) { resp := &struct { KeyTransaction *KeyTransaction `json:"key_transaction,omitempty"` }{} path := "key_transactions/" + strconv.Itoa(id) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return resp.KeyTransaction, nil } func (o *KeyTransactionsOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[name]": o.Filter.Name, "filter[ids]": o.Filter.IDs, "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/legacy_alert_policies.go ================================================ package newrelic import ( "strconv" ) // LegacyAlertPolicyLinks describes object links for Alert Policies. type LegacyAlertPolicyLinks struct { NotificationChannels []int `json:"notification_channels,omitempty"` Servers []int `json:"servers,omitempty"` } // LegacyAlertPolicyCondition describes conditions that trigger an LegacyAlertPolicy. type LegacyAlertPolicyCondition struct { ID int `json:"id,omitempty"` Enabled bool `json:"enabled,omitempty"` Severity string `json:"severity,omitempty"` Threshold float64 `json:"threshold,omitempty"` TriggerMinutes int `json:"trigger_minutes,omitempty"` Type string `json:"type,omitempty"` } // LegacyAlertPolicy describes a New Relic alert policy. type LegacyAlertPolicy struct { Conditions []LegacyAlertPolicyCondition `json:"conditions,omitempty"` Enabled bool `json:"enabled,omitempty"` ID int `json:"id,omitempty"` Links LegacyAlertPolicyLinks `json:"links,omitempty"` IncidentPreference string `json:"incident_preference,omitempty"` Name string `json:"name,omitempty"` } // LegacyAlertPolicyFilter provides filters for LegacyAlertPolicyOptions. type LegacyAlertPolicyFilter struct { Name string } // LegacyAlertPolicyOptions is an optional means of filtering when calling // GetLegacyAlertPolicies. type LegacyAlertPolicyOptions struct { Filter LegacyAlertPolicyFilter Page int } func (o *LegacyAlertPolicyOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[name]": o.Filter.Name, "page": o.Page, }) } // GetLegacyAlertPolicy will return the LegacyAlertPolicy with particular ID. func (c *Client) GetLegacyAlertPolicy(id int) (*LegacyAlertPolicy, error) { resp := &struct { LegacyAlertPolicy *LegacyAlertPolicy `json:"alert_policy,omitempty"` }{} err := c.doGet("alert_policies/"+strconv.Itoa(id)+".json", nil, resp) if err != nil { return nil, err } return resp.LegacyAlertPolicy, nil } // GetLegacyAlertPolicies will return a slice of LegacyAlertPolicy items, // optionally filtering by LegacyAlertPolicyOptions. func (c *Client) GetLegacyAlertPolicies(options *LegacyAlertPolicyOptions) ([]LegacyAlertPolicy, error) { resp := &struct { LegacyAlertPolicies []LegacyAlertPolicy `json:"alert_policies,omitempty"` }{} err := c.doGet("alert_policies.json", options, resp) if err != nil { return nil, err } return resp.LegacyAlertPolicies, nil } ================================================ FILE: modules/newrelic/client/main.go ================================================ /* * NewRelic API for Go * * Please see the included LICENSE file for licensing information. * * Copyright 2016 by authors and contributors. */ package newrelic import ( "net/http" "net/url" "time" ) const ( // defaultAPIURL is the default base URL for New Relic's latest API. defaultAPIURL = "https://api.newrelic.com/v2/" // defaultTimeout is the default timeout for the http.Client used. defaultTimeout = 5 * time.Second ) // Client provides a set of methods to interact with the New Relic API. type Client struct { apiKey string httpClient *http.Client url *url.URL } // NewWithHTTPClient returns a new Client object for interfacing with the New // Relic API, allowing for override of the http.Client object. func NewWithHTTPClient(apiKey string, client *http.Client) *Client { u, err := url.Parse(defaultAPIURL) if err != nil { panic(err) } return &Client{ apiKey: apiKey, httpClient: client, url: u, } } // NewClient returns a new Client object for interfacing with the New Relic API. func NewClient(apiKey string) *Client { return NewWithHTTPClient(apiKey, &http.Client{Timeout: defaultTimeout}) } ================================================ FILE: modules/newrelic/client/metrics.go ================================================ package newrelic import ( "time" ) // Metric describes a New Relic metric. type Metric struct { Name string `json:"name,omitempty"` Values []string `json:"values,omitempty"` } // MetricsOptions options allow filtering when getting lists of metric names // associated with an entity. type MetricsOptions struct { Name string Page int } // MetricTimeslice describes the period to which a Metric pertains. type MetricTimeslice struct { From time.Time `json:"from,omitempty"` To time.Time `json:"to,omitempty"` Values map[string]float64 `json:"values,omitempty"` } // MetricData describes the data for a particular metric. type MetricData struct { Name string `json:"name,omitempty"` Timeslices []MetricTimeslice `json:"timeslices,omitempty"` } // MetricDataOptions allow filtering when getting data about a particular set // of New Relic metrics. type MetricDataOptions struct { Names Array Values Array From time.Time To time.Time Period int Summarize bool Raw bool } // MetricDataResponse is the response received from New Relic for any request // for metric data. type MetricDataResponse struct { From time.Time `json:"from,omitempty"` To time.Time `json:"to,omitempty"` MetricsNotFound []string `json:"metrics_not_found,omitempty"` MetricsFound []string `json:"metrics_found,omitempty"` Metrics []MetricData `json:"metrics,omitempty"` } func (o *MetricsOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "name": o.Name, "page": o.Page, }) } func (o *MetricDataOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "names[]": o.Names, "values[]": o.Values, "from": o.From, "to": o.To, "period": o.Period, "summarize": o.Summarize, "raw": o.Raw, }) } // MetricClient implements a generic New Relic metrics client. // This is used as a general client for fetching metric names and data. type MetricClient struct { newRelicClient *Client } // NewMetricClient creates and returns a new MetricClient. func NewMetricClient(newRelicClient *Client) *MetricClient { return &MetricClient{ newRelicClient: newRelicClient, } } // GetMetrics is a generic function for fetching a list of available metrics // from different parts of New Relic. // Example: Application metrics, Component metrics, etc. func (mc *MetricClient) GetMetrics(path string, options *MetricsOptions) ([]Metric, error) { resp := &struct { Metrics []Metric `json:"metrics,omitempty"` }{} err := mc.newRelicClient.doGet(path, options, resp) if err != nil { return nil, err } return resp.Metrics, nil } // GetMetricData is a generic function for fetching data for a specific metric. // from different parts of New Relic. // Example: Application metric data, Component metric data, etc. func (mc *MetricClient) GetMetricData(path string, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { resp := &struct { MetricData MetricDataResponse `json:"metric_data,omitempty"` }{} if options == nil { options = &MetricDataOptions{} } options.Names = Array{names} err := mc.newRelicClient.doGet(path, options, resp) if err != nil { return nil, err } return &resp.MetricData, nil } ================================================ FILE: modules/newrelic/client/mobile_application_metrics.go ================================================ package newrelic import ( "fmt" ) // GetMobileApplicationMetrics will return a slice of Metric items for a // particular MobileAplication ID, optionally filtering by // MetricsOptions. func (c *Client) GetMobileApplicationMetrics(id int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "mobile_applications/%d/metrics.json", id, ), options, ) } // GetMobileApplicationMetricData will return all metric data for a particular // MobileAplication and slice of metric names, optionally filtered by // MetricDataOptions. func (c *Client) GetMobileApplicationMetricData(id int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "mobile_applications/%d/metrics/data.json", id, ), names, options, ) } ================================================ FILE: modules/newrelic/client/mobile_applications.go ================================================ package newrelic import ( "strconv" ) // MobileApplicationSummary describes an Application's host. type MobileApplicationSummary struct { ActiveUsers int `json:"active_users,omitempty"` LaunchCount int `json:"launch_count,omitempty"` Throughput float64 `json:"throughput,omitempty"` ResponseTime float64 `json:"response_time,omitempty"` CallsPerSession float64 `json:"calls_per_session,omitempty"` InteractionTime float64 `json:"interaction_time,omitempty"` FailedCallRate float64 `json:"failed_call_rate,omitempty"` RemoteErrorRate float64 `json:"remote_error_rate"` } // MobileApplicationCrashSummary describes a MobileApplication's crash data. type MobileApplicationCrashSummary struct { SupportsCrashData bool `json:"supports_crash_data,omitempty"` UnresolvedCrashCount int `json:"unresolved_crash_count,omitempty"` CrashCount int `json:"crash_count,omitempty"` CrashRate float64 `json:"crash_rate,omitempty"` } // MobileApplication describes a New Relic Application Host. type MobileApplication struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` HealthStatus string `json:"health_status,omitempty"` Reporting bool `json:"reporting,omitempty"` MobileSummary MobileApplicationSummary `json:"mobile_summary,omitempty"` CrashSummary MobileApplicationCrashSummary `json:"crash_summary,omitempty"` } // GetMobileApplications returns a slice of New Relic Mobile Applications. func (c *Client) GetMobileApplications() ([]MobileApplication, error) { resp := &struct { Applications []MobileApplication `json:"applications,omitempty"` }{} path := "mobile_applications.json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return resp.Applications, nil } // GetMobileApplication returns a single Mobile Application with the id. func (c *Client) GetMobileApplication(id int) (*MobileApplication, error) { resp := &struct { Application MobileApplication `json:"application,omitempty"` }{} path := "mobile_applications/" + strconv.Itoa(id) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return &resp.Application, nil } ================================================ FILE: modules/newrelic/client/notification_channels.go ================================================ package newrelic import ( "strconv" ) // NotificationChannelLinks describes object links for notification channels. type NotificationChannelLinks struct { NotificationChannels []int `json:"notification_channels,omitempty"` User int `json:"user,omitempty"` } // NotificationChannel describes a New Relic notification channel. type NotificationChannel struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` DowntimeOnly bool `json:"downtime_only,omitempty"` URL string `json:"url,omitempty"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Email string `json:"email,omitempty"` Subdomain string `json:"subdomain,omitempty"` Service string `json:"service,omitempty"` MobileAlerts bool `json:"mobile_alerts,omitempty"` EmailAlerts bool `json:"email_alerts,omitempty"` Room string `json:"room,omitempty"` Links NotificationChannelLinks `json:"links,omitempty"` } // NotificationChannelsFilter provides filters for // NotificationChannelsOptions. type NotificationChannelsFilter struct { Type []string IDs []int } // NotificationChannelsOptions is an optional means of filtering when calling // GetNotificationChannels. type NotificationChannelsOptions struct { Filter NotificationChannelsFilter Page int } func (o *NotificationChannelsOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[type]": o.Filter.Type, "filter[ids]": o.Filter.IDs, "page": o.Page, }) } // GetNotificationChannel will return the NotificationChannel with particular ID. func (c *Client) GetNotificationChannel(id int) (*NotificationChannel, error) { resp := &struct { NotificationChannel *NotificationChannel `json:"notification_channel,omitempty"` }{} err := c.doGet("notification_channels/"+strconv.Itoa(id)+".json", nil, resp) if err != nil { return nil, err } return resp.NotificationChannel, nil } // GetNotificationChannels will return a slice of NotificationChannel items, // optionally filtering by NotificationChannelsOptions. func (c *Client) GetNotificationChannels(options *NotificationChannelsOptions) ([]NotificationChannel, error) { resp := &struct { NotificationChannels []NotificationChannel `json:"notification_channels,omitempty"` }{} err := c.doGet("notification_channels.json", options, resp) if err != nil { return nil, err } return resp.NotificationChannels, nil } ================================================ FILE: modules/newrelic/client/server_metrics.go ================================================ package newrelic import ( "fmt" ) // GetServerMetrics will return a slice of Metric items for a particular // Server ID, optionally filtering by MetricsOptions. func (c *Client) GetServerMetrics(id int, options *MetricsOptions) ([]Metric, error) { mc := NewMetricClient(c) return mc.GetMetrics( fmt.Sprintf( "servers/%d/metrics.json", id, ), options, ) } // GetServerMetricData will return all metric data for a particular Server and // slice of metric names, optionally filtered by MetricDataOptions. func (c *Client) GetServerMetricData(id int, names []string, options *MetricDataOptions) (*MetricDataResponse, error) { mc := NewMetricClient(c) return mc.GetMetricData( fmt.Sprintf( "servers/%d/metrics/data.json", id, ), names, options, ) } ================================================ FILE: modules/newrelic/client/servers.go ================================================ package newrelic import ( "strconv" "time" ) // ServersFilter is the filtering component of ServersOptions. type ServersFilter struct { Name string Host string IDs []int Labels []string Reported bool } // ServersOptions provides a filtering mechanism for GetServers. type ServersOptions struct { Filter ServersFilter Page int } // ServerSummary describes the summary component of a Server. type ServerSummary struct { CPU float64 `json:"cpu,omitempty"` CPUStolen float64 `json:"cpu_stolen,omitempty"` DiskIO float64 `json:"disk_io,omitempty"` Memory float64 `json:"memory,omitempty"` MemoryUsed int64 `json:"memory_used,omitempty"` MemoryTotal int64 `json:"memory_total,omitempty"` FullestDisk float64 `json:"fullest_disk,omitempty"` FullestDiskFree int64 `json:"fullest_disk_free,omitempty"` } // ServerLinks link Servers to the objects to which they pertain. type ServerLinks struct { AlertPolicy int `json:"alert_policy,omitempty"` } // Server represents a New Relic Server. type Server struct { ID int `json:"id,omitempty"` AccountID int `json:"account_id,omitempty"` Name string `json:"name,omitempty"` Host string `json:"host,omitempty"` HealthStatus string `json:"health_status,omitempty"` Reporting bool `json:"reporting,omitempty"` LastReportedAt time.Time `json:"last_reported_at,omitempty"` Summary ServerSummary `json:"summary,omitempty"` Links ServerLinks `json:"links,omitempty"` } // GetServers will return a slice of New Relic Servers, optionally filtered by // ServerOptions. func (c *Client) GetServers(opt *ServersOptions) ([]Server, error) { resp := &struct { Servers []Server `json:"servers,omitempty"` }{} path := "servers.json" err := c.doGet(path, opt, resp) if err != nil { return nil, err } return resp.Servers, nil } // GetServer will return a single New Relic Server for the given id. func (c *Client) GetServer(id int) (*Server, error) { resp := &struct { Server *Server `json:"server,omitempty"` }{} path := "servers/" + strconv.Itoa(id) + ".json" err := c.doGet(path, nil, resp) if err != nil { return nil, err } return resp.Server, nil } func (o *ServersOptions) String() string { if o == nil { return "" } return encodeGetParams(map[string]interface{}{ "filter[name]": o.Filter.Name, "filter[host]": o.Filter.Host, "filter[ids]": o.Filter.IDs, "filter[labels]": o.Filter.Labels, "filter[reported]": o.Filter.Reported, "page": o.Page, }) } ================================================ FILE: modules/newrelic/client/usages.go ================================================ package newrelic import ( "time" ) // Usage describes usage over a single time period. type Usage struct { From time.Time `json:"from,omitempty"` To time.Time `json:"to,omitempty"` Usage int `json:"usage,omitempty"` } // UsageData represents usage data for a product over a time frame, including // a slice of Usages. type UsageData struct { Product string `json:"product,omitempty"` From time.Time `json:"from,omitempty"` To time.Time `json:"to,omitempty"` Unit string `json:"unit,omitempty"` Usages []Usage `json:"usages,omitempty"` } type usageParams struct { Start time.Time End time.Time IncludeSubaccount bool } func (o *usageParams) String() string { return encodeGetParams(map[string]interface{}{ "start_date": o.Start.Format("2006-01-02"), "end_date": o.End.Format("2006-01-02"), "include_subaccounts": o.IncludeSubaccount, }) } // GetUsages will return usage for a product in a given time frame. func (c *Client) GetUsages(product string, start, end time.Time, includeSubaccounts bool) (*UsageData, error) { resp := &struct { UsageData *UsageData `json:"usage_data,omitempty"` }{} options := &usageParams{start, end, includeSubaccounts} err := c.doGet("usages/"+product+".json", options, resp) if err != nil { return nil, err } return resp.UsageData, nil } ================================================ FILE: modules/newrelic/client.go ================================================ package newrelic import ( nr "github.com/wtfutil/wtf/modules/newrelic/client" ) type Client2 struct { applicationId int nrClient *nr.Client } func NewClient(apiKey string, applicationId int) *Client2 { return &Client2{ applicationId: applicationId, nrClient: nr.NewClient(apiKey), } } func (client *Client2) Application() (*nr.Application, error) { application, err := client.nrClient.GetApplication(client.applicationId) if err != nil { return nil, err } return application, nil } func (client *Client2) Deployments() ([]nr.ApplicationDeployment, error) { opts := &nr.ApplicationDeploymentOptions{Page: 1} deployments, err := client.nrClient.GetApplicationDeployments(client.applicationId, opts) if err != nil { return nil, err } return deployments, nil } ================================================ FILE: modules/newrelic/display.go ================================================ package newrelic import ( "fmt" nr "github.com/wtfutil/wtf/modules/newrelic/client" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/wtf" ) func (widget *Widget) content() (string, string, bool) { client := widget.currentData() if client == nil { return widget.CommonSettings().Title, " NewRelic data unavailable ", false } app, appErr := client.Application() deploys, depErr := client.Deployments() appName := "error" if appErr == nil { appName = app.Name } var content string title := fmt.Sprintf("%s - [green]%s[white]", widget.CommonSettings().Title, appName) wrap := false if depErr != nil { wrap = true content = depErr.Error() } else { content = widget.contentFrom(deploys) } return title, content, wrap } func (widget *Widget) contentFrom(deploys []nr.ApplicationDeployment) string { str := fmt.Sprintf( " %s\n", fmt.Sprintf( "[%s]Latest Deploys[white]", widget.settings.Colors.Subheading, ), ) revisions := []string{} for _, deploy := range deploys { if (deploy.Revision != "") && utils.DoesNotInclude(revisions, deploy.Revision) { lineColor := "white" if wtf.IsToday(deploy.Timestamp) { lineColor = "lightblue" } revLen := 8 if revLen > len(deploy.Revision) { revLen = len(deploy.Revision) } str += fmt.Sprintf( " [green]%s[%s] %s %-.16s[white]\n", deploy.Revision[0:revLen], lineColor, deploy.Timestamp.Format("Jan 02 15:04 MST"), utils.NameFromEmail(deploy.User), ) revisions = append(revisions, deploy.Revision) if len(revisions) == widget.settings.deployCount { break } } } return str } ================================================ FILE: modules/newrelic/keyboard.go ================================================ package newrelic import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous application") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next application") } ================================================ FILE: modules/newrelic/settings.go ================================================ package newrelic import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "NewRelic" ) type Settings struct { *cfg.Common apiKey string `help:"Your New Relic API token."` deployCount int `help:"The number of past deploys to display on screen." optional:"true"` applicationIDs []interface{} `help:"The integer ID of the New Relic application you wish to report on."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_NEW_RELIC_API_KEY")), deployCount: ymlConfig.UInt("deployCount", 5), applicationIDs: ymlConfig.UList("applicationIDs"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/newrelic/widget.go ================================================ package newrelic import ( "sort" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.MultiSourceWidget view.TextWidget Clients []*Client2 settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "applicationID", "applicationIDs"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.initializeKeyboardControls() for _, id := range utils.ToInts(widget.settings.applicationIDs) { widget.Clients = append(widget.Clients, NewClient(widget.settings.apiKey, id)) } sort.Slice(widget.Clients, func(i, j int) bool { return widget.Clients[i].applicationId < widget.Clients[j].applicationId }) widget.SetDisplayFunction(widget.Refresh) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) currentData() *Client2 { if len(widget.Clients) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.Clients) { return nil } return widget.Clients[widget.Idx] } ================================================ FILE: modules/nextbus/settings.go ================================================ package nextbus import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "nextbus" ) // Settings defines the configuration properties for this module type Settings struct { common *cfg.Common route string `help:"Route Number of your bus"` agency string `help:"Transit agency of your bus"` stopID string `help:"Your bus stop number"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), route: ymlConfig.UString("route"), agency: ymlConfig.UString("agency"), stopID: ymlConfig.UString("stopID"), } return &settings } ================================================ FILE: modules/nextbus/widget.go ================================================ package nextbus import ( "encoding/json" "fmt" "io" "net/http" "strconv" "github.com/rivo/tview" "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { return getNextBus(widget.settings.agency, widget.settings.route, widget.settings.stopID) } type AutoGenerated struct { Copyright string `json:"copyright"` Predictions Predictions `json:"predictions"` } type Prediction struct { AffectedByLayover string `json:"affectedByLayover"` Seconds string `json:"seconds"` TripTag string `json:"tripTag"` Minutes string `json:"minutes"` IsDeparture string `json:"isDeparture"` Block string `json:"block"` DirTag string `json:"dirTag"` Branch string `json:"branch"` EpochTime string `json:"epochTime"` Vehicle string `json:"vehicle"` } type Direction struct { PredictionRaw json.RawMessage `json:"prediction"` Title string `json:"title"` } type Predictions struct { RouteTag string `json:"routeTag"` StopTag string `json:"stopTag"` RouteTitle string `json:"routeTitle"` AgencyTitle string `json:"agencyTitle"` StopTitle string `json:"stopTitle"` Direction Direction `json:"direction"` } func getNextBus(agency string, route string, stopID string) string { url := fmt.Sprintf("https://webservices.umoiq.com/service/publicJSONFeed?command=predictions&a=%s&r=%s&stopId=%s", agency, route, stopID) resp, err := http.Get(url) if err != nil { logger.Log(fmt.Sprintf("[nextbus] Error: Failed to make requests to umoiq for next bus predictions. Reason: %s", err)) return "[nextbus] error calling umoiq" } body, readErr := io.ReadAll(resp.Body) if readErr != nil { logger.Log(fmt.Sprintf("[nextbus] Error: Failed to parse response body from umoiq. Reason: %s", err)) return "[nextbus] error parsing response body" } defer resp.Body.Close() var parsedResponse AutoGenerated // partial unmarshal, we don't have r.Predictions.Direction.PredictionRaw <- YET unmarshalError := json.Unmarshal(body, &parsedResponse) if unmarshalError != nil { logger.Log(fmt.Sprintf("[nextbus] Error: Failed to unmarshal body from umoiq. Reason: %s", err)) return "[nextbus] error unmarshalling response body" } parseType := "" // hacky, try object parse first nextBusObject := Prediction{} if err := json.Unmarshal(parsedResponse.Predictions.Direction.PredictionRaw, &nextBusObject); err == nil { parseType = "object" } // if object parse failed, it probably means we have an array nextBuses := []Prediction{} if err := json.Unmarshal(parsedResponse.Predictions.Direction.PredictionRaw, &nextBuses); err == nil { parseType = "array" } // build the final string finalStr := "" if parseType == "array" { for _, nextBus := range nextBuses { finalStr += fmt.Sprintf("%s | ETA [%s]\n", parsedResponse.Predictions.RouteTitle, strTimeToInt(nextBus.Minutes, nextBus.Seconds)) } } else { finalStr += fmt.Sprintf("%s | ETA [%s]\n", parsedResponse.Predictions.RouteTitle, strTimeToInt(nextBusObject.Minutes, nextBusObject.Seconds)) } return finalStr } // takes minutes and seconds from the API, does math to find the remainder seconds // since the API only gives whole minutes func strTimeToInt(sourceMinutes string, sourceSeconds string) string { min, _ := strconv.Atoi(sourceMinutes) sec, _ := strconv.Atoi(sourceSeconds) sec = sec % 60 return fmt.Sprintf("%02d:%02d", min, sec) } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } ================================================ FILE: modules/opsgenie/client.go ================================================ package opsgenie import ( "encoding/json" "fmt" "net/http" ) type OnCallResponse struct { OnCallData OnCallData `json:"data"` Message string `json:"message"` RequestID string `json:"requestId"` Took float32 `json:"took"` } type OnCallData struct { Recipients []string `json:"onCallRecipients"` Parent Parent `json:"_parent"` } type Parent struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` } var opsGenieAPIUrl = map[string]string{ "us": "https://api.opsgenie.com", "eu": "https://api.eu.opsgenie.com", } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Fetch(scheduleIdentifierType string, schedules []string) ([]*OnCallResponse, error) { agregatedResponses := []*OnCallResponse{} if regionURL, regionErr := opsGenieAPIUrl[widget.settings.region]; regionErr { for _, sched := range schedules { scheduleURL := fmt.Sprintf("%s/v2/schedules/%s/on-calls?scheduleIdentifierType=%s&flat=true", regionURL, sched, scheduleIdentifierType) response, err := opsGenieRequest(scheduleURL, widget.settings.apiKey) agregatedResponses = append(agregatedResponses, response) if err != nil { return nil, err } } return agregatedResponses, nil } else { return nil, fmt.Errorf("you specified wrong region. Possible options are only 'us' and 'eu'") } } /* -------------------- Unexported Functions -------------------- */ func opsGenieRequest(url string, apiKey string) (*OnCallResponse, error) { req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } req.Header.Set("Authorization", fmt.Sprintf("GenieKey %s", apiKey)) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() response := &OnCallResponse{} if err := json.NewDecoder(resp.Body).Decode(response); err != nil { return nil, err } return response, nil } ================================================ FILE: modules/opsgenie/settings.go ================================================ package opsgenie import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "OpsGenie" ) type Settings struct { *cfg.Common apiKey string `help:"Your OpsGenie API token."` region string `help:"Defines region to use. Possible options: us (by default), eu." optional:"true"` displayEmpty bool `help:"Whether schedules with no assigned person on-call should be displayed." optional:"true"` schedule []string `help:"A list of names of the schedule(s) to retrieve."` scheduleIdentifierType string `help:"Type of the schedule identifier." values:"id or name" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_OPS_GENIE_API_KEY"))), region: ymlConfig.UString("region", "us"), displayEmpty: ymlConfig.UBool("displayEmpty", true), scheduleIdentifierType: ymlConfig.UString("scheduleIdentifierType", "id"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() settings.schedule = settings.arrayifySchedules(ymlConfig) return &settings } // arrayifySchedules figures out if we're dealing with a single project or an array of projects func (settings *Settings) arrayifySchedules(ymlConfig *config.Config) []string { schedules := []string{} // Single schedule schedule, err := ymlConfig.String("schedule") if err == nil { schedules = append(schedules, schedule) return schedules } // Array of schedules scheduleList := ymlConfig.UList("schedule") for _, scheduleName := range scheduleList { if schedule, ok := scheduleName.(string); ok { schedules = append(schedules, schedule) } } return schedules } ================================================ FILE: modules/opsgenie/widget.go ================================================ package opsgenie import ( "fmt" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { onCallResponses, err := widget.Fetch( widget.settings.scheduleIdentifierType, widget.settings.schedule, ) title := widget.CommonSettings().Title var content string wrap := false if err != nil { wrap = true content = err.Error() } else { for _, data := range onCallResponses { if (len(data.OnCallData.Recipients) == 0) && !widget.settings.displayEmpty { continue } var msg string if len(data.OnCallData.Recipients) == 0 { msg = " [gray]no one[white]\n\n" } else { msg = fmt.Sprintf(" %s\n\n", strings.Join(utils.NamesFromEmails(data.OnCallData.Recipients), ", ")) } content += widget.cleanScheduleName(data.OnCallData.Parent.Name) content += msg } } return title, content, wrap } func (widget *Widget) cleanScheduleName(schedule string) string { cleanedName := strings.ReplaceAll(schedule, "_", " ") return fmt.Sprintf(" [green]%s[white]\n", cleanedName) } ================================================ FILE: modules/pagerduty/client.go ================================================ package pagerduty import ( "context" "time" "github.com/PagerDuty/go-pagerduty" ) const ( queryTimeFmt = "2006-01-02T15:04:05Z07:00" ) // GetOnCalls returns a list of people currently on call func GetOnCalls(apiKey string, scheduleIDs []string) ([]pagerduty.OnCall, error) { client := pagerduty.NewClient(apiKey) var results []pagerduty.OnCall var queryOpts pagerduty.ListOnCallOptions queryOpts.ScheduleIDs = scheduleIDs queryOpts.Since = time.Now().Format(queryTimeFmt) queryOpts.Until = time.Now().Format(queryTimeFmt) oncalls, err := client.ListOnCallsWithContext(context.Background(), queryOpts) if err != nil { return nil, err } results = append(results, oncalls.OnCalls...) for oncalls.More { queryOpts.Offset = oncalls.Offset oncalls, err = client.ListOnCallsWithContext(context.Background(), queryOpts) if err != nil { return nil, err } results = append(results, oncalls.OnCalls...) } return results, nil } // GetIncidents returns a list of unresolved incidents func GetIncidents(apiKey string, teamIDs []string, userIDs []string) ([]pagerduty.Incident, error) { client := pagerduty.NewClient(apiKey) var results []pagerduty.Incident var queryOpts pagerduty.ListIncidentsOptions queryOpts.DateRange = "all" queryOpts.Statuses = []string{"triggered", "acknowledged"} queryOpts.TeamIDs = teamIDs queryOpts.UserIDs = userIDs items, err := client.ListIncidentsWithContext(context.Background(), queryOpts) if err != nil { return nil, err } results = append(results, items.Incidents...) for items.More { queryOpts.Offset = items.Offset items, err = client.ListIncidentsWithContext(context.Background(), queryOpts) if err != nil { return nil, err } results = append(results, items.Incidents...) } return results, nil } ================================================ FILE: modules/pagerduty/settings.go ================================================ package pagerduty import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "PagerDuty" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"Your PagerDuty API key."` escalationFilter []interface{} `help:"An array of schedule names you want to filter the OnCalls on."` myName string `help:"The name to highlight when on-call in PagerDuty."` scheduleIDs []interface{} `help:"An array of schedule IDs you want to restrict the OnCalls query to."` showIncidents bool `help:"Whether or not to list incidents." optional:"true"` showOnCallEnd bool `help:"Whether or not to display the date the oncall schedule ends." optional:"true"` showSchedules bool `help:"Whether or not to show schedules." optional:"true"` teamIDs []interface{} `help:"An array of team IDs to restrict the incidents query to" optional:"true"` userIDs []interface{} `help:"An array of user IDs to restrict the incidents query to" optional:"true"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_PAGERDUTY_API_KEY"))), escalationFilter: ymlConfig.UList("escalationFilter"), myName: ymlConfig.UString("myName"), scheduleIDs: ymlConfig.UList("scheduleIDs", []interface{}{}), showIncidents: ymlConfig.UBool("showIncidents", true), showOnCallEnd: ymlConfig.UBool("showOnCallEnd", false), showSchedules: ymlConfig.UBool("showSchedules", true), teamIDs: ymlConfig.UList("teamIDs", []interface{}{}), userIDs: ymlConfig.UList("userIDs", []interface{}{}), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/pagerduty/sort.go ================================================ package pagerduty import "github.com/PagerDuty/go-pagerduty" type ByEscalationLevel []pagerduty.OnCall func (s ByEscalationLevel) Len() int { return len(s) } func (s ByEscalationLevel) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s ByEscalationLevel) Less(i, j int) bool { return s[i].EscalationLevel < s[j].EscalationLevel } ================================================ FILE: modules/pagerduty/widget.go ================================================ package pagerduty import ( "fmt" "sort" "time" "github.com/PagerDuty/go-pagerduty" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( onCallTimeAPILayout = "2006-01-02T15:04:05Z" onCallTimeDisplayLayout = "Jan 2, 2006" ) type Widget struct { view.TextWidget settings *Settings } // NewWidget creates and returns an instance of PagerDuty widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { var onCalls []pagerduty.OnCall var incidents []pagerduty.Incident var err1 error var err2 error if widget.settings.showIncidents { teamIDs := utils.ToStrs(widget.settings.teamIDs) userIDs := utils.ToStrs(widget.settings.userIDs) incidents, err2 = GetIncidents(widget.settings.apiKey, teamIDs, userIDs) } if widget.settings.showSchedules { scheduleIDs := utils.ToStrs(widget.settings.scheduleIDs) onCalls, err1 = GetOnCalls(widget.settings.apiKey, scheduleIDs) } var content string wrap := false if err1 != nil || err2 != nil { wrap = true if err1 != nil { content += err1.Error() } if err2 != nil { content += err2.Error() } } else { widget.View.SetWrap(false) content = widget.contentFrom(onCalls, incidents) } widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, content, wrap }) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) contentFrom(onCalls []pagerduty.OnCall, incidents []pagerduty.Incident) string { var str string // Incidents if widget.settings.showIncidents { str += fmt.Sprintf("[%s] Incidents[white]\n", widget.settings.Colors.Subheading) if len(incidents) > 0 { for _, incident := range incidents { str += fmt.Sprintf("\n [%s]%s[white]\n", widget.settings.Colors.Label, tview.Escape(incident.Summary)) str += fmt.Sprintf(" Status: %s\n", incident.Status) str += fmt.Sprintf(" Service: %s\n", incident.Service.Summary) str += fmt.Sprintf(" Escalation: %s\n", incident.EscalationPolicy.Summary) str += fmt.Sprintf(" Link: %s\n", incident.HTMLURL) } } else { str += "\n No open incidents\n" } str += "\n" } onCallTree := make(map[string][]pagerduty.OnCall) filter := make(map[string]bool) for _, item := range widget.settings.escalationFilter { filter[item.(string)] = true } // OnCalls for _, onCall := range onCalls { summary := onCall.EscalationPolicy.Summary if len(widget.settings.escalationFilter) == 0 || filter[summary] { onCallTree[summary] = append(onCallTree[summary], onCall) } } // We want to sort our escalation policies for predictability/ease of finding keys := make([]string, 0, len(onCallTree)) for k := range onCallTree { keys = append(keys, k) } sort.Strings(keys) if len(keys) > 0 { str += fmt.Sprintf("[%s] Schedules[white]\n", widget.settings.Colors.Subheading) // Print out policies, and escalation order of users for _, key := range keys { str += fmt.Sprintf( "\n [%s]%s\n", widget.settings.Colors.Label, key, ) values := onCallTree[key] sort.Sort(ByEscalationLevel(values)) for _, onCall := range values { str += fmt.Sprintf( " [%s]%d - %s\n", widget.settings.Colors.Text, onCall.EscalationLevel, widget.userSummary(&onCall), ) onCallEnd := widget.onCallEndSummary(&onCall) if onCallEnd != "" { str += fmt.Sprintf( " %s\n", onCallEnd, ) } } } } return str } // onCallEndSummary may or may not return the date that the specified onCall schedule ends func (widget *Widget) onCallEndSummary(onCall *pagerduty.OnCall) string { if !widget.settings.showOnCallEnd { return "" } if onCall.End == "" { return "" } end, err := time.Parse(onCallTimeAPILayout, onCall.End) if err != nil { return "" } return end.Format(onCallTimeDisplayLayout) } // userSummary returns the name of the person assigned to the specified onCall schedule func (widget *Widget) userSummary(onCall *pagerduty.OnCall) string { summary := onCall.User.Summary if summary == widget.settings.myName { summary = fmt.Sprintf("[::b]%s", summary) } return summary } ================================================ FILE: modules/pihole/client.go ================================================ package pihole import ( "encoding/json" "fmt" "io" "net/http" url2 "net/url" "regexp" "strconv" "strings" "time" ) type Status struct { DomainsBeingBlocked string `json:"domains_being_blocked"` DNSQueriesToday string `json:"dns_queries_today"` AdsBlockedToday string `json:"ads_blocked_today"` AdsPercentageToday string `json:"ads_percentage_today"` UniqueDomains string `json:"unique_domains"` QueriesForwarded string `json:"queries_forwarded"` QueriesCached string `json:"queries_cached"` Status string `json:"status"` GravityLastUpdated struct { Relative struct { Days FlexInt `json:"days"` Hours FlexInt `json:"hours"` Minutes FlexInt `json:"minutes"` } } `json:"gravity_last_updated"` } func getStatus(c http.Client, apiURL string) (status Status, err error) { var req *http.Request var url *url2.URL if url, err = url2.Parse(apiURL); err != nil { return status, fmt.Errorf(" failed to parse API URL\n %s", parseError(err)) } var query url2.Values if query, err = url2.ParseQuery(url.RawQuery); err != nil { return status, fmt.Errorf(" failed to parse query\n %s", parseError(err)) } query.Add("summary", "") url.RawQuery = query.Encode() if req, err = http.NewRequest("GET", url.String(), http.NoBody); err != nil { return status, fmt.Errorf(" failed to create request\n %s", parseError(err)) } var resp *http.Response if resp, err = c.Do(req); err != nil || resp == nil { return status, fmt.Errorf(" failed to connect to Pi-hole server\n %s", parseError(err)) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { return } }() if resp.StatusCode >= http.StatusBadRequest { return status, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", resp.StatusCode) } var rBody []byte if rBody, err = io.ReadAll(resp.Body); err != nil { return status, fmt.Errorf(" failed to read status response") } if err = json.Unmarshal(rBody, &status); err != nil { return status, fmt.Errorf(" failed to retrieve status: check provided api URL and token\n %s", parseError(err)) } return status, err } type FlexInt int func (fi *FlexInt) UnmarshalJSON(b []byte) error { if b[0] != '"' { return json.Unmarshal(b, (*int)(fi)) } var s string if err := json.Unmarshal(b, &s); err != nil { return err } i, err := strconv.Atoi(s) if err != nil { return err } *fi = FlexInt(i) return nil } type TopItems struct { TopQueries map[string]int `json:"top_queries"` TopAds map[string]int `json:"top_ads"` } func getTopItems(c http.Client, settings *Settings) (ti TopItems, err error) { var req *http.Request var url *url2.URL if url, err = url2.Parse(settings.apiUrl); err != nil { return ti, fmt.Errorf(" failed to parse API URL\n %s", parseError(err)) } var query url2.Values if query, err = url2.ParseQuery(url.RawQuery); err != nil { return ti, fmt.Errorf(" failed to parse query\n %s", parseError(err)) } query.Add("auth", settings.token) query.Add("topItems", strconv.Itoa(settings.showTopItems)) url.RawQuery = query.Encode() req, err = http.NewRequest("GET", url.String(), http.NoBody) if err != nil { return ti, fmt.Errorf(" failed to create request\n %s", parseError(err)) } var resp *http.Response if resp, err = c.Do(req); err != nil || resp == nil { return ti, fmt.Errorf(" failed to connect to Pi-hole server\n %s", parseError(err)) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { return } }() if resp.StatusCode >= http.StatusBadRequest { return ti, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", resp.StatusCode) } var rBody []byte rBody, err = io.ReadAll(resp.Body) if err = json.Unmarshal(rBody, &ti); err != nil { return ti, fmt.Errorf(" failed to retrieve top items: check provided api URL and token\n %s", parseError(err)) } return ti, err } type TopClients struct { TopSources map[string]int `json:"top_sources"` } // parseError removes any token from output and ensures a non-nil response func parseError(err error) string { if err == nil { return "unknown error" } var re = regexp.MustCompile(`auth=[a-zA-Z0-9]*`) return re.ReplaceAllString(err.Error(), "auth=") } func getTopClients(c http.Client, settings *Settings) (tc TopClients, err error) { var req *http.Request var url *url2.URL if url, err = url2.Parse(settings.apiUrl); err != nil { return tc, fmt.Errorf(" failed to parse API URL\n %s", parseError(err)) } var query url2.Values if query, err = url2.ParseQuery(url.RawQuery); err != nil { return tc, fmt.Errorf(" failed to parse query\n %s", parseError(err)) } query.Add("topClients", strconv.Itoa(settings.showTopClients)) query.Add("auth", settings.token) url.RawQuery = query.Encode() if req, err = http.NewRequest("GET", url.String(), http.NoBody); err != nil { return tc, fmt.Errorf(" failed to create request\n %s", parseError(err)) } var resp *http.Response if resp, err = c.Do(req); err != nil || resp == nil { return tc, fmt.Errorf(" failed to connect to Pi-hole server\n %s", parseError(err)) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { return } }() if resp.StatusCode >= http.StatusBadRequest { return tc, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", resp.StatusCode) } var rBody []byte if rBody, err = io.ReadAll(resp.Body); err != nil { return tc, fmt.Errorf(" failed to read top clients response\n %s", parseError(err)) } if err = json.Unmarshal(rBody, &tc); err != nil { return tc, fmt.Errorf(" failed to retrieve top clients: check provided api URL and token\n %s", parseError(err)) } return tc, err } type QueryTypes struct { QueryTypes map[string]float32 `json:"querytypes"` } func getQueryTypes(c http.Client, settings *Settings) (qt QueryTypes, err error) { var req *http.Request var url *url2.URL if url, err = url2.Parse(settings.apiUrl); err != nil { return qt, fmt.Errorf(" failed to parse API URL\n %s", parseError(err)) } var query url2.Values if query, err = url2.ParseQuery(url.RawQuery); err != nil { return qt, fmt.Errorf(" failed to parse query\n %s", parseError(err)) } query.Add("getQueryTypes", strconv.Itoa(settings.showTopClients)) query.Add("auth", settings.token) url.RawQuery = query.Encode() if req, err = http.NewRequest("GET", url.String(), http.NoBody); err != nil { return qt, fmt.Errorf(" failed to create request\n %s", parseError(err)) } var resp *http.Response if resp, err = c.Do(req); err != nil || resp == nil { return qt, fmt.Errorf(" failed to connect to Pi-hole server\n %s", parseError(err)) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { return } }() if resp.StatusCode >= http.StatusBadRequest { return qt, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", resp.StatusCode) } var rBody []byte if rBody, err = io.ReadAll(resp.Body); err != nil { return qt, fmt.Errorf(" failed to read top clients response\n %s", parseError(err)) } if err = json.Unmarshal(rBody, &qt); err != nil { return qt, fmt.Errorf(" failed to parse query types response\n %s", parseError(err)) } return qt, err } func checkServer(c http.Client, apiURL string) error { var err error var req *http.Request var url *url2.URL if url, err = url2.Parse(apiURL); err != nil { return fmt.Errorf(" failed to parse API URL\n %s", parseError(err)) } if url.Host == "" { return fmt.Errorf(" please specify 'apiUrl' in Pi-hole settings, e.g.\n apiUrl: http://:/admin/api.php") } if req, err = http.NewRequest("GET", fmt.Sprintf("%s?version", url.String()), http.NoBody); err != nil { return fmt.Errorf("invalid request: %s", parseError(err)) } var resp *http.Response if resp, err = c.Do(req); err != nil { return fmt.Errorf(" failed to connect to Pi-hole server\n %s", parseError(err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", resp.StatusCode) } var vResp struct { Version int `json:"version"` } var rBody []byte if rBody, err = io.ReadAll(resp.Body); err != nil { return fmt.Errorf(" Pi-hole server failed to respond\n %s", parseError(err)) } if err = json.Unmarshal(rBody, &vResp); err != nil { return fmt.Errorf(" invalid response returned from Pi-hole Server\n %s", parseError(err)) } if vResp.Version != 3 { return fmt.Errorf(" only Pi-hole API version 3 is supported\n version %d was detected", vResp.Version) } return err } func (widget *Widget) adblockSwitch(action string) { var req *http.Request var url *url2.URL url, _ = url2.Parse(widget.settings.apiUrl) var query url2.Values query, _ = url2.ParseQuery(url.RawQuery) query.Add(strings.ToLower(action), "") query.Add("auth", widget.settings.token) url.RawQuery = query.Encode() req, _ = http.NewRequest("GET", url.String(), http.NoBody) c := getClient() resp, _ := c.Do(req) defer func() { _ = resp.Body.Close() }() widget.Refresh() } func getClient() http.Client { return http.Client{ Transport: &http.Transport{ TLSHandshakeTimeout: 5 * time.Second, DisableKeepAlives: false, DisableCompression: false, ResponseHeaderTimeout: 20 * time.Second, }, Timeout: 21 * time.Second, } } ================================================ FILE: modules/pihole/keyboard.go ================================================ package pihole func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("d", widget.disable, "disable Pi-hole") widget.SetKeyboardChar("e", widget.enable, "enable Pi-hole") } ================================================ FILE: modules/pihole/settings.go ================================================ package pihole import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Pi-hole" ) type Settings struct { *cfg.Common wrapText bool apiUrl string token string showTopItems int showTopClients int maxClientWidth int maxDomainWidth int showSummary bool } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiUrl: ymlConfig.UString("apiUrl"), token: ymlConfig.UString("token"), showSummary: ymlConfig.UBool("showSummary", true), showTopItems: ymlConfig.UInt("showTopItems", 5), showTopClients: ymlConfig.UInt("showTopClients", 5), maxClientWidth: ymlConfig.UInt("maxClientWidth", 20), maxDomainWidth: ymlConfig.UInt("maxDomainWidth", 20), } cfg.ModuleSecret(name, globalConfig, &settings.token). Service(settings.apiUrl).Load() return &settings } ================================================ FILE: modules/pihole/view.go ================================================ package pihole import ( "bytes" "fmt" "io" "net/http" "sort" "strconv" "strings" "github.com/olekukonko/tablewriter" ) func getSummaryView(c http.Client, settings *Settings) string { var err error var s Status url := settings.apiUrl + "?status&auth=" + settings.token s, err = getStatus(c, url) if err != nil { return err.Error() } var sb strings.Builder buf := new(bytes.Buffer) switch strings.ToLower(s.Status) { case "disabled": sb.WriteString(" [white]Status [red]DISABLED\n") case "enabled": sb.WriteString(" [white]Status [green]ENABLED\n") default: sb.WriteString(" [white]Status [yellow]UNKNOWN\n") } summaryTable := createTable([]string{}, buf) summaryTable.Append([]string{"Domain blocklist", s.DomainsBeingBlocked, "Queries today", s.DNSQueriesToday}) summaryTable.Append([]string{"Ads blocked today", fmt.Sprintf("%s (%s%%)", s.AdsBlockedToday, s.AdsPercentageToday), "Cached queries", s.QueriesCached}) summaryTable.Append([]string{"Blocklist Age", fmt.Sprintf("%dd %dh %dm", s.GravityLastUpdated.Relative.Days, s.GravityLastUpdated.Relative.Hours, s.GravityLastUpdated.Relative.Minutes), "Forwarded queries", s.QueriesForwarded}) summaryTable.Render() sb.WriteString(buf.String()) return sb.String() } func getTopItemsView(c http.Client, settings *Settings) string { var err error var ti TopItems ti, err = getTopItems(c, settings) if err != nil { return err.Error() } buf := new(bytes.Buffer) var sb strings.Builder tiTable := createTable([]string{"Top Queries", "", "Top Ads", ""}, buf) largest := len(ti.TopAds) if len(ti.TopQueries) > largest { largest = len(ti.TopQueries) } sortedTiQueries := sortMapByIntVal(ti.TopQueries) sortedTiAds := sortMapByIntVal(ti.TopAds) for x := 0; x < largest; x++ { tiQVal := []string{"", ""} if len(sortedTiQueries) > x { tiQVal = []string{shorten(sortedTiQueries[x][0], settings.maxDomainWidth), sortedTiQueries[x][1]} } tiAVal := []string{"", ""} if len(sortedTiAds) > x { tiAVal = []string{shorten(sortedTiAds[x][0], settings.maxDomainWidth), sortedTiAds[x][1]} } tiTable.Append([]string{tiQVal[0], tiQVal[1], tiAVal[0], tiAVal[1]}) } tiTable.Render() sb.WriteString(buf.String()) return sb.String() } func getTopClientsView(c http.Client, settings *Settings) string { tc, err := getTopClients(c, settings) if err != nil { return err.Error() } var tq QueryTypes tq, err = getQueryTypes(c, settings) if err != nil { return err.Error() } buf := new(bytes.Buffer) tcTable := createTable([]string{"Top Clients", "", "Top Query Types", ""}, buf) sortedTcQueries := sortMapByIntVal(tc.TopSources) sortedTopQT := sortMapByFloatVal(tq.QueryTypes) largest := len(tc.TopSources) if len(tq.QueryTypes) > largest { largest = len(tq.QueryTypes) } if settings.showTopClients < largest { largest = settings.showTopClients } for x := 0; x < largest; x++ { tcVal := []string{"", ""} if len(sortedTcQueries) > x { tcVal = []string{sortedTcQueries[x][0], sortedTcQueries[x][1]} } tqtVal := []string{"", ""} if len(sortedTopQT) > x && sortedTopQT[x][0] != "" { tqtVal = []string{sortedTopQT[x][0], sortedTopQT[x][1] + "%"} } tcTable.Append([]string{tcVal[0], tcVal[1], tqtVal[0], tqtVal[1]}) } tcTable.Render() var sb strings.Builder sb.WriteString(buf.String()) return sb.String() } func shorten(s string, limit int) string { if len(s) > limit { return s[:limit] + "..." } return s } func createTable(header []string, buf io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(buf) if len(header) != 0 { table.SetHeader(header) table.SetHeaderLine(false) table.SetHeaderAlignment(0) } table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetBorder(true) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetTablePadding(" ") table.SetNoWhiteSpace(false) return table } func sortMapByIntVal(m map[string]int) (sorted [][]string) { type kv struct { Key string Value int } ss := make([]kv, len(m)) for k, v := range m { ss = append(ss, kv{k, v}) } sort.Slice(ss, func(i, j int) bool { return ss[i].Value > ss[j].Value }) for _, kv := range ss { sorted = append(sorted, []string{kv.Key, strconv.Itoa(kv.Value)}) } return } func sortMapByFloatVal(m map[string]float32) (sorted [][]string) { type kv struct { Key string Value float32 } ss := make([]kv, len(m)) for k, v := range m { if k == "" || v == 0.00 { continue } ss = append(ss, kv{k, v}) } sort.Slice(ss, func(i, j int) bool { return ss[i].Value > ss[j].Value }) for _, kv := range ss { sorted = append(sorted, []string{kv.Key, fmt.Sprintf("%.2f", kv.Value)}) } return } ================================================ FILE: modules/pihole/widget.go ================================================ package pihole import ( "strings" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.MultiSourceWidget view.TextWidget settings *Settings } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } widget.settings.RefreshInterval = 30 * time.Second widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.Refresh) widget.View.SetWordWrap(true) widget.View.SetWrap(settings.wrapText) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title c := getClient() if err := checkServer(c, widget.settings.apiUrl); err != nil { return title, err.Error(), widget.settings.wrapText } var sb strings.Builder if widget.settings.showSummary { sb.WriteString(getSummaryView(c, widget.settings)) } if widget.settings.showTopItems > 0 { sb.WriteString(getTopItemsView(c, widget.settings)) } if widget.settings.showTopClients > 0 { sb.WriteString(getTopClientsView(c, widget.settings)) } output := sb.String() return title, output, widget.settings.wrapText } func (widget *Widget) disable() { widget.adblockSwitch("disable") } func (widget *Widget) enable() { widget.adblockSwitch("enable") } ================================================ FILE: modules/ping/settings.go ================================================ package ping import ( "fmt" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Pings" ) type Host struct { Label string `help:"Label: The name to use for the host you want to ping. Uses hostname if blank."` Hostname string `help:"Hostname: IP address or hostname to ping"` Up bool // not meant to be set by user } type Settings struct { common *cfg.Common hosts []Host } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), hosts: buildhosts(ymlConfig), } return &settings } func buildhosts(ymlConfig *config.Config) []Host { hosts := []Host{} yaml := ymlConfig.UList("hosts") // Iterate through each host in the config for _, rawHost := range yaml { host, ok := rawHost.(map[string]interface{}) if !ok { continue // bad host, skip } hostname, ok := host["hostname"].(string) if !ok { continue // hostname is required, skip } if hostname == "" { continue // hostname is required, skip } label := hostname // a default if missing from config if value, ok := host["label"]; ok { // Using Sprintf here instead of a string assert. This is to cover the // case where someone puts a number as the label instead of a YAML string. // Weird case, yes, but wanted to prevent runtime errors. label = fmt.Sprintf("%v", value) } hosts = append(hosts, Host{Label: label, Hostname: hostname, Up: false}) } return hosts } ================================================ FILE: modules/ping/widget.go ================================================ package ping import ( "fmt" "log" "strings" "sync" "time" probing "github.com/prometheus-community/pro-bing" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget hosts []Host settings *Settings } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.common), settings: settings, } widget.hosts = widget.settings.hosts return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) doPings() { var wg sync.WaitGroup for i := range widget.hosts { idx := i host := widget.hosts[idx] widget.hosts[idx].Up = false // reset to false each time wg.Add(1) go func() { defer wg.Done() pinger, err := probing.NewPinger(host.Hostname) if err == nil { pinger.Count = 1 pinger.Timeout = 10 * time.Second err = pinger.Run() // Blocks until finished. if err == nil { stats := pinger.Statistics() // get send/receive/duplicate/rtt stats if stats.PacketsRecv > 0 { widget.hosts[idx].Up = true } else { widget.hosts[idx].Up = false } } else { log.Fatalf("error sending ping: %v", err) } } }() } wg.Wait() } func (widget *Widget) Refresh() { widget.doPings() widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { nameWidth := 12 for _, t := range widget.hosts { if len(t.Label) > nameWidth { nameWidth = len(t.Label) + 2 } } s := []string{} for _, t := range widget.hosts { var status string if t.Up { status = "[green]Up" } else { status = "[red]DOWN" } statusLine := fmt.Sprintf("[white]%-*s: %s", nameWidth, t.Label, status) s = append(s, statusLine) } return strings.Join(s, "\n") } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } ================================================ FILE: modules/pivotal/client.go ================================================ package pivotal import ( "encoding/json" "fmt" "io" "net/http" "net/url" ) type Resource struct { Response interface{} Raw string } type PivotalClient struct { token string baseUrl string projectId string user *User } type Error struct { Code string `json:"code"` Kind string `json:"kind"` Error string `json:"error"` } func NewPivotalClient(token string, projectId string) *PivotalClient { baseUrl := "https://www.pivotaltracker.com/services/v5/" if baseUrl == "" { baseUrl = "https://www.pivotaltracker.com/services/v5/" } pivotal := PivotalClient{ token: token, baseUrl: baseUrl, projectId: projectId, } pivotal.user, _ = pivotal.getCurrentUser() return &pivotal } func (pivotal *PivotalClient) apiv5(resource string) (*Resource, error) { trn := &http.Transport{} meth := "GET" client := &http.Client{ Transport: trn, } apiToken := pivotal.token URL := fmt.Sprintf("%s%s", pivotal.baseUrl, resource) req, err := http.NewRequest(meth, URL, http.NoBody) if err != nil { return nil, err } req.Header.Add("Content-Type", "application/json") req.Header.Add("X-TrackerToken", apiToken) resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // check if we received a Pivotal Error response Err := Error{} err = json.Unmarshal([]byte(string(data)), &Err) if err == nil && Err.Error != "" { return nil, fmt.Errorf("%s", Err.Error) } return &Resource{Response: &resp, Raw: string(data)}, nil } func (pivotal *PivotalClient) getCurrentUser() (*User, error) { resource, err := pivotal.apiv5("me") if err != nil { return nil, err } user := User{} err = json.Unmarshal([]byte(resource.Raw), &user) if err != nil { return nil, err } return &user, nil } func (pivotal *PivotalClient) searchStories(filter string) (*PivotalTrackerResponse, error) { fields := ":default,stories(:default,stories(:default,branches,pull_requests))" res := fmt.Sprintf("projects/%s/search?fields=%s&query=%s", pivotal.projectId, fields, url.QueryEscape(filter), ) resource, err := pivotal.apiv5(res) if err != nil { return nil, err } var response PivotalTrackerResponse err = json.Unmarshal([]byte(resource.Raw), &response) if err != nil { return nil, err } return &response, nil } ================================================ FILE: modules/pivotal/display.go ================================================ package pivotal import ( "fmt" "regexp" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" ) const ( hasPullFailIcon = '💥' hasPullIcon = "🌱" ) var statusMapEmoji = map[string]string{ "started": "🚧", "unstarted": " ", "finished": "🚀", "delivered": "🚢", "rejected": "❌", "accepted": "✅", "planned": "📅", "unscheduled": "❓", } func (widget *Widget) display() { widget.SetItemCount(widget.CurrentSource().getItemCount()) widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { proj := widget.CurrentSource() if proj == nil { return widget.CommonSettings().Title, "No sources", false } if proj.Err != nil { return widget.CommonSettings().Title, proj.Err.Error(), true } title := fmt.Sprintf( "[%s]%s[white] - %d ", widget.settings.Colors.Title, proj.name, proj.getItemCount()) str := "" for idx, item := range proj.stories { rowColor := widget.RowColor(idx) displayText := getShowText(&item) row := fmt.Sprintf( `[%s]|%s%s| %s[%s]`, widget.RowColor(idx), getStatusIcon(&item), getPullStatusIcon(&item), tview.Escape(displayText), rowColor, ) str += utils.HighlightableHelper(widget.View, row, idx, len(item.Name)) } return title, str, false } func getStatusIcon(story *Story) string { state := story.CurrentState val, ok := statusMapEmoji[state] if ok { state = val } return state } func getPullStatusIcon(story *Story) string { //prs := len(story.PullRequests) var prs string prs = " " if len(story.PullRequests) > 0 { prs = hasPullIcon } return prs } func getShowText(story *Story) string { if story == nil { return "" } space := regexp.MustCompile(`\s+`) title := space.ReplaceAllString(story.Name, " ") //html.UnescapeString("[" + rowColor + "]" + title) return title } ================================================ FILE: modules/pivotal/keyboard.go ================================================ package pivotal import ( "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("o", widget.Open, "Open item in browser") widget.SetKeyboardChar("p", widget.OpenPulls, "Open pull requests in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") widget.SetKeyboardKey(tcell.KeyEnter, widget.Open, "Open PR in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/pivotal/settings.go ================================================ package pivotal import ( "fmt" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "os" ) const ( defaultFocusable = true defaultTitle = "Pivotal" ) type customQuery struct { title string `help:"Display title for this query"` filter string `help:"Pivotal search query filter"` perPage int `help:"Number of issues to show"` project string `help:"Pivotal project id"` } type Settings struct { *cfg.Common filter string projectId string apiToken string status string customQueries []customQuery `help:"Custom queries allow you to filter pull requests and issues however you like. Give the query a title and a filter. Filters can be copied directly from GitHub’s UI." optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), filter: ymlConfig.UString("filter", ymlConfig.UString("filter")), projectId: ymlConfig.UString("projectId", ymlConfig.UString("projectId", os.Getenv("PIVOTALTRACKER_PROJECT"))), apiToken: ymlConfig.UString("apiToken", ymlConfig.UString("apiToken", os.Getenv("PIVOTALTRACKER_TOKEN"))), status: ymlConfig.UString("status"), } settings.customQueries = parseCustomQueries(ymlConfig) cfg.ModuleSecret(name, globalConfig, &settings.apiToken).Load() return &settings } func parseCustomQueries(ymlConfig *config.Config) []customQuery { var result []customQuery if customQueries, err := ymlConfig.Map("customQueries"); err == nil { for _, query := range customQueries { c := customQuery{} for key, value := range query.(map[string]interface{}) { switch key { case "title": c.title = value.(string) case "filter": c.filter = value.(string) case "project": switch value := value.(type) { case bool, float64, int: c.project = fmt.Sprint(value) case string: c.project = value } case "perPage": c.perPage = value.(int) } } if c.title != "" && c.filter != "" { result = append(result, c) } } } return result } ================================================ FILE: modules/pivotal/structs.go ================================================ package pivotal import ( "time" ) type User struct { ID int `json:"id"` } type Story struct { Kind string `json:"kind"` ID int `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` AcceptedAt time.Time `json:"accepted_at"` // CreatedAt int64 `json:"created_at"` // UpdatedAt int64 `json:"updated_at"` // AcceptedAt int64 `json:"accepted_at"` Estimate int `json:"estimate"` StoryType string `json:"story_type"` StoryPriority string `json:"story_priority"` Name string `json:"name"` Description string `json:"description"` CurrentState string `json:"current_state"` RequestedByID int `json:"requested_by_id"` URL string `json:"url"` ProjectID int `json:"project_id"` OwnerIDs []int `json:"owner_ids"` OwnedByID int `json:"owned_by_id"` Labels []Label `json:"labels"` Tasks []interface{} `json:"tasks"` PullRequests []StoryPullRequest `json:"pull_requests"` CicdEvents []interface{} `json:"cicd_events"` Branches []StoryBranch `json:"branches"` Blockers []interface{} `json:"blockers"` FollowerIDs []int `json:"follower_ids"` Comments []StoryComment `json:"comments"` BlockedStoryIDs []int `json:"blocked_story_ids"` Reviews []StoryReview `json:"reviews"` Project StoryProject `json:"project"` } type PivotalTrackerResponse struct { Stories *StoryResponse `json:"stories"` Epics *EpicResponse `json:"epics"` Query string `json:"query"` } type StoryResponse struct { Stories []Story `json:"stories"` TotalPoints int `json:"total_points"` TotalPointsCompleted int `json:"total_points_completed"` TotalHits int `json:"total_hits"` TotalHitsWithDone int `json:"total_hits_with_done"` } type EpicResponse struct { Epics []Epic `json:"epics"` TotalHits int `json:"total_hits"` TotalHitsWithDone int `json:"total_hits_with_done"` } type Epic struct { ID int `json:"id"` Kind string `json:"kind"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // CreatedAt int64 `json:"created_at"` // UpdatedAt int64 `json:"updated_at"` ProjectID int `json:"project_id"` Name string `json:"name"` URL string `json:"url"` Label Label `json:"label"` } type Label struct { ID int `json:"id"` ProjectID int `json:"project_id"` Kind string `json:"kind"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // CreatedAt int64 `json:"created_at"` // UpdatedAt int64 `json:"updated_at"` } type StoryLabel struct { ID int `json:"id"` ProjectID int `json:"project_id"` Kind string `json:"kind"` Name string `json:"name"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type StoryPullRequest struct { ID int `json:"id"` Kind string `json:"kind"` StoryID int `json:"story_id"` Owner string `json:"owner"` Repo string `json:"repo"` HostURL string `json:"host_url"` Status string `json:"status"` Number int `json:"number"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // CreatedAt int64 `json:"created_at"` // UpdatedAt int64 `json:"updated_at"` } type StoryTask struct { ID int `json:"id"` Kind string `json:"kind"` Description string `json:"description"` Complete bool `json:"complete"` Position int `json:"position"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` StoryID int `json:"story_id"` } type StoryBranch struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` CommitHash string `json:"commit_hash,omitempty"` CommitMessage string `json:"commit_message,omitempty"` AuthorName string `json:"author_name,omitempty"` AuthorEmail string `json:"author_email,omitempty"` CreatedAt string `json:"created_at,omitempty"` } type StoryComment struct { Kind string `json:"kind"` ID int64 `json:"id"` Text string `json:"text"` PersonID int64 `json:"person_id"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` StoryID int64 `json:"story_id"` Attachments []interface{} `json:"attachments"` Reactions []interface{} `json:"reactions"` } type StoryReview struct { ID int `json:"id"` ReviewerID int `json:"reviewer_id"` Kind string `json:"kind"` StoryID int `json:"story_id"` ReviewTypeID int `json:"review_type_id"` Status string `json:"status"` CreatedAt int `json:"created_at"` UpdatedAt int `json:"updated_at"` } type StoryProject struct { ID int `json:"id"` Kind string `json:"kind"` Name string `json:"name"` Version int `json:"version"` IterationLength int `json:"iteration_length"` WeekStartDay string `json:"week_start_day"` PointScale string `json:"point_scale"` PointScaleIsCustom bool `json:"point_scale_is_custom"` BugsAndChoresAreEstimatable bool `json:"bugs_and_chores_are_estimatable"` AutomaticPlanning bool `json:"automatic_planning"` EnableTasks bool `json:"enable_tasks"` TimeZone struct { Kind string `json:"kind"` OlsonName string `json:"olson_name"` Offset string `json:"offset"` } `json:"time_zone"` VelocityAveragedOver int `json:"velocity_averaged_over"` NumberOfDoneIterationsToShow int `json:"number_of_done_iterations_to_show"` HasGoogleDomain bool `json:"has_google_domain"` EnableIncomingEmails bool `json:"enable_incoming_emails"` InitialVelocity int `json:"initial_velocity"` Public bool `json:"public"` AtomEnabled bool `json:"atom_enabled"` ProjectType string `json:"project_type"` HasCICDIntegration bool `json:"has_cicd_integration"` Capabilities struct { PrioritySupport bool `json:"priority_support"` LabelsPanel bool `json:"labels_panel"` LabelsPanelBulkActions bool `json:"labels_panel_bulk_actions"` DigitalRiverIntegration bool `json:"digital_river_integration"` DigitalRiverDebug bool `json:"digital_river_debug"` StartSendingDRNotices bool `json:"start_sending_dr_notices"` EnableEAPEvents bool `json:"enable_eap_events"` } `json:"capabilities"` StartDate string `json:"start_date"` StartTime int64 `json:"start_time"` ShownIterationsStartTime int64 `json:"shown_iterations_start_time"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` ShowStoryPriority bool `json:"show_story_priority"` ShowPriorityIcon bool `json:"show_priority_icon"` ShowPriorityIconInAllPanels bool `json:"show_priority_icon_in_all_panels"` Epics []struct { ID int `json:"id"` Name string `json:"name"` LabelID int `json:"label_id"` } `json:"epics"` } ================================================ FILE: modules/pivotal/view.go ================================================ package pivotal import ( "fmt" "github.com/wtfutil/wtf/utils" ) type PivotalSource struct { client *PivotalClient name string filter string widget *Widget Err error stories []Story max_items int } // NewPivotalSource returns a new Pivotal Filter source with a name func NewPivotalSource(name string, filter string, client *PivotalClient, widget *Widget) *PivotalSource { source := PivotalSource{ name: name, filter: filter, client: client, widget: widget, } source.loadStories() return &source } func (source *PivotalSource) loadStories() { search, err := source.client.searchStories(source.filter) if err != nil { source.stories = nil source.Err = err source.setItemCount(0) } else { source.stories = search.Stories.Stories source.Err = err source.setItemCount(len(source.stories)) } } // Open: Will open Pivotal search url with filter applied using the utils helper func (source *PivotalSource) Open() { sel := source.widget.GetSelected() projectID := source.client.projectId if sel >= 0 && sel < source.getItemCount() { story := &source.stories[sel] baseURL := "https://www.pivotaltracker.com/n/projects/" ticketURL := fmt.Sprintf("%s%s/stories/%d", baseURL, projectID, story.ID) utils.OpenFile(ticketURL) } } // OpenPulls will open the GitHub Pull Requests URL using the utils helper func (source *PivotalSource) OpenPulls() { sel := source.widget.GetSelected() if sel >= 0 && sel < source.getItemCount() { story := &source.stories[sel] if len(story.PullRequests) > 0 { pr := story.PullRequests[0] ticketURL := fmt.Sprintf("%s%s/%s/pull/%d", pr.HostURL, pr.Owner, pr.Repo, pr.Number) utils.OpenFile(ticketURL) } } } /* -------------------- Counts -------------------- */ func (source *PivotalSource) getItemCount() int { if source.stories == nil { return 0 } return len(source.stories) } func (source *PivotalSource) setItemCount(count int) { source.max_items = count } /* -------------------- Unexported Functions -------------------- */ ================================================ FILE: modules/pivotal/widget.go ================================================ package pivotal import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // A Widget represents a Todoist widget type Widget struct { view.MultiSourceWidget view.ScrollableWidget settings *Settings client *PivotalClient projectClient map[string]*PivotalClient sources []*PivotalSource } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "customQuery", "customQueries"), ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, client: NewPivotalClient(settings.apiToken, settings.projectId), projectClient: make(map[string]*PivotalClient), } widget.loadSources() // Add the client to projectClient list widget.projectClient[widget.settings.projectId] = widget.client //Build the Souce lists widget.sources = widget.buildPivotalSources() widget.SetRenderFunction(widget.display) widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.display) return &widget } func (widget *Widget) loadSources() { var queries []string for _, query := range widget.settings.customQueries { queries = append(queries, query.title) } widget.Sources = queries } func (widget *Widget) buildPivotalSources() []*PivotalSource { var sources []*PivotalSource for _, query := range widget.settings.customQueries { client := widget.client // Make sure that we have a viable Pivotal Client if query.project != "" && query.project != widget.client.projectId { nclient, ok := widget.projectClient[query.project] if !ok { nclient = NewPivotalClient(widget.settings.apiToken, query.project) } client = nclient } sources = append(sources, NewPivotalSource(query.title, query.filter, client, widget)) } return sources } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) CurrentSource() *PivotalSource { if len(widget.sources) == 0 { return nil } return widget.sources[widget.Idx] } func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.SetItemCount(widget.CurrentSource().getItemCount()) widget.display() } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Open() { widget.CurrentSource().Open() } func (widget *Widget) OpenPulls() { widget.CurrentSource().OpenPulls() } ================================================ FILE: modules/pocket/client.go ================================================ package pocket import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // Client pocket client Documention at https://getpocket.com/developer/docs/overview type Client struct { consumerKey string accessToken *string baseURL string redirectURL string } // NewClient returns a new PocketClient func NewClient(consumerKey, redirectURL string) *Client { return &Client{ consumerKey: consumerKey, redirectURL: redirectURL, baseURL: "https://getpocket.com/v3", } } // Item represents link in pocket api type Item struct { ItemID string `json:"item_id"` ResolvedID string `json:"resolved_id"` GivenURL string `json:"given_url"` GivenTitle string `json:"given_title"` Favorite string `json:"favorite"` Status string `json:"status"` TimeAdded string `json:"time_added"` TimeUpdated string `json:"time_updated"` TimeRead string `json:"time_read"` TimeFavorited string `json:"time_favorited"` SortID int `json:"sort_id"` ResolvedTitle string `json:"resolved_title"` ResolvedURL string `json:"resolved_url"` Excerpt string `json:"excerpt"` IsArticle string `json:"is_article"` IsIndex string `json:"is_index"` HasVideo string `json:"has_video"` HasImage string `json:"has_image"` WordCount string `json:"word_count"` Lang string `json:"lang"` TimeToRead int `json:"time_to_read"` TopImageURL string `json:"top_image_url"` ListenDurationEstimate int `json:"listen_duration_estimate"` } // ItemLists represent list of links type ItemLists struct { Status int `json:"status"` Complete int `json:"complete"` List map[string]Item `json:"list"` Since int `json:"since"` } type request struct { requestBody interface{} method string headers map[string]string url string } func (*Client) request(req request, result interface{}) error { var reqBody io.Reader if req.requestBody != nil { jsonValues, err := json.Marshal(req.requestBody) if err != nil { return err } reqBody = bytes.NewBuffer(jsonValues) } request, err := http.NewRequest(req.method, req.url, reqBody) if err != nil { return err } for key, value := range req.headers { request.Header.Add(key, value) } request.Header.Set("User-Agent", "wtfutil (https://github.com/wtfutil/wtf)") resp, err := http.DefaultClient.Do(request) if err != nil { return err } defer func() { _ = resp.Body.Close() }() responseBody, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { return fmt.Errorf(`server responded with [%d]:%s,url:%s`, resp.StatusCode, responseBody, req.url) } if err := json.Unmarshal(responseBody, &result); err != nil { return fmt.Errorf("could not unmarshal url [%s] \n\t\tresponse [%s] error:%w", req.url, responseBody, err) } return nil } type obtainRequestTokenRequest struct { ConsumerKey string `json:"consumer_key"` RedirectURI string `json:"redirect_uri"` } // ObtainRequestToken get request token to be used in the auth workflow func (client *Client) ObtainRequestToken() (code string, err error) { url := fmt.Sprintf("%s/oauth/request", client.baseURL) requestData := obtainRequestTokenRequest{ConsumerKey: client.consumerKey, RedirectURI: client.redirectURL} var responseData map[string]string req := request{ headers: map[string]string{ "X-Accept": "application/json", "Content-Type": "application/json", }, method: "POST", requestBody: requestData, url: url, } err = client.request(req, &responseData) if err != nil { return code, err } return responseData["code"], nil } // CreateAuthLink create authorization link to redirect the user to func (client *Client) CreateAuthLink(requestToken string) string { return fmt.Sprintf("https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s", requestToken, client.redirectURL) } type accessTokenRequest struct { ConsumerKey string `json:"consumer_key"` RequestCode string `json:"code"` } // accessTokenResponse represents type accessTokenResponse struct { AccessToken string `json:"access_token"` } // GetAccessToken exchange request token for accesstoken func (client *Client) GetAccessToken(requestToken string) (accessToken string, err error) { url := fmt.Sprintf("%s/oauth/authorize", client.baseURL) requestData := accessTokenRequest{ ConsumerKey: client.consumerKey, RequestCode: requestToken, } req := request{ method: "POST", url: url, requestBody: requestData, } req.headers = map[string]string{ "X-Accept": "application/json", "Content-Type": "application/json", } var response accessTokenResponse err = client.request(req, &response) if err != nil { return "", err } return response.AccessToken, nil } /* LinkState represents link states to be retrieved According to the api https://getpocket.com/developer/docs/v3/retrieve there are 3 states: 1-archive 2-unread 3-all however archive does not really well work and returns links that are in the unread list buy inspecting getpocket I found out that there is an undocumanted read state */ type LinkState string const ( // Read links that has been read (undocumanted) Read LinkState = "read" // Unread links has not been read Unread LinkState = "unread" ) // GetLinks retrieve links of a given states https://getpocket.com/developer/docs/v3/retrieve func (client *Client) GetLinks(state LinkState) (response ItemLists, err error) { url := fmt.Sprintf("%s/get?sort=newest&state=%s&consumer_key=%s&access_token=%s", client.baseURL, state, client.consumerKey, *client.accessToken) req := request{ method: "GET", url: url, } req.headers = map[string]string{ "X-Accept": "application/json", "Content-Type": "application/json", } err = client.request(req, &response) return response, err } // Action represents a mutation to link type Action string const ( // Archive to put the link in the archived list (read list) Archive Action = "archive" // ReAdd to put the link back in the to reed list ReAdd Action = "readd" ) type actionParams struct { Action Action `json:"action"` ItemID string `json:"item_id"` } // ModifyLink change the state of the link func (client *Client) ModifyLink(action Action, itemID string) (ok bool, err error) { actions := []actionParams{ { Action: action, ItemID: itemID, }, } urlActionParm, err := json.Marshal(actions) if err != nil { return false, err } url := fmt.Sprintf("%s/send?consumer_key=%s&access_token=%s&actions=%s", client.baseURL, client.consumerKey, *client.accessToken, urlActionParm) req := request{ method: "GET", url: url, } err = client.request(req, nil) if err != nil { return false, err } return true, nil } ================================================ FILE: modules/pocket/item_service.go ================================================ package pocket import "sort" type sortByTimeAdded []Item func (a sortByTimeAdded) Len() int { return len(a) } func (a sortByTimeAdded) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a sortByTimeAdded) Less(i, j int) bool { return a[i].TimeAdded > a[j].TimeAdded } func orderItemResponseByKey(response ItemLists) []Item { var items sortByTimeAdded for _, v := range response.List { items = append(items, v) } sort.Sort(items) return items } ================================================ FILE: modules/pocket/keyboard.go ================================================ package pocket import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("a", widget.toggleLink, "Toggle Link") widget.SetKeyboardChar("t", widget.toggleView, "Toggle view (links ,archived links)") widget.SetKeyboardChar("j", widget.Next, "Select Next Link") widget.SetKeyboardChar("k", widget.Prev, "Select Previous Link") widget.SetKeyboardChar("o", widget.openLink, "Open Link in the browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select Next Link") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select Previous Link") widget.SetKeyboardKey(tcell.KeyEnter, widget.openLink, "Open Link in the browser") } ================================================ FILE: modules/pocket/settings.go ================================================ package pocket import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Pocket" ) type Settings struct { *cfg.Common consumerKey string requestKey *string accessToken *string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), consumerKey: ymlConfig.UString("consumerKey"), } cfg.ModuleSecret(name, globalConfig, &settings.consumerKey).Load() return &settings } ================================================ FILE: modules/pocket/widget.go ================================================ package pocket import ( "fmt" "os" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/logger" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "gopkg.in/yaml.v2" ) type Widget struct { view.ScrollableWidget settings *Settings client *Client items []Item archivedView bool } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, client: NewClient(settings.consumerKey, "http://localhost"), archivedView: false, } widget.CommonSettings() widget.SetRenderFunction(widget.Render) widget.View.SetScrollable(true) widget.View.SetRegions(true) widget.initializeKeyboardControls() widget.Selected = -1 widget.SetItemCount(0) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) Refresh() { if widget.client.accessToken == nil { metaData, err := readMetaDataFromDisk() if err != nil || metaData.AccessToken == nil { widget.Redraw(widget.authorizeWorkFlow) return } widget.client.accessToken = metaData.AccessToken } state := Unread if widget.archivedView { state = Read } response, err := widget.client.GetLinks(state) if err != nil { widget.SetItemCount(0) } widget.items = orderItemResponseByKey(response) widget.SetItemCount(len(widget.items)) widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ type pocketMetaData struct { AccessToken *string } func writeMetaDataToDisk(metaData pocketMetaData) error { fileData, err := yaml.Marshal(metaData) if err != nil { return fmt.Errorf("could not write token to disk %w", err) } wtfConfigDir, err := cfg.WtfConfigDir() if err != nil { return nil } filePath := fmt.Sprintf("%s/%s", wtfConfigDir, "pocket.data") err = os.WriteFile(filePath, fileData, 0644) return err } func readMetaDataFromDisk() (pocketMetaData, error) { wtfConfigDir, err := cfg.WtfConfigDir() var metaData pocketMetaData if err != nil { return metaData, err } filePath := fmt.Sprintf("%s/%s", wtfConfigDir, "pocket.data") fileData, err := utils.ReadFileBytes(filePath) if err != nil { return metaData, err } err = yaml.Unmarshal(fileData, &metaData) return metaData, err } /* Authorization workflow is documented at https://getpocket.com/developer/docs/authentication broken to 4 steps : 1- Obtain a platform consumer key from http://getpocket.com/developer/apps/new. 2- Obtain a request token 3- Redirect user to Pocket to continue authorization 4- Receive the callback from Pocket, this wont be used 5- Convert a request token into a Pocket access token */ func (widget *Widget) authorizeWorkFlow() (string, string, bool) { title := widget.CommonSettings().Title if widget.settings.requestKey == nil { requestToken, err := widget.client.ObtainRequestToken() if err != nil { logger.Log(err.Error()) return title, err.Error(), true } widget.settings.requestKey = &requestToken redirectURL := widget.client.CreateAuthLink(requestToken) content := fmt.Sprintf("Please click on %s to Authorize the app", redirectURL) return title, content, true } if widget.settings.accessToken == nil { accessToken, err := widget.client.GetAccessToken(*widget.settings.requestKey) if err != nil { logger.Log(err.Error()) redirectURL := widget.client.CreateAuthLink(*widget.settings.requestKey) content := fmt.Sprintf("Please click on %s to Authorize the app", redirectURL) return title, content, true } content := "Authorized" widget.settings.accessToken = &accessToken metaData := pocketMetaData{ AccessToken: &accessToken, } err = writeMetaDataToDisk(metaData) if err != nil { content = err.Error() } return title, content, true } content := "Authorized" return title, content, true } func (widget *Widget) toggleView() { widget.archivedView = !widget.archivedView widget.Refresh() } func (widget *Widget) openLink() { sel := widget.GetSelected() if sel >= 0 && widget.items != nil && sel < len(widget.items) { item := &widget.items[sel] utils.OpenFile(item.GivenURL) } } func (widget *Widget) toggleLink() { sel := widget.GetSelected() action := Archive if widget.archivedView { action = ReAdd } if sel >= 0 && widget.items != nil && sel < len(widget.items) { item := &widget.items[sel] _, err := widget.client.ModifyLink(action, item.ItemID) if err != nil { logger.Log(err.Error()) } } widget.Refresh() } func (widget *Widget) formatItem(item Item, isSelected bool) string { foreColor, backColor := widget.settings.Colors.EvenForeground, widget.settings.Colors.EvenBackground text := item.ResolvedTitle if isSelected { foreColor = widget.settings.Colors.HighlightedForeground backColor = widget.settings.Colors.HighlightedBackground } return fmt.Sprintf("[%s:%s]%s[white]", foreColor, backColor, tview.Escape(text)) } func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title currentViewTitle := "Reading List" if widget.archivedView { currentViewTitle = "Archived list" } title = fmt.Sprintf("%s-%s", title, currentViewTitle) content := "" for i, v := range widget.items { content += widget.formatItem(v, i == widget.Selected) + "\n" } return title, content, false } ================================================ FILE: modules/power/battery.go ================================================ //go:build !linux && !freebsd package power import ( "fmt" "os/exec" "regexp" "strconv" "strings" "github.com/wtfutil/wtf/utils" ) const ( timeRegExp = "^(?:\\d|[01]\\d|2[0-3]):[0-5]\\d" ) type Battery struct { args []string cmd string result string Charge string Remaining string } func NewBattery() *Battery { battery := &Battery{ args: []string{"-g", "batt"}, cmd: "pmset", } return battery } /* -------------------- Exported Functions -------------------- */ func (battery *Battery) Refresh() { data := battery.execute() battery.result = battery.parse(data) } func (battery *Battery) String() string { return battery.result } /* -------------------- Unexported Functions -------------------- */ func (battery *Battery) execute() string { cmd := exec.Command(battery.cmd, battery.args...) return utils.ExecuteCommand(cmd) } func (battery *Battery) parse(data string) string { lines := strings.Split(data, "\n") if len(lines) < 2 { return msgNoBattery } stats := strings.Split(lines[1], "\t") if len(stats) < 2 { return msgNoBattery } details := strings.Split(stats[1], "; ") if len(details) < 3 { return msgNoBattery } str := "" str = str + fmt.Sprintf(" %14s: %s\n", "Charge", battery.formatCharge(details[0])) str = str + fmt.Sprintf(" %14s: %s\n", "Remaining", battery.formatRemaining(details[2])) str = str + fmt.Sprintf(" %14s: %s\n", "State", battery.formatState(details[1])) return str } func (battery *Battery) formatCharge(data string) string { percent, _ := strconv.ParseFloat(strings.Replace(data, "%", "", -1), 32) return utils.ColorizePercent(percent) } func (battery *Battery) formatRemaining(data string) string { r, _ := regexp.Compile(timeRegExp) result := r.FindString(data) if result == "" || result == "0:00" { result = "-" } return result } func (battery *Battery) formatState(data string) string { color := "" switch data { case "charging": color = "[green]" case "discharging": color = "[yellow]" default: color = "[white]" } return color + data + "[white]" } ================================================ FILE: modules/power/battery_freebsd.go ================================================ //go:build freebsd package power import ( "fmt" "os/exec" "strconv" "strings" "github.com/wtfutil/wtf/utils" ) var batteryState string type Battery struct { args []string cmd string result string Charge string Remaining string } func NewBattery() *Battery { return &Battery{} } /* -------------------- Exported Functions -------------------- */ func (battery *Battery) Refresh() { data := battery.execute() battery.result = battery.parse(data) } func (battery *Battery) String() string { return battery.result } /* -------------------- Unexported Functions -------------------- */ // returns 3 numbers // // 1/0 = AC/battery // c = battery charge percentage // -1/s = charging / seconds to empty func (battery *Battery) execute() string { cmd := exec.Command("apm", "-alt") return utils.ExecuteCommand(cmd) } func (battery *Battery) parse(data string) string { lines := strings.Split(data, "\n") if len(lines) < 3 { return "unknown" } batteryState = strings.TrimSpace(lines[0]) charge := strings.TrimSpace(lines[1]) timeToEmpty := "∞" seconds, err := strconv.Atoi(strings.TrimSpace(lines[2])) if err == nil && seconds >= 0 { h := seconds / 3600 m := seconds % 3600 / 60 s := seconds % 60 timeToEmpty = fmt.Sprintf("%2d:%02d:%02d", h, m, s) } str := fmt.Sprintf(" %14s: %s%%\n", "Charge", battery.formatCharge(charge)) str += fmt.Sprintf(" %14s: %s\n", "Remaining", timeToEmpty) str += fmt.Sprintf(" %14s: %s\n", "State", battery.formatState(batteryState)) return str } func (battery *Battery) formatCharge(data string) string { percent, _ := strconv.ParseFloat(strings.Replace(data, "%", "", -1), 32) return utils.ColorizePercent(percent) } func (battery *Battery) formatState(data string) string { color := "" switch data { case "1": color = "[green]charging" case "0": color = "[yellow]discharging" default: color = "[white]unknown" } return color + "[white]" } ================================================ FILE: modules/power/battery_linux.go ================================================ //go:build linux package power import ( "fmt" "os/exec" "strconv" "strings" "github.com/wtfutil/wtf/utils" ) var batteryState string type Battery struct { result string Charge string Remaining string } func NewBattery() *Battery { return &Battery{} } /* -------------------- Exported Functions -------------------- */ func (battery *Battery) Refresh() { data := battery.execute() battery.result = battery.parse(data) } func (battery *Battery) String() string { return battery.result } /* -------------------- Unexported Functions -------------------- */ func (battery *Battery) execute() string { cmd := exec.Command("upower", "-e") lines := strings.Split(utils.ExecuteCommand(cmd), "\n") var target string for _, l := range lines { if strings.Contains(l, "/battery") { target = l break } } cmd = exec.Command("upower", "-i", target) return utils.ExecuteCommand(cmd) } func (battery *Battery) parse(data string) string { lines := strings.Split(data, "\n") if len(lines) < 2 { return "unknown" } table := make(map[string]string) for _, line := range lines { parts := strings.Split(line, ":") if len(parts) < 2 { continue } table[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } if s := table["time to empty"]; s == "" { table["time to empty"] = "∞" } str := fmt.Sprintf(" %14s: %s\n", "Charge", battery.formatCharge(table["percentage"])) str += fmt.Sprintf(" %14s: %s\n", "Remaining", table["time to empty"]) str += fmt.Sprintf(" %14s: %s\n", "State", battery.formatState(table["state"])) if s := table["time to full"]; s != "" { str += fmt.Sprintf(" %10s: %s\n", "TimeToFull", table["time to full"]) } batteryState = table["state"] return str } func (battery *Battery) formatCharge(data string) string { percent, _ := strconv.ParseFloat(strings.ReplaceAll(data, "%", ""), 32) return utils.ColorizePercent(percent) } func (battery *Battery) formatState(data string) string { color := "" switch data { case "charging": color = "[green]" case "discharging": color = "[yellow]" default: color = "[white]" } return color + data + "[white]" } ================================================ FILE: modules/power/managed_device_test.go ================================================ package power import ( "reflect" "testing" "gotest.tools/assert" ) func Test_Refresh(t *testing.T) { // ioreg -c AppleDeviceManagementHIDEventService -r -l data := ` +-o AppleDeviceManagementHIDEventService { "LowBatteryNotificationPercentage" = 2 "PrimaryUsagePage" = 65333 "BatteryFaultNotificationType" = "TPBatteryFault" "HasBattery" = Yes "VendorID" = 76 "VersionNumber" = 0 "Built-In" = No "DeviceAddress" = "3c-9b" "WakeReason" = "Button (0x03)" "Product" = "Magic Trackpad 2" "SerialNumber" = "3c-9b" "Transport" = "Bluetooth" "BatteryLowNotificationType" = "TPLowBattery" "ProductID" = 613 "DeviceUsagePairs" = ({"DeviceUsagePage"=65333,"DeviceUsage"=11},{"DeviceUsagePage"=65333,"DeviceUsage"=20}) "IOPersonalityPublisher" = "com.apple.driver.AppleTopCaseHIDEventDriver" "BatteryPercent" = 81 "MTFW Version" = 944 "BD_ADDR" = <3ca6f6cccc9b> "BatteryStatusNotificationType" = "BatteryStatusChanged" "CriticallyLowBatteryNotificationPercentage" = 1 "ReportInterval" = 11250 "RadioFW Version" = 272 "VendorIDSource" = 1 "STFW Version" = 2144 "CFBundleIdentifier" = "com.apple.driver.AppleTopCaseHIDEventDriver" "IOProviderClass" = "IOHIDInterface" "LocationID" = 1642556667 "BluetoothDevice" = Yes "IOClass" = "AppleDeviceManagementHIDEventService" "HIDServiceSupport" = No "CFBundleIdentifierKernel" = "com.apple.driver.AppleTopCaseHIDEventDriver" "ProductIDArray" = (613) "BatteryStatusFlags" = 0 "ColorID" = 33 "IOMatchCategory" = "IODefaultMatchCategory" "CountryCode" = 0 "IOProbeScore" = 7175 "PrimaryUsage" = 11 "IOGeneralInterest" = "IOCommand is not serializable" "BTFW Version" = 272 } +-o AppleDeviceManagementHIDEventService { "LowBatteryNotificationPercentage" = 2 "PrimaryUsagePage" = 65666 "BatteryFaultNotificationType" = "KBBatteryFault" "HasBattery" = Yes "VendorID" = 76 "TrustedAccessoryFW Version" = 5666 "Built-In" = No "DeviceAddress" = "ac-c5" "VersionNumber" = 0 "WakeReason" = "Host (0x01)" "Product" = "Magic Keyboard with Touch ID" "SerialNumber" = "ac-c5" "Transport" = "Bluetooth" "BatteryLowNotificationType" = "KB2LowBattery" "ProductID" = 666 "DeviceUsagePairs" = ({"DeviceUsagePage"=65666,"DeviceUsage"=11},{"DeviceUsagePage"=65666,"DeviceUsage"=20}) "IOPersonalityPublisher" = "com.apple.driver.AppleTopCaseDriverV2" "BatteryPercent" = 93 "BD_ADDR" = "BatteryStatusNotificationType" = "BatteryStatusChanged" "CriticallyLowBatteryNotificationPercentage" = 1 "ReportInterval" = 11250 "RadioFW Version" = 328 "VendorIDSource" = 1 "STFW Version" = 1024 "CFBundleIdentifier" = "com.apple.driver.AppleTopCaseHIDEventDriver" "IOProviderClass" = "IOHIDInterface" "LocationID" = 1642556667 "BluetoothDevice" = Yes "IOClass" = "AppleDeviceManagementHIDEventService" "HIDServiceSupport" = No "CFBundleIdentifierKernel" = "com.apple.driver.AppleTopCaseHIDEventDriver" "ProductIDArray" = (666) "BatteryStatusFlags" = 0 "ColorID" = 32 "IOMatchCategory" = "IODefaultMatchCategory" "CountryCode" = 2 "IOProbeScore" = 7175 "PrimaryUsage" = 11 "IOGeneralInterest" = "IOCommand is not serializable" "BTFW Version" = 328 } ` manDevices := NewManagedDevices() manDevices.Devices = manDevices.parse(data) assert.Equal(t, 2, len(manDevices.Devices)) first := manDevices.Devices[0] assert.Equal(t, "Magic Trackpad 2", first.Product()) assert.Equal(t, int64(81), first.BatteryPercent()) assert.Equal(t, true, first.BluetoothDevice()) assert.Equal(t, true, first.HasBattery()) } func Test_Add(t *testing.T) { tests := []struct { name string src string expected map[string]string }{ { name: "with empty string", src: "", expected: map[string]string{}, }, { name: "with no delimiter match", src: "catsdogs", expected: map[string]string{}, }, { name: "with valid src", src: "cats=dogs", expected: map[string]string{ "cats": "dogs", }, }, { name: "with valid multiline src", src: "cats=dogs\nx=y", expected: map[string]string{ "cats": "dogs", "x": "y", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manDev := NewManagedDevice() manDev.Add(tt.src) if !reflect.DeepEqual(tt.expected, manDev.Attributes) { t.Errorf("\nexpected %v\n got %v", tt.expected, manDev.Attributes) } }) } } func Test_Attributes(t *testing.T) { tests := []struct { name string data string }{ { name: "with valid attributes", data: ` "LowBatteryNotificationPercentage" = 2 "PrimaryUsagePage" = 65666 "BatteryFaultNotificationType" = "KBBatteryFault" "HasBattery" = Yes "VendorID" = 76 "TrustedAccessoryFW Version" = 5666 "Built-In" = No "DeviceAddress" = "ac-c5" "VersionNumber" = 0 "WakeReason" = "Host (0x01)" "Product" = "Magic Keyboard with Touch ID" "SerialNumber" = "ac-c5" "Transport" = "Bluetooth" "BatteryLowNotificationType" = "KB2LowBattery" "ProductID" = 666 "DeviceUsagePairs" = ({"DeviceUsagePage"=65666,"DeviceUsage"=11},{"DeviceUsagePage"=65666,"DeviceUsage"=20}) "IOPersonalityPublisher" = "com.apple.driver.AppleTopCaseDriverV2" "BatteryPercent" = 93 "BD_ADDR" = "BatteryStatusNotificationType" = "BatteryStatusChanged" "CriticallyLowBatteryNotificationPercentage" = 1 "ReportInterval" = 11250 "RadioFW Version" = 328 "VendorIDSource" = 1 "STFW Version" = 1024 "CFBundleIdentifier" = "com.apple.driver.AppleTopCaseHIDEventDriver" "IOProviderClass" = "IOHIDInterface" "LocationID" = 1642556667 "BluetoothDevice" = Yes "IOClass" = "AppleDeviceManagementHIDEventService" "HIDServiceSupport" = No "CFBundleIdentifierKernel" = "com.apple.driver.AppleTopCaseHIDEventDriver" "ProductIDArray" = (666) "BatteryStatusFlags" = 0 "ColorID" = 32 "IOMatchCategory" = "IODefaultMatchCategory" "CountryCode" = 2 "IOProbeScore" = 7175 "PrimaryUsage" = 11 "IOGeneralInterest" = "IOCommand is not serializable" "BTFW Version" = 328 `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manDev := NewManagedDevice() manDev.Add(tt.data) assert.Equal(t, manDev.BatteryPercent(), int64(93)) assert.Equal(t, manDev.BluetoothDevice(), true) assert.Equal(t, manDev.BuiltIn(), false) assert.Equal(t, manDev.HasBattery(), true) assert.Equal(t, manDev.Product(), "Magic Keyboard with Touch ID") }) } } func Test_BatteryPercent(t *testing.T) { tests := []struct { name string percent string expected int64 }{ { name: "with empty percent", percent: "", expected: -1, }, { name: "with invalid percent", percent: "3a3", expected: -1, }, { name: "with negative percent", percent: "-23", expected: -23, }, { name: "with valid percent", percent: "23", expected: 23, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manDev := NewManagedDevice() manDev.Attributes["BatteryPercent"] = tt.percent actual := manDev.BatteryPercent() assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: modules/power/managed_devices.go ================================================ package power import ( "bufio" "fmt" "os/exec" "strconv" "strings" "github.com/wtfutil/wtf/utils" ) // ManageDevices are ... type ManagedDevices struct { Devices []*ManagedDevice args []string cmd string } func NewManagedDevices() *ManagedDevices { manDevices := &ManagedDevices{ Devices: []*ManagedDevice{}, // This command queries for all managed devices args: []string{"-c", "AppleDeviceManagementHIDEventService", "-r", "-l"}, cmd: "ioreg", } return manDevices } func (manDevices *ManagedDevices) Refresh() { cmd := exec.Command(manDevices.cmd, manDevices.args...) data := utils.ExecuteCommand(cmd) manDevices.Devices = manDevices.parse(data) } /* -------------------- Unexported Functions -------------------- */ // parse takes the output of the command and turns it into ManagedDevice instances func (manDevices *ManagedDevices) parse(data string) []*ManagedDevice { devices := []*ManagedDevice{} chunks := utils.FindBetween(data, "{\n", "}\n") for _, chunk := range chunks { manDev := NewManagedDevice() manDev.Add(chunk) devices = append(devices, manDev) } return devices } /* -------------------- And Another Thing -------------------- */ // ManagedDevice represents an entry in the output returned by ioreg when // passed AppleDeviceManagementHIDEventService type ManagedDevice struct { Attributes map[string]string } func NewManagedDevice() *ManagedDevice { manDev := &ManagedDevice{ Attributes: map[string]string{}, } return manDev } /* -------------------- Exported Functions -------------------- */ // Add takes a chunk of raw text and attempts to parse it as managed device data // and create an attribute map from it. /* A typical chunk will look like: "LowBatteryNotificationPercentage" = 2 "BatteryFaultNotificationType" = "TPBatteryFault" ... "VersionNumber" = 0 which should become: { "LowBatteryNotificationPercentage": 2, "BatteryFaultNotificationType": "TPBatteryFault", "VersionNumber": 0, } */ func (manDev *ManagedDevice) Add(chunk string) { scanner := bufio.NewScanner(strings.NewReader(chunk)) for scanner.Scan() { line := strings.ReplaceAll(scanner.Text(), "\"", "") pieces := strings.Split(line, "=") if len(pieces) == 2 { left := strings.TrimSpace(pieces[0]) right := strings.TrimSpace(pieces[1]) manDev.Attributes[left] = right } } } // Dump writes out all the device attributes as a single string func (manDev *ManagedDevice) Dump() string { out := "" for attribute, value := range manDev.Attributes { out += fmt.Sprintf("%s %s\n", attribute, value) } return out } /* -------------------- Attributes -------------------- */ // BatteryPercent returns the percent of the device battery func (manDev *ManagedDevice) BatteryPercent() int64 { percent, err := strconv.ParseInt(manDev.Attributes["BatteryPercent"], 10, 64) if err != nil { return -1 } return percent } // BluetoothDevice returns whether or not the device supports bluetooth func (manDev *ManagedDevice) BluetoothDevice() bool { return manDev.Attributes["BluetoothDevice"] == "Yes" } // BuiltIn returns whether or not the device is built into the computer func (manDev *ManagedDevice) BuiltIn() bool { return manDev.Attributes["BuiltIn"] == "Yes" } // HasBattery returns whether or not the device has a battery func (manDev *ManagedDevice) HasBattery() bool { return manDev.Attributes["HasBattery"] == "Yes" } // Product returns the name of the device func (manDev *ManagedDevice) Product() string { return manDev.Attributes["Product"] } ================================================ FILE: modules/power/settings.go ================================================ package power import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Power" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/power/source.go ================================================ //go:build !linux && !freebsd package power import ( "os/exec" "regexp" "strings" "github.com/wtfutil/wtf/utils" ) const SingleQuotesRegExp = "'(.*)'" // powerSource returns the name of the current power source, probably one of // "AC Power" or "Battery Power" func powerSource() string { cmd := exec.Command("pmset", []string{"-g", "ps"}...) result := utils.ExecuteCommand(cmd) r, _ := regexp.Compile(SingleQuotesRegExp) source := r.FindString(result) source = strings.Replace(source, "'", "", -1) return source } ================================================ FILE: modules/power/source_freebsd.go ================================================ //go:build freebsd package power // powerSource returns the name of the current power source, probably one of // "AC Power" or "Battery Power" func powerSource() string { switch batteryState { case "1": return "AC Power" case "0": return "Battery Power" } return batteryState } ================================================ FILE: modules/power/source_linux.go ================================================ //go:build linux package power // powerSource returns the name of the current power source, probably one of // "AC Power" or "Battery Power" func powerSource() string { switch batteryState { case "charging", "fully-charged": return "AC Power" case "discharging": return "Battery Power" } return batteryState } ================================================ FILE: modules/power/widget.go ================================================ package power import ( "fmt" "runtime" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( msgNoBattery = " no battery found" productNameTrimLen = 14 ) type Widget struct { view.TextWidget Battery *Battery ManagedDevices *ManagedDevices settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), Battery: NewBattery(), ManagedDevices: NewManagedDevices(), settings: settings, } widget.View.SetWrap(true) return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.Battery.Refresh() // Handle the reading of connected battery-driven devices switch runtime.GOOS { case "darwin": widget.ManagedDevices.Refresh() case "linux": case "windows": default: } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { content := fmt.Sprintf(" %14s: %s\n", "Source", powerSource()) if widget.Battery.String() != msgNoBattery { content += widget.Battery.String() content += "\n" } content += "\n" for _, manDev := range widget.ManagedDevices.Devices { if manDev.HasBattery() { percent := utils.ColorizePercent(float64(manDev.BatteryPercent())) prodName := manDev.Product() if len(prodName) > productNameTrimLen { prodName = prodName[:productNameTrimLen] } content += fmt.Sprintf(" %s: %s\n", prodName, percent) } } return widget.CommonSettings().Title, content, true } ================================================ FILE: modules/progress/settings.go ================================================ package progress import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Progress" ) type colors struct { gradientA string `help:"Start color for linear gradient." values:"any X11 or hex color" optional:"true" default:"#56ab2f"` gradientB string `help:"End color for linear gradient." values:"any X11 or hex color" optional:"true" default:"#a8e063"` solid string `help:"Use a solid color instead of linear color gradient ." values:"any X11 or hex color" optional:"true"` } // Settings defines the configuration properties for this module type Settings struct { colors common *cfg.Common showPercentage string `help:"Where to display the percentage" values:"left, right, above, below, titleLeft, titleRight, or none" optional:"true" default:"right"` padding int `help:"Amount of spaces to add as left/right padding." values:"A positive integer, 0..n" optional:"true" default:"1"` minimum float64 `help:"Minimum progress value." values:"A positive decimal value, 0.0..n.n" optional:"true" default:"0"` maximum float64 `help:"Maximum progress value." values:"A positive decimal value, 0.0..n.n" optional:"true" default:"0"` current float64 `help:"Current progress value. If maximum value is 0, current value is assumed to be a percentage between 0-100." values:"A positive decimal value, 0.0..n.n" optional:"true" default:"0"` minimumCmd string `help:"Execute shell command to determine minimum progress value. Return value must be numeric." values:"Any shell command" optional:"true"` maximumCmd string `help:"Execute shell command to determine maximum progress value. Return value must be numeric." values:"Any shell command" optional:"true"` currentCmd string `help:"Execute shell command to determine current progress value. Return value must be numeric." values:"Any shell command" optional:"true"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), showPercentage: ymlConfig.UString("showPercentage", "right"), padding: ymlConfig.UInt("padding", 1), minimum: ymlConfig.UFloat64("minimum", 0), maximum: ymlConfig.UFloat64("maximum", 0), current: ymlConfig.UFloat64("current", 0), minimumCmd: ymlConfig.UString("minimumCmd", ""), maximumCmd: ymlConfig.UString("maximumCmd", ""), currentCmd: ymlConfig.UString("currentCmd", ""), } settings.gradientA = ymlConfig.UString("colors.gradientA", "#56ab2f") settings.gradientB = ymlConfig.UString("colors.gradientB", "#a8e063") settings.solid = ymlConfig.UString("colors.solid", "") return &settings } ================================================ FILE: modules/progress/widget.go ================================================ package progress import ( "errors" "fmt" "os" "os/exec" "strconv" "strings" "github.com/charmbracelet/bubbles/progress" "github.com/muesli/reflow/ansi" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) var errShellUndefined = errors.New("command shell not defined in $SHELL environment variable") // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings minimum float64 maximum float64 current float64 percent float64 padding string shell string err error } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.common), settings: settings, minimum: settings.minimum, maximum: settings.maximum, current: settings.current, shell: os.Getenv("SHELL"), padding: strings.Repeat(" ", settings.padding), } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { var err error if cmd := widget.settings.minimumCmd; cmd != "" { widget.minimum, err = widget.execValueCmd(cmd) if err != nil { widget.err = fmt.Errorf("minimumCmd execution failed: %w", err) widget.display() return } } if cmd := widget.settings.maximumCmd; cmd != "" { widget.maximum, err = widget.execValueCmd(cmd) if err != nil { widget.err = fmt.Errorf("maximumCmd execution failed: %w", err) widget.display() return } } if cmd := widget.settings.currentCmd; cmd != "" { widget.current, err = widget.execValueCmd(cmd) if err != nil { widget.err = fmt.Errorf("currentCmd execution failed: %w", err) widget.display() return } } widget.calcPercent() widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { if widget.err != nil { return "[red]Error: " + widget.err.Error() } percent := widget.formatPercent(widget.percent) bar := widget.buildProgressBar(percent) barView := tview.TranslateANSI(bar.ViewAs(widget.percent)) var sb strings.Builder switch widget.settings.showPercentage { case "left": sb.WriteString(widget.padding + percent + barView + widget.padding) case "right": sb.WriteString(widget.padding + barView + percent + widget.padding) case "above": centered := utils.CenterText(percent, bar.Width+widget.settings.padding*2) sb.WriteString(centered + "\n" + widget.padding + barView + widget.padding) case "below": centered := utils.CenterText(percent, bar.Width+widget.settings.padding*2) sb.WriteString(widget.padding + barView + widget.padding + "\n" + centered) default: sb.WriteString(widget.padding + barView + widget.padding) } return sb.String() } func (widget *Widget) display() { title := widget.CommonSettings().Title switch widget.settings.showPercentage { case "titleLeft": title = widget.formatPercent(widget.percent) + " " + title case "titleRight": title = title + " " + widget.formatPercent(widget.percent) } widget.Redraw(func() (string, string, bool) { return title, widget.content(), false }) } func (widget *Widget) execValueCmd(cmd string) (float64, error) { if widget.shell == "" { return -1, errShellUndefined } out, err := exec.Command(widget.shell, "-c", cmd).Output() if err != nil { return -1, err } outStr := strings.TrimSpace(string(out)) val, err := strconv.ParseFloat(outStr, 64) if err != nil { return -1, fmt.Errorf("failed to parse command output '%s' as float64: %w", outStr, err) } return val, nil } func (widget *Widget) buildProgressBar(percent string) *progress.Model { pOpts := []progress.Option{ progress.WithWidth(widget.calcBarWidth(percent)), progress.WithoutPercentage(), } if widget.settings.solid != "" { pOpts = append(pOpts, progress.WithSolidFill(widget.settings.solid)) } else { pOpts = append(pOpts, progress.WithGradient( widget.settings.gradientA, widget.settings.gradientB, )) } pb := progress.New(pOpts...) return &pb } func (widget *Widget) calcPercent() { if widget.maximum == 0 { if widget.current > 100 { widget.percent = 1 } if widget.current < 0 { widget.percent = 0 } widget.percent = widget.current / 100 return } if widget.current > widget.maximum { widget.percent = 1 return } if widget.current < widget.minimum { widget.percent = 0 return } widget.percent = (widget.current - widget.minimum) / (widget.maximum - widget.minimum) } func (widget *Widget) formatPercent(p float64) string { switch widget.settings.showPercentage { case "left": return fmt.Sprintf("%.0f%% ", p*100) case "right": return fmt.Sprintf(" %.0f%%", p*100) case "none": return "" default: return fmt.Sprintf("%.0f%%", p*100) } } func (widget *Widget) calcBarWidth(percent string) int { _, _, width, _ := widget.View.GetInnerRect() width -= widget.settings.padding * 2 if widget.settings.showPercentage == "left" || widget.settings.showPercentage == "right" { width -= ansi.PrintableRuneWidth(percent) } return width } ================================================ FILE: modules/resourceusage/settings.go ================================================ package resourceusage import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultRefreshInterval = "1s" defaultTitle = "ResourceUsage" ) type Settings struct { *cfg.Common cpuCombined bool showCPU bool showMem bool showSwp bool } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), cpuCombined: ymlConfig.UBool("cpuCombined", false), showCPU: ymlConfig.UBool("showCPU", true), showMem: ymlConfig.UBool("showMem", true), showSwp: ymlConfig.UBool("showSwp", true), } settings.RefreshInterval = cfg.ParseTimeString(ymlConfig, "refreshInterval", defaultRefreshInterval) return &settings } ================================================ FILE: modules/resourceusage/widget.go ================================================ package resourceusage import ( "fmt" "math" "time" "code.cloudfoundry.org/bytefmt" "github.com/rivo/tview" "github.com/shirou/gopsutil/cpu" "github.com/shirou/gopsutil/mem" "github.com/wtfutil/wtf/view" ) // Widget define wtf widget to register widget later type Widget struct { settings *Settings tviewApp *tview.Application view.BarGraph } // NewWidget Make new instance of widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ BarGraph: view.NewBarGraph(tviewApp, redrawChan, settings.Name, settings.Common), tviewApp: tviewApp, settings: settings, } widget.View.SetWrap(false) widget.View.SetWordWrap(false) return &widget } /* -------------------- Exported Functions -------------------- */ // MakeGraph - Load the dead drop stats func MakeGraph(widget *Widget) { cpuStats, memInfo := getDataFromSystem(widget) var itemsCount = 0 if widget.settings.showCPU { itemsCount += len(cpuStats) } if widget.settings.showMem { itemsCount++ } if widget.settings.showSwp { itemsCount++ } var stats = make([]view.Bar, itemsCount) var nextIndex = 0 if widget.settings.showCPU && len(cpuStats) > 0 { for i, stat := range cpuStats { // Stats sometimes jump outside the 0-100 range, possibly due to timing stat = math.Min(100, stat) stat = math.Max(0, stat) var label string if widget.settings.cpuCombined { label = "CPU" } else { label = fmt.Sprint(i) } bar := view.Bar{ Label: label, Percent: int(stat), ValueLabel: fmt.Sprintf("%d%%", int(stat)), LabelColor: "red", } stats[nextIndex] = bar nextIndex++ } } if widget.settings.showMem { usedMemLabel := bytefmt.ByteSize(memInfo.Used) totalMemLabel := bytefmt.ByteSize(memInfo.Total) if usedMemLabel[len(usedMemLabel)-1] == totalMemLabel[len(totalMemLabel)-1] { usedMemLabel = usedMemLabel[:len(usedMemLabel)-1] } stats[nextIndex] = view.Bar{ Label: "Mem", Percent: int(memInfo.UsedPercent), ValueLabel: fmt.Sprintf("%s/%s", usedMemLabel, totalMemLabel), LabelColor: "green", } nextIndex++ } if widget.settings.showSwp { swapUsed := memInfo.SwapTotal - memInfo.SwapFree var swapPercent float64 if memInfo.SwapTotal > 0 { swapPercent = float64(swapUsed) / float64(memInfo.SwapTotal) } usedSwapLabel := bytefmt.ByteSize(swapUsed) totalSwapLabel := bytefmt.ByteSize(memInfo.SwapTotal) if usedSwapLabel[len(usedSwapLabel)-1] == totalSwapLabel[len(totalSwapLabel)-1] { usedSwapLabel = usedSwapLabel[:len(usedSwapLabel)-1] } stats[nextIndex] = view.Bar{ Label: "Swp", Percent: int(swapPercent * 100), ValueLabel: fmt.Sprintf("%s/%s", usedSwapLabel, totalSwapLabel), LabelColor: "yellow", } } widget.BuildBars(stats) } // Refresh & update after interval time func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.View.Clear() MakeGraph(widget) } /* -------------------- Unexported Functions -------------------- */ func getDataFromSystem(widget *Widget) (cpuStats []float64, memInfo mem.VirtualMemoryStat) { if widget.settings.showCPU { rCPUStats, err := cpu.Percent(time.Duration(0), !widget.settings.cpuCombined) if err == nil { cpuStats = rCPUStats } } if widget.settings.showMem || widget.settings.showSwp { rMemInfo, err := mem.VirtualMemory() if err == nil { memInfo = *rMemInfo } } return cpuStats, memInfo } ================================================ FILE: modules/rollbar/client.go ================================================ package rollbar import ( "fmt" "net/http" "net/url" "github.com/wtfutil/wtf/utils" ) func CurrentActiveItems(accessToken, assignedToName string, activeOnly bool) (*ActiveItems, error) { items := &ActiveItems{} rollbarAPIURL.Host = "api.rollbar.com" rollbarAPIURL.Path = "/api/1/items" resp, err := rollbarItemRequest(accessToken, assignedToName, activeOnly) if err != nil { return items, err } err = utils.ParseJSON(&items, resp.Body) if err != nil { return items, err } return items, nil } /* -------------------- Unexported Functions -------------------- */ var ( rollbarAPIURL = &url.URL{Scheme: "https"} ) func rollbarItemRequest(accessToken, assignedToName string, activeOnly bool) (*http.Response, error) { params := url.Values{} params.Add("access_token", accessToken) params.Add("assigned_user", assignedToName) if activeOnly { params.Add("status", "active") } requestURL := rollbarAPIURL.ResolveReference(&url.URL{RawQuery: params.Encode()}) req, _ := http.NewRequest("GET", requestURL.String(), http.NoBody) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } return resp, nil } ================================================ FILE: modules/rollbar/keyboard.go ================================================ package rollbar import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openBuild, "Open item in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openBuild, "Open item in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/rollbar/rollbar.go ================================================ package rollbar type ActiveItems struct { Results Result `json:"result"` } type Item struct { Environment string `json:"environment"` Title string `json:"title"` Platform string `json:"platform"` Status string `json:"status"` TotalOccurrences int `json:"total_occurrences"` Level string `json:"level"` ID int `json:"counter"` } type Result struct { Items []Item `json:"items"` } ================================================ FILE: modules/rollbar/settings.go ================================================ package rollbar import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Rollbar" ) type Settings struct { *cfg.Common accessToken string `help:"Your Rollbar project access token (Only needs read capabilities)."` activeOnly bool `help:"Only show items that are active." optional:"true"` assignedToName string `help:"Set this to your username if you only want to see items assigned to you." optional:"true"` count int `help:"How many items you want to see. 100 is max." optional:"true"` projectName string `help:"This is used to create a link to the item."` projectOwner string `help:"This is used to create a link to the item."` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), accessToken: ymlConfig.UString("accessToken", os.Getenv("WTF_ROLLBAR_ACCESS_TOKEN")), activeOnly: ymlConfig.UBool("activeOnly", false), assignedToName: ymlConfig.UString("assignedToName"), count: ymlConfig.UInt("count", 10), projectName: ymlConfig.UString("projectName", "Items"), projectOwner: ymlConfig.UString("projectOwner"), } cfg.ModuleSecret(name, globalConfig, &settings.accessToken).Load() return &settings } ================================================ FILE: modules/rollbar/widget.go ================================================ package rollbar import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // A Widget represents a Rollbar widget type Widget struct { view.ScrollableWidget items *Result settings *Settings err error } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } items, err := CurrentActiveItems( widget.settings.accessToken, widget.settings.assignedToName, widget.settings.activeOnly, ) if err != nil { widget.err = err widget.items = nil widget.SetItemCount(0) } else { widget.items = &items.Results widget.SetItemCount(len(widget.items.Items)) } widget.Render() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - %s", widget.CommonSettings().Title, widget.settings.projectName) if widget.err != nil { return widget.CommonSettings().Title, widget.err.Error(), true } result := widget.items if result == nil || len(result.Items) == 0 { return title, "No results", false } var str string if len(result.Items) > widget.settings.count { result.Items = result.Items[:widget.settings.count] } for idx, item := range result.Items { row := fmt.Sprintf( "[%s] [%s] %s [%s] %s [%s]count: %d [%s]%s", widget.RowColor(idx), levelColor(&item), item.Level, statusColor(&item), item.Title, widget.RowColor(idx), item.TotalOccurrences, "blue", item.Environment, ) str += utils.HighlightableHelper(widget.View, row, idx, len(item.Title)) } return title, str, false } func statusColor(item *Item) string { switch item.Status { case "active": return "red" case "resolved": return "green" default: return "red" } } func levelColor(item *Item) string { switch item.Level { case "error": return "red" case "critical": return "green" case "warning": return "yellow" default: return "grey" } } func (widget *Widget) openBuild() { if widget.GetSelected() >= 0 && widget.items != nil && widget.GetSelected() < len(widget.items.Items) { item := &widget.items.Items[widget.GetSelected()] utils.OpenFile( fmt.Sprintf( "https://rollbar.com/%s/%s/%s/%d", widget.settings.projectOwner, widget.settings.projectName, "items", item.ID, ), ) } } ================================================ FILE: modules/security/dns.go ================================================ package security import ( "os/exec" "runtime" "strings" "github.com/wtfutil/wtf/utils" ) /* -------------------- Exported Functions -------------------- */ func DnsServers() []string { switch runtime.GOOS { case "linux": return dnsLinux() case "darwin": return dnsMacOS() case "windows": return dnsWindows() default: return []string{runtime.GOOS} } } /* -------------------- Unexported Functions -------------------- */ func dnsLinux() []string { // This may be very Ubuntu specific cmd := exec.Command("nmcli", "device", "show") out := utils.ExecuteCommand(cmd) lines := strings.Split(out, "\n") dns := []string{} for _, l := range lines { if strings.HasPrefix(l, "IP4.DNS") { parts := strings.Split(l, ":") dns = append(dns, strings.TrimSpace(parts[1])) } } return dns } func dnsMacOS() []string { cmdString := `scutil --dns | head -n 7 | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}'` cmd := exec.Command("sh", "-c", cmdString) out := utils.ExecuteCommand(cmd) lines := strings.Split(out, "\n") if len(lines) > 0 { return lines } return []string{} } func dnsWindows() []string { cmd := exec.Command("powershell.exe", "-NoProfile", "Get-DnsClientServerAddress | Select-Object –ExpandProperty ServerAddresses") return []string{utils.ExecuteCommand(cmd)} } ================================================ FILE: modules/security/firewall.go ================================================ package security import ( "fmt" "os/exec" "runtime" "strings" "syscall" "github.com/wtfutil/wtf/utils" ) const osxFirewallCmd = "/usr/libexec/ApplicationFirewall/socketfilterfw" /* -------------------- Exported Functions -------------------- */ func FirewallState() string { switch runtime.GOOS { case "darwin": return firewallStateMacOS() case "linux": return firewallStateLinux() case "windows": return firewallStateWindows() default: return "" } } func FirewallStealthState() string { switch runtime.GOOS { case "linux": return firewallStealthStateLinux() case "darwin": return firewallStealthStateMacOS() case "windows": return firewallStealthStateWindows() default: return "" } } /* -------------------- Unexported Functions -------------------- */ func firewallStateLinux() string { // Check UFW first if hasUfw := checkUfw(); hasUfw != "" { return hasUfw } // Check Firewalld if hasFirewalld := checkFirewalld(); hasFirewalld != "" { return hasFirewalld } // Check nftables if hasNft := checkNftables(); hasNft != "" { return hasNft } // Check iptables as last resort if hasIpt := checkIptables(); hasIpt != "" { return hasIpt } return "[red]No firewall[white]" } func checkFirewalld() string { checkInstalled := exec.Command("which", "firewall-cmd") if err := checkInstalled.Run(); err != nil { return "" } cmd := exec.Command("firewall-cmd", "--state") err := cmd.Start() if err != nil { return "[red]Failed to start status check (firewalld)[white]" } err = cmd.Wait() if err == nil { return "[green]Active (firewalld)[white]" } if exitError, ok := err.(*exec.ExitError); ok { sc := exitError.Sys().(syscall.WaitStatus).ExitStatus() switch sc { case 251: return "[yellow]Running but failed (firewalld)[white]" case 252: return "[red]Not running (firewalld)[white]" default: return fmt.Sprintf("[red]Unexpected state (%d) assume not running (firewalld)[white]", sc) } } else { return fmt.Sprintf("[red] Error waiting for command: %v (firewalld)[white]", err) } } func checkUfw() string { // First check if UFW is installed checkInstalled := exec.Command("which", "ufw") if err := checkInstalled.Run(); err != nil { return "" } // Then check if service is running cmd := exec.Command("systemctl", "is-active", "ufw") err := cmd.Run() if err == nil { return "[green]Enabled (ufw)[white]" } return "[red]Disabled (ufw)[white]" } func checkNftables() string { // First check if nftables is installed checkInstalled := exec.Command("which", "nft") if err := checkInstalled.Run(); err != nil { return "" } // Then check if service is running cmd := exec.Command("systemctl", "is-active", "nftables") err := cmd.Run() if err == nil { return "[green]Enabled (nftables)[white]" } return "[red]Disabled (nftables)[white]" } func checkIptables() string { // First check if iptables is installed checkInstalled := exec.Command("which", "iptables") if strings.Contains(utils.ExecuteCommand(checkInstalled), "not found") { return "" } // Check if iptables module is loaded cmd := exec.Command("lsmod") out := utils.ExecuteCommand(cmd) if strings.Contains(out, "ip_tables") { // Check for any active rules cmd := exec.Command("iptables", "-L") out := utils.ExecuteCommand(cmd) if strings.Contains(out, "Chain") && !strings.Contains(out, "0 references") { return "[green]Enabled (iptables)[white]" } return "[yellow]Loaded but unable to check rules (iptables)[white]" } return "" } func firewallStateMacOS() string { cmd := exec.Command(osxFirewallCmd, "--getglobalstate") str := utils.ExecuteCommand(cmd) return statusLabel(str) } func firewallStateWindows() string { // The raw way to do this in PS, not using netsh, nor registry, is the following: // if (((Get-NetFirewallProfile | select name,enabled) // | where { $_.Enabled -eq $True } | measure ).Count -eq 3) // { Write-Host "OK" -ForegroundColor Green} else { Write-Host "OFF" -ForegroundColor Red } cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "& { ((Get-NetFirewallProfile | select name,enabled) | where { $_.Enabled -eq $True } | measure ).Count }") fwStat := utils.ExecuteCommand(cmd) fwStat = strings.TrimSpace(fwStat) // Always sanitize PowerShell output: "3\r\n" switch fwStat { case "3": return "[green]Good[white] (3/3)" case "2": return "[orange]Poor[white] (2/3)" case "1": return "[yellow]Bad[white] (1/3)" case "0": return "[red]Disabled[white]" default: return "[white]N/A[white]" } } /* -------------------- Getting Stealth State ------------------- */ // "Stealth": Not responding to pings from unauthorized devices func firewallStealthStateLinux() string { return "[white]N/A[white]" } func firewallStealthStateMacOS() string { cmd := exec.Command(osxFirewallCmd, "--getstealthmode") str := utils.ExecuteCommand(cmd) return statusLabel(str) } func firewallStealthStateWindows() string { return "[white]N/A[white]" } func statusLabel(str string) string { label := "off" if strings.Contains(str, "enabled") { label = "on" } return label } ================================================ FILE: modules/security/security_data.go ================================================ package security type SecurityData struct { Dns []string FirewallEnabled string FirewallStealth string LoggedInUsers []string WifiEncryption string WifiName string } func NewSecurityData() *SecurityData { return &SecurityData{} } func (data SecurityData) DnsAt(idx int) string { if len(data.Dns) > idx { return data.Dns[idx] } return "" } func (data *SecurityData) Fetch() { data.Dns = DnsServers() data.FirewallEnabled = FirewallState() data.FirewallStealth = FirewallStealthState() data.LoggedInUsers = LoggedInUsers() data.WifiName = WifiName() data.WifiEncryption = WifiEncryption() } ================================================ FILE: modules/security/settings.go ================================================ package security import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Security" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/security/users.go ================================================ package security // http://applehelpwriter.com/2017/05/21/how-to-reveal-hidden-users/ import ( "os/exec" "runtime" "strings" "github.com/wtfutil/wtf/utils" ) /* -------------------- Exported Functions -------------------- */ func LoggedInUsers() []string { switch runtime.GOOS { case "linux": return loggedInUsersLinux() case "darwin": return loggedInUsersMacOs() case "windows": return loggedInUsersWindows() default: return []string{} } } /* -------------------- Unexported Functions -------------------- */ func cleanUsers(users []string) []string { rejects := []string{"_", "root", "nobody", "daemon", "Guest"} cleaned := []string{} for _, user := range users { clean := true for _, reject := range rejects { if strings.HasPrefix(user, reject) { clean = false continue } } if clean && user != "" { cleaned = append(cleaned, user) } } return cleaned } func loggedInUsersLinux() []string { cmd := exec.Command("who", "-us") users := utils.ExecuteCommand(cmd) cleaned := []string{} for _, user := range strings.Split(users, "\n") { clean := true col := strings.Split(user, " ") if len(col) > 0 { for _, cleanedU := range cleaned { u := strings.TrimSpace(col[0]) if u == "" || strings.Compare(cleanedU, col[0]) == 0 { clean = false } } if clean { cleaned = append(cleaned, col[0]) } } } return cleaned } func loggedInUsersMacOs() []string { cmd := exec.Command("dscl", []string{".", "-list", "/Users"}...) users := utils.ExecuteCommand(cmd) return cleanUsers(strings.Split(users, "\n")) } func loggedInUsersWindows() []string { // We can use either one: // (Get-WMIObject -class Win32_ComputerSystem | select username).username // [System.Security.Principal.WindowsIdentity]::GetCurrent().Name // The original was: // cmd := exec.Command("powershell.exe", "(query user) -replace '\\s{2,}', ','") // But that didn't work! // The real powershell command reads: // powershell.exe -NoProfile -Command "& { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name }" // But we here have to write it as: cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "& { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name }") // ToDo: Make list for multi-user systems users := utils.ExecuteCommand(cmd) return cleanUsers(strings.Split(users, "\n")) } ================================================ FILE: modules/security/widget.go ================================================ package security import ( "fmt" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { data := NewSecurityData() data.Fetch() var str string if data.WifiName != "" { str += fmt.Sprintf(" [%s]WiFi[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf(" %8s: %s\n", "Network", data.WifiName) str += fmt.Sprintf(" %8s: %s\n", "Crypto", data.WifiEncryption) str += "\n" } str += fmt.Sprintf(" [%s]Firewall[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf(" %8s: %4s\n", "Status", data.FirewallEnabled) str += fmt.Sprintf(" %8s: %4s\n", "Stealth", data.FirewallStealth) str += "\n" str += fmt.Sprintf(" [%s]Users[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf(" %s", strings.Join(data.LoggedInUsers, "\n ")) str += "\n\n" str += fmt.Sprintf(" [%s]DNS[white]\n", widget.settings.Colors.Subheading) // If no DNS servers are found, display a single line of 'n/a' if len(data.Dns) == 0 { str += fmt.Sprintf(" %6s\n", "n/a") } else { for _, ip := range data.Dns { str += fmt.Sprintf(" %12s\n", ip) } } str += "\n" return widget.CommonSettings().Title, str, false } ================================================ FILE: modules/security/wifi.go ================================================ package security import ( "os/exec" "runtime" "strings" "github.com/wtfutil/wtf/utils" ) // https://github.com/yelinaung/wifi-name/blob/master/wifi-name.go const osxWifiCmd = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport" const osxWifiArg = "-I" /* -------------------- Exported Functions -------------------- */ func WifiEncryption() string { switch runtime.GOOS { case "linux": return wifiEncryptionLinux() case "darwin": return wifiEncryptionMacOS() case "windows": return wifiEncryptionWindows() default: return "" } } func WifiName() string { switch runtime.GOOS { case "linux": return wifiNameLinux() case "darwin": return wifiNameMacOS() case "windows": return wifiNameWindows() default: return "" } } /* -------------------- Unexported Functions -------------------- */ func wifiEncryptionLinux() string { cmd := exec.Command("nmcli", "-t", "-f", "in-use,security", "dev", "wifi") out := utils.ExecuteCommand(cmd) name := utils.FindMatch(`\*:(.+)`, out) if len(name) > 0 { return name[0][1] } return "" } func wifiEncryptionMacOS() string { name := utils.FindMatch(`s*auth: (.+)s*`, wifiInfo()) return matchStr(name) } func wifiInfo() string { cmd := exec.Command(osxWifiCmd, osxWifiArg) return utils.ExecuteCommand(cmd) } func wifiNameLinux() string { cmd, _ := exec.Command("iwgetid", "-r").Output() return string(cmd) } func wifiNameMacOS() string { name := utils.FindMatch(`s*SSID: (.+)s*`, wifiInfo()) return matchStr(name) } func matchStr(data [][]string) string { if len(data) <= 1 { return "" } return data[1][1] } // Windows func wifiEncryptionWindows() string { return parseWlanNetsh("Authentication") } func wifiNameWindows() string { return parseWlanNetsh("SSID") } func parseWlanNetsh(target string) string { cmd := exec.Command("netsh.exe", "wlan", "show", "interfaces") out, err := cmd.Output() if err != nil { return "" } splits := strings.Split(string(out), "\n") var words []string for _, line := range splits { token := strings.Split(line, ":") for _, word := range token { words = append(words, strings.TrimSpace(word)) } } for i, token := range words { if token == target { return words[i+1] } } return "N/A" } ================================================ FILE: modules/spacex/client.go ================================================ package spacex import ( "net/http" "github.com/wtfutil/wtf/utils" ) const ( spacexLaunchAPI = "https://api.spacexdata.com/v3/launches/next" ) type Launch struct { FlightNumber int `json:"flight_number"` MissionName string `json:"mission_name"` LaunchDate int64 `json:"launch_date_unix"` IsTentative bool `json:"tentative"` Rocket Rocket `json:"rocket"` LaunchSite LaunchSite `json:"launch_site"` Links Links `json:"links"` Details string `json:"details"` } type LaunchSite struct { Name string `json:"site_name_long"` } type Rocket struct { Name string `json:"rocket_name"` } type Links struct { RedditLink string `json:"reddit_campaign"` YouTubeLink string `json:"video_link"` } func NextLaunch() (*Launch, error) { resp, err := http.Get(spacexLaunchAPI) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var data Launch err = utils.ParseJSON(&data, resp.Body) if err != nil { return nil, err } return &data, nil } ================================================ FILE: modules/spacex/settings.go ================================================ package spacex import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { spacex := ymlConfig.UString("spacex") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, spacex, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/spacex/widget.go ================================================ package spacex import ( "fmt" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" "github.com/wtfutil/wtf/wtf" ) type Widget struct { view.TextWidget settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := &Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return widget } func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.Redraw(widget.content) } func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { var title = "Next SpaceX 🚀" if widget.CommonSettings().Title != "" { title = widget.CommonSettings().Title } launch, err := NextLaunch() var str string if err != nil { handleError(widget, err) } else { str = fmt.Sprintf("[%s]Mission[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf("%s: %s\n", "Name", launch.MissionName) str += fmt.Sprintf("%s: %s\n", "Date", wtf.UnixTime(launch.LaunchDate).Format(time.RFC822)) str += fmt.Sprintf("%s: %s\n", "Site", launch.LaunchSite.Name) str += "\n" str += fmt.Sprintf("[%s]Links[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf("%s: %s\n", "YouTube", launch.Links.YouTubeLink) str += fmt.Sprintf("%s: %s\n", "Reddit", launch.Links.RedditLink) if widget.CommonSettings().Height >= 2 { str += "\n" str += fmt.Sprintf("[%s]Details[white]\n", widget.settings.Colors.Subheading) str += fmt.Sprintf("%s: %s\n", "RocketName", launch.Rocket.Name) str += fmt.Sprintf("%s: %s\n", "Details", launch.Details) } } return title, str, true } func handleError(widget *Widget, err error) { widget.err = err } ================================================ FILE: modules/spotify/keyboard.go ================================================ package spotify import ( "time" "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("l", widget.next, "Select next item") widget.SetKeyboardChar("h", widget.previous, "Select previous item") widget.SetKeyboardChar(" ", widget.playPause, "Play/pause song") widget.SetKeyboardKey(tcell.KeyDown, widget.next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.previous, "Select previous item") } func (widget *Widget) previous() { widget.client.Previous() time.Sleep(time.Second * 1) widget.Refresh() } func (widget *Widget) next() { widget.client.Next() time.Sleep(time.Second * 1) widget.Refresh() } func (widget *Widget) playPause() { widget.client.PlayPause() time.Sleep(time.Second * 1) widget.Refresh() } ================================================ FILE: modules/spotify/settings.go ================================================ package spotify import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Spotify" ) type colors struct { label string text string } type Settings struct { colors *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } settings.label = ymlConfig.UString("colors.label", "green") settings.text = ymlConfig.UString("colors.text", "white") return &settings } ================================================ FILE: modules/spotify/widget.go ================================================ package spotify import ( "fmt" "strings" "time" "github.com/rivo/tview" "github.com/wtfutil/spotigopher/spotigopher" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // A Widget represents a Spotify widget type Widget struct { view.TextWidget client spotigopher.SpotifyClient settings *Settings spotigopher.Info } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), Info: spotigopher.Info{}, client: spotigopher.NewClient(), settings: settings, } widget.settings.RefreshInterval = 5 * time.Second widget.initializeKeyboardControls() widget.View.SetWrap(true) widget.View.SetWordWrap(true) return &widget } func (w *Widget) refreshSpotifyInfos() error { info, err := w.client.GetInfo() w.Info = info return err } func (w *Widget) Refresh() { w.Redraw(w.createOutput) } func (w *Widget) createOutput() (string, string, bool) { var content string err := w.refreshSpotifyInfos() if err != nil { content = err.Error() } else { labelColor := w.settings.label textColor := w.settings.text artist := strings.Join(w.Artist, ", ") content = utils.CenterText(fmt.Sprintf("[%s]Now %v [%s]\n", labelColor, w.Status, textColor), w.CommonSettings().Width) content += utils.CenterText(fmt.Sprintf("[%s]Title:[%s] %v\n ", labelColor, textColor, w.Title), w.CommonSettings().Width) content += utils.CenterText(fmt.Sprintf("[%s]Artist:[%s] %v\n", labelColor, textColor, artist), w.CommonSettings().Width) content += utils.CenterText(fmt.Sprintf("[%s]%v:[%s] %v\n", labelColor, w.TrackNumber, textColor, w.Album), w.CommonSettings().Width) } return w.CommonSettings().Title, content, true } ================================================ FILE: modules/spotifyweb/keyboard.go ================================================ package spotifyweb import ( "time" "github.com/gdamore/tcell/v2" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("h", widget.selectPrevious, "Select previous item") widget.SetKeyboardChar("l", widget.selectNext, "Select next item") widget.SetKeyboardChar(" ", widget.playPause, "Play/pause") widget.SetKeyboardChar("s", widget.toggleShuffle, "Toggle shuffle") widget.SetKeyboardKey(tcell.KeyDown, widget.selectNext, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.selectPrevious, "Select previous item") } func (widget *Widget) selectPrevious() { err := widget.client.Previous() if err != nil { return } time.Sleep(time.Millisecond * 500) widget.Refresh() } func (widget *Widget) selectNext() { err := widget.client.Next() if err != nil { return } time.Sleep(time.Millisecond * 500) widget.Refresh() } func (widget *Widget) playPause() { var err error if widget.playerState.Playing { err = widget.client.Pause() } else { err = widget.client.Play() } if err != nil { return } time.Sleep(time.Millisecond * 500) widget.Refresh() } func (widget *Widget) toggleShuffle() { widget.playerState.ShuffleState = !widget.playerState.ShuffleState err := widget.client.Shuffle(widget.playerState.ShuffleState) if err != nil { return } time.Sleep(time.Millisecond * 500) widget.Refresh() } ================================================ FILE: modules/spotifyweb/settings.go ================================================ package spotifyweb import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Spotify Web" ) type Settings struct { *cfg.Common callbackPort string clientID string secretKey string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), callbackPort: ymlConfig.UString("callbackPort", "8080"), clientID: ymlConfig.UString("clientID", os.Getenv("SPOTIFY_ID")), secretKey: ymlConfig.UString("secretKey", os.Getenv("SPOTIFY_SECRET")), } cfg.ModuleSecret(name, globalConfig, &settings.secretKey).Load() return &settings } ================================================ FILE: modules/spotifyweb/widget.go ================================================ package spotifyweb import ( "errors" "fmt" "net/http" "os" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "github.com/zmb3/spotify" ) var ( auth spotify.Authenticator tempClientChan = make(chan *spotify.Client) state = "wtfSpotifyWebStateString" authURL string callbackPort string redirectURI string ) // Info is the struct that contains all the information the Spotify player displays to the user type Info struct { Artists string Title string Album string TrackNumber int Status string } // Widget is the struct used by all WTF widgets to transfer to the main widget controller type Widget struct { view.TextWidget Info client *spotify.Client clientChan chan *spotify.Client playerState *spotify.PlayerState settings *Settings } func authHandler(w http.ResponseWriter, r *http.Request) { tok, err := auth.Token(state, r) if err != nil { http.Error(w, "Couldn't get token", http.StatusForbidden) } if st := r.FormValue("state"); st != state { http.NotFound(w, r) } // use the token to get an authenticated client client := auth.NewClient(tok) _, err = fmt.Fprintf(w, "Login Completed!") if err != nil { return } tempClientChan <- &client } // NewWidget creates a new widget for WTF func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { redirectURI = "http://localhost:" + settings.callbackPort + "/callback" auth = spotify.NewAuthenticator(redirectURI, spotify.ScopeUserReadCurrentlyPlaying, spotify.ScopeUserReadPlaybackState, spotify.ScopeUserModifyPlaybackState) auth.SetAuthInfo(settings.clientID, settings.secretKey) authURL = auth.AuthURL(state) var client *spotify.Client var playerState *spotify.PlayerState widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), Info: Info{}, client: client, clientChan: tempClientChan, playerState: playerState, settings: settings, } http.HandleFunc("/callback", authHandler) go func() { err := http.ListenAndServe(":"+callbackPort, nil) if err != nil { return } }() go func() { // wait for auth to complete client = <-tempClientChan // use the client to make calls that require authorization _, err := client.CurrentUser() if err != nil { panic(err) } playerState, err = client.PlayerState() if err != nil { fmt.Println(err) os.Exit(1) } widget.client = client widget.playerState = playerState widget.Refresh() }() // While I wish I could find the reason this doesn't work, I can't. // // Normally, this should open the URL to the browser, however it opens the Explorer window in Windows. // This mostly likely has to do with the fact that the URL includes some very special characters that no terminal likes. // The only solution would be to include quotes in the command, which is why I do here, but it doesn't work. // // If inconvenient, I'll remove this option and save the URL in a file or some other method. utils.OpenFile(`"` + authURL + `"`) widget.settings.RefreshInterval = 5 * time.Second widget.initializeKeyboardControls() widget.View.SetWrap(true) widget.View.SetWordWrap(true) return &widget } func (w *Widget) refreshSpotifyInfos() error { if w.client == nil || w.playerState == nil { return errors.New("authentication failed! Please log in to Spotify by visiting the following page in your browser: " + authURL) } var err error w.playerState, err = w.client.PlayerState() if err != nil { return errors.New("extracting player state failed! Please refresh or restart WTF") } w.Album = fmt.Sprint(w.playerState.Item.Album.Name) artists := "" for _, artist := range w.playerState.Item.Artists { artists += artist.Name + ", " } artists = artists[:len(artists)-2] w.Artists = artists w.Title = fmt.Sprint(w.playerState.Item.Name) w.TrackNumber = w.playerState.Item.TrackNumber if w.playerState.Playing { w.Status = "Playing" } else { w.Status = "Paused" } return nil } // Refresh refreshes the current view of the widget func (w *Widget) Refresh() { w.Redraw(w.createOutput) } func (w *Widget) createOutput() (string, string, bool) { var output string err := w.refreshSpotifyInfos() if err != nil { output = err.Error() } else { output += utils.CenterText(fmt.Sprintf("[green]Now %v [white]\n", w.Status), w.CommonSettings().Width) output += utils.CenterText(fmt.Sprintf("[green]Title:[white] %v\n", w.Title), w.CommonSettings().Width) output += utils.CenterText(fmt.Sprintf("[green]Artist:[white] %v\n", w.Artists), w.CommonSettings().Width) output += utils.CenterText(fmt.Sprintf("[green]Album:[white] %v\n", w.Album), w.CommonSettings().Width) if w.playerState.ShuffleState { output += utils.CenterText("[green]Shuffle:[white] on\n", w.CommonSettings().Width) } else { output += utils.CenterText("[green]Shuffle:[white] off\n", w.CommonSettings().Width) } } return w.CommonSettings().Title, output, true } ================================================ FILE: modules/status/settings.go ================================================ package status import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Status" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/status/widget.go ================================================ package status import ( "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget CurrentIcon int settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), CurrentIcon: 0, settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { widget.Redraw(widget.animation) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) animation() (string, string, bool) { icons := []string{"|", "/", "-", "\\", "|"} next := icons[widget.CurrentIcon] widget.CurrentIcon++ if widget.CurrentIcon == len(icons) { widget.CurrentIcon = 0 } return widget.CommonSettings().Title, next, false } ================================================ FILE: modules/steam/client.go ================================================ package steam import ( "encoding/json" "fmt" "io" "net/http" ) type Steam struct { client *http.Client baseUrl string } type ClientOpts struct { key string baseUrl string } func NewClient(opts *ClientOpts) *Steam { baseUrl := opts.baseUrl if opts.baseUrl == "" { baseUrl = "http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=" } baseUrl += opts.key + "&steamids=" return &Steam{ client: &http.Client{}, baseUrl: baseUrl, } } type Player struct { Personaname string `json:"personaname"` ProfileUrl string `json:"profileurl"` Personastate int `json:"personastate"` Gameextrainfo string `json:"gameextrainfo"` } type SteamResponse struct { Response struct { Players []Player `json:"players"` } `json:"response"` } func (s *Steam) Status(steamID string) (*Player, error) { resp, err := s.fetch(steamID) if err != nil { return nil, err } var response SteamResponse if err := json.Unmarshal(resp, &response); err != nil { return nil, err } return &response.Response.Players[0], nil } func (s *Steam) fetch(id string) ([]byte, error) { resp, err := http.Get(s.baseUrl + id) if err != nil || resp.StatusCode != 200 { return nil, fmt.Errorf("error fetching %s steam status: %v, status: %d", id, err, resp.StatusCode) } defer resp.Body.Close() return io.ReadAll(resp.Body) } ================================================ FILE: modules/steam/keyboard.go ================================================ package steam import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/steam/settings.go ================================================ package steam import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true ) type Settings struct { *cfg.Common numberOfResults int `help:"Number of rows to show. Default is 10." optional:"true"` key string `help:"Steam API key (default is env var STEAM_API_KEY)"` userIds []string `help:"Steam user ids" optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { steam := ymlConfig.UString("steam") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, steam, defaultFocusable, ymlConfig, globalConfig), numberOfResults: ymlConfig.UInt("numberOfResults", 10), key: ymlConfig.UString("key", os.Getenv("STEAM_API_KEY")), userIds: utils.ToStrs(ymlConfig.UList("userIds", make([]interface{}, 0))), } return &settings } ================================================ FILE: modules/steam/widget.go ================================================ package steam import ( "context" "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "golang.org/x/sync/errgroup" ) type Widget struct { view.ScrollableWidget settings *Settings err error steam *Steam players []*Player } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, steam: NewClient(&ClientOpts{key: settings.key}), } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } func (widget *Widget) Refresh() { errg, _ := errgroup.WithContext(context.Background()) players := make([]*Player, len(widget.settings.userIds)) for i, id := range widget.settings.userIds { func(idx int, id string) { errg.Go(func() error { status, err := widget.steam.Status(id) if err != nil { return err } players[idx] = status return nil }) }(i, id) } if err := errg.Wait(); err != nil { widget.err = err widget.players = nil widget.SetItemCount(0) } else { widget.err = nil if len(players) <= widget.settings.numberOfResults { widget.players = players } else { widget.players = players[:widget.settings.numberOfResults] } widget.SetItemCount(len(widget.players)) } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } func friendlyStatus(personastate int) string { switch personastate { case 0: return "Offline" case 1: return "Online" case 2: return "Busy" case 3: return "Away" case 4: return "Snooze" case 5: return "Looking to Trade" case 6: return "Looking to Play" } return "" } func (widget *Widget) content() (string, string, bool) { var title = "Steam Statuses" if widget.CommonSettings().Title != "" { title = widget.CommonSettings().Title } if widget.err != nil { return title, widget.err.Error(), true } if len(widget.players) == 0 { return title, "No data", false } var str string for idx, player := range widget.players { status := friendlyStatus(player.Personastate) row := fmt.Sprintf( "[white]%s: [yellow]%s", player.Personaname, status, ) if len(player.Gameextrainfo) > 0 { row += " [red](" + player.Gameextrainfo + ")" } str += utils.HighlightableHelper(widget.View, row, idx, len(player.Personaname)) } return title, str, false } ================================================ FILE: modules/stocks/finnhub/client.go ================================================ package finnhub import ( "encoding/json" "fmt" "net/http" "net/url" ) // Client .. type Client struct { symbols []string apiKey string } // NewClient .. func NewClient(symbols []string, apiKey string) *Client { client := Client{ symbols: symbols, apiKey: apiKey, } return &client } // Getquote .. func (client *Client) Getquote() ([]Quote, error) { quotes := []Quote{} for _, s := range client.symbols { resp, err := client.finnhubRequest(s) if err != nil { return quotes, err } var quote Quote quote.Stock = s err = json.NewDecoder(resp.Body).Decode("e) if err != nil { return quotes, err } quotes = append(quotes, quote) } return quotes, nil } /* -------------------- Unexported Functions -------------------- */ var ( finnhubURL = &url.URL{Scheme: "https", Host: "finnhub.io", Path: "/api/v1/quote"} ) func (client *Client) finnhubRequest(symbol string) (*http.Response, error) { params := url.Values{} params.Add("symbol", symbol) params.Add("token", client.apiKey) url := finnhubURL.ResolveReference(&url.URL{RawQuery: params.Encode()}) req, err := http.NewRequest("GET", url.String(), http.NoBody) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") if err != nil { return nil, err } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } return resp, nil } ================================================ FILE: modules/stocks/finnhub/quote.go ================================================ package finnhub type Quote struct { C float64 `json:"c"` H float64 `json:"h"` L float64 `json:"l"` O float64 `json:"o"` Pc float64 `json:"pc"` T int `json:"t"` Stock string } ================================================ FILE: modules/stocks/finnhub/settings.go ================================================ package finnhub import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "📈 Stocks Price" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common apiKey string `help:"Your finnhub API token."` symbols []string `help:"An array of stocks symbols (i.e. AAPL, MSFT)"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_FINNHUB_API_KEY"))), symbols: utils.ToStrs(ymlConfig.UList("symbols")), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/stocks/finnhub/widget.go ================================================ package finnhub import ( "fmt" "github.com/jedib0t/go-pretty/v6/table" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget .. type Widget struct { view.TextWidget *Client settings *Settings } // NewWidget .. func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ Client: NewClient(settings.symbols, settings.apiKey), TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { quotes, err := widget.Getquote() title := widget.CommonSettings().Title t := table.NewWriter() t.AppendHeader(table.Row{"#", "Stock", "Current Price", "Open Price", "Change"}) wrap := false if err != nil { wrap = true } else { for idx, q := range quotes { t.AppendRows([]table.Row{ {idx, q.Stock, q.C, q.O, fmt.Sprintf("%.4f", (q.C-q.O)/q.C)}, }) } } return title, t.Render(), wrap } ================================================ FILE: modules/stocks/yfinance/settings.go ================================================ package yfinance import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = false defaultTitle = "Yahoo Finance" ) type colors struct { bigup string up string drop string bigdrop string } // Settings defines the configuration properties for this module type Settings struct { common *cfg.Common colors colors sort bool symbols []string `help:"An array of Yahoo Finance symbols (for example: DOCN, GME, GC=F)"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), // RefreshInterval: , } settings.common.RefreshInterval = cfg.ParseTimeString(ymlConfig, "refreshInterval", "60s") settings.colors.bigup = ymlConfig.UString("colors.bigup", "greenyellow") settings.colors.up = ymlConfig.UString("colors.up", "green") settings.colors.drop = ymlConfig.UString("colors.drop", "firebrick") settings.colors.bigdrop = ymlConfig.UString("colors.bigdrop", "red") settings.sort = ymlConfig.UBool("sort", false) settings.symbols = utils.ToStrs(ymlConfig.UList("symbols")) return &settings } ================================================ FILE: modules/stocks/yfinance/widget.go ================================================ package yfinance import ( "fmt" "sort" "github.com/jedib0t/go-pretty/v6/table" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { // The last call should always be to the display function widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { yquotes := quotes(widget.settings.symbols) colors := map[string]string{ "bigup": widget.settings.colors.bigup, "up": widget.settings.colors.up, "drop": widget.settings.colors.drop, "bigdrop": widget.settings.colors.bigdrop, } if widget.settings.sort { sort.SliceStable(yquotes, func(i, j int) bool { return yquotes[i].MarketChangePct > yquotes[j].MarketChangePct }) } t := table.NewWriter() t.SetStyle(tableStyle()) for _, yq := range yquotes { t.AppendRow([]interface{}{ GetMarketIcon(yq.MarketState), yq.Symbol, fmt.Sprintf("%8.2f %s", yq.MarketPrice, yq.Currency), GetTrendIcon(yq.Trend), fmt.Sprintf("[%s]%+6.2f (%+5.2f%%)[white]", colors[yq.Trend], yq.MarketChange, yq.MarketChangePct), }) } return t.Render() } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } ================================================ FILE: modules/stocks/yfinance/yquote.go ================================================ package yfinance import ( "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "github.com/piquette/finance-go/quote" ) type MarketState string type yquote struct { Trend string // can be bigup (>3%), up, drop or bigdrop (<3%) Symbol string Currency string MarketState string MarketPrice float64 MarketChange float64 MarketChangePct float64 } func tableStyle() table.Style { return table.Style{ Name: "yfinance", Box: table.BoxStyle{ BottomLeft: "", BottomRight: "", BottomSeparator: "", Left: "", LeftSeparator: "", MiddleHorizontal: " ", MiddleSeparator: "", MiddleVertical: "", PaddingLeft: " ", PaddingRight: "", Right: "", RightSeparator: "", TopLeft: "", TopRight: "", TopSeparator: "", UnfinishedRow: "", }, Color: table.ColorOptions{ Footer: text.Colors{}, Header: text.Colors{}, Row: text.Colors{}, RowAlternate: text.Colors{}, }, Format: table.FormatOptions{ Footer: text.FormatUpper, Header: text.FormatUpper, Row: text.FormatDefault, }, Options: table.Options{ DrawBorder: false, SeparateColumns: false, SeparateFooter: false, SeparateHeader: false, SeparateRows: false, }, } } func quotes(symbols []string) []yquote { var yquotes []yquote for _, symbol := range symbols { var yq yquote var MarketPrice float64 var MarketChange float64 var MarketChangePct float64 q, err := quote.Get(symbol) if q == nil || err != nil { yq = yquote{ Symbol: symbol, Trend: "?", MarketState: "?", } } else { switch q.MarketState { case "PRE": MarketPrice = q.PreMarketPrice MarketChange = q.PreMarketChange MarketChangePct = q.PreMarketChangePercent case "POST": MarketPrice = q.PostMarketPrice MarketChange = q.PostMarketChange MarketChangePct = q.PostMarketChangePercent default: MarketPrice = q.RegularMarketPrice MarketChange = q.RegularMarketChange MarketChangePct = q.RegularMarketChangePercent } yq = yquote{ Symbol: q.Symbol, Currency: q.CurrencyID, Trend: GetTrend(MarketChangePct), MarketState: string(q.MarketState), MarketPrice: MarketPrice, MarketChange: MarketChange, MarketChangePct: MarketChangePct, } } yquotes = append(yquotes, yq) } return yquotes } func GetMarketIcon(state string) string { states := map[string]string{ "PRE": "⏭", "REGULAR": "▶", "POST": "⏮", "?": "?", } if icon, ok := states[state]; ok { return icon } else { return "⏹" } } func GetTrendIcon(trend string) string { icons := map[string]string{ "bigup": "⬆️ ", "up": "↗️ ", "drop": "↘️ ", "bigdrop": "⬇️ ", } return icons[trend] } func GetTrend(pct float64) string { var trend string if pct > 3 { trend = "bigup" } else if pct > 0 { trend = "up" } else if pct > -3 { trend = "drop" } else { trend = "bigdrop" } return trend } ================================================ FILE: modules/subreddit/api.go ================================================ package subreddit import ( "crypto/tls" "fmt" "net/http" "github.com/wtfutil/wtf/utils" ) var rootPage = "https://www.reddit.com/r/" func GetLinks(subreddit string, sortMode string, topTimePeriod string) ([]Link, error) { url := rootPage + subreddit + "/" + sortMode + ".json" if sortMode == "top" { url = url + "?sort=top&t=" + topTimePeriod } request, err := http.NewRequest("GET", url, http.NoBody) if err != nil { return nil, err } request.Header.Set("User-Agent", "wtfutil (https://github.com/wtfutil/wtf)") // See https://www.reddit.com/r/redditdev/comments/t8e8hc/comment/i18yga2/?utm_source=share&utm_medium=web2x&context=3 client := &http.Client{ Transport: &http.Transport{ TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, }, } resp, err := client.Do(request) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } var m RedditDocument err = utils.ParseJSON(&m, resp.Body) if err != nil { return nil, err } if len(m.Data.Children) == 0 { return nil, fmt.Errorf("no links") } var links []Link for _, l := range m.Data.Children { links = append(links, l.Data) } return links, nil } ================================================ FILE: modules/subreddit/keyboard.go ================================================ package subreddit import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openLink, "Open target URL in browser") widget.SetKeyboardChar("c", widget.openReddit, "Open Reddit comments in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openReddit, "Open story in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/subreddit/link.go ================================================ package subreddit type Link struct { Score int `json:"ups"` Title string `json:"title"` ItemURL string `json:"url"` Permalink string `json:"permalink"` } type RedditDocument struct { Data Subreddit `json:"data"` } type RedditLinkDocument struct { Data Link `json:"data"` } type Subreddit struct { Children []RedditLinkDocument `json:"Children"` } ================================================ FILE: modules/subreddit/settings.go ================================================ package subreddit import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true ) // Settings contains the settings for the subreddit view type Settings struct { *cfg.Common subreddit string `help:"Subreddit to look at" optional:"false"` numberOfPosts int `help:"Number of posts to show. Default is 10." optional:"true"` sortOrder string `help:"Sort order for the posts (hot, new, rising, top), default hot" optional:"true"` topTimePeriod string `help:"If top sort is selected, the time period to show posts from (hour, week, day, month, year, all, default all)"` } // NewSettingsFromYAML creates the settings for this module from a yaml file func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { subreddit := ymlConfig.UString("subreddit") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, subreddit, defaultFocusable, ymlConfig, globalConfig), numberOfPosts: ymlConfig.UInt("numberOfPosts", 10), sortOrder: ymlConfig.UString("sortOrder", "hot"), topTimePeriod: ymlConfig.UString("topTimePeriod", "all"), subreddit: subreddit, } return &settings } ================================================ FILE: modules/subreddit/widget.go ================================================ package subreddit import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget settings *Settings err error links []Link } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { links, err := GetLinks(widget.settings.subreddit, widget.settings.sortOrder, widget.settings.topTimePeriod) if err != nil { widget.err = err widget.links = nil widget.SetItemCount(0) } else { if len(links) <= widget.settings.numberOfPosts { widget.links = links widget.SetItemCount(len(widget.links)) widget.err = nil } else { widget.links = links[:widget.settings.numberOfPosts] widget.SetItemCount(len(widget.links)) widget.err = nil } } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := "/r/" + widget.settings.subreddit + " - " + widget.settings.sortOrder if widget.err != nil { return title, widget.err.Error(), true } var content string for idx, link := range widget.links { row := fmt.Sprintf( `[%s]%2d. %s`, widget.RowColor(idx), idx+1, tview.Escape(link.Title), ) content += utils.HighlightableHelper(widget.View, row, idx, len(link.Title)) } return title, content, false } func (widget *Widget) openLink() { sel := widget.GetSelected() if sel >= 0 && widget.links != nil && sel < len(widget.links) { story := &widget.links[sel] utils.OpenFile(story.ItemURL) } } func (widget *Widget) openReddit() { sel := widget.GetSelected() if sel >= 0 && widget.links != nil && sel < len(widget.links) { story := &widget.links[sel] fullLink := "http://reddit.com" + story.Permalink utils.OpenFile(fullLink) } } ================================================ FILE: modules/system/settings.go ================================================ package system import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "System" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/system/system_info.go ================================================ //go:build !windows package system import ( "os/exec" "runtime" "strings" "github.com/wtfutil/wtf/utils" ) type SystemInfo struct { ProductName string ProductVersion string BuildVersion string } func NewSystemInfo() *SystemInfo { m := make(map[string]string) arg := []string{} var cmd *exec.Cmd switch runtime.GOOS { case "linux": arg = append(arg, "-a") cmd = exec.Command("lsb_release", arg...) case "darwin": cmd = exec.Command("sw_vers", arg...) default: cmd = exec.Command("sw_vers", arg...) } raw := utils.ExecuteCommand(cmd) for _, row := range strings.Split(raw, "\n") { parts := strings.Split(row, ":") if len(parts) < 2 { continue } m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } var sysInfo *SystemInfo switch runtime.GOOS { case "linux": sysInfo = &SystemInfo{ ProductName: m["Distributor ID"], ProductVersion: m["Description"], BuildVersion: m["Release"], } default: sysInfo = &SystemInfo{ ProductName: m["ProductName"], ProductVersion: m["ProductVersion"], BuildVersion: m["BuildVersion"], } } return sysInfo } ================================================ FILE: modules/system/system_info_windows.go ================================================ //go:build windows package system import ( "os/exec" "strings" ) type SystemInfo struct { ProductName string ProductVersion string BuildVersion string } func NewSystemInfo() *SystemInfo { m := make(map[string]string) cmd := exec.Command("powershell.exe", "(Get-CimInstance Win32_OperatingSystem).version") out, err := cmd.Output() if err != nil { panic(err) } s := strings.Split(string(out), ".") m["ProductName"] = "Windows" m["ProductVersion"] = "Windows " + s[0] + "." + s[1] m["BuildVersion"] = s[2] sysInfo := SystemInfo{ ProductName: m["ProductName"], ProductVersion: m["ProductVersion"], BuildVersion: m["BuildVersion"], } return &sysInfo } ================================================ FILE: modules/system/widget.go ================================================ package system import ( "fmt" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget Date string Version string settings *Settings systemInfo *SystemInfo } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, date, version string, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), Date: date, settings: settings, Version: version, } widget.systemInfo = NewSystemInfo() return &widget } func (widget *Widget) display() (string, string, bool) { content := fmt.Sprintf( "%8s: %s\n%8s: %s\n\n%8s: %s\n%8s: %s", "Built", widget.prettyDate(), "Vers", widget.Version, "OS", widget.systemInfo.ProductVersion, "Build", widget.systemInfo.BuildVersion, ) return widget.CommonSettings().Title, content, false } func (widget *Widget) Refresh() { widget.Redraw(widget.display) } func (widget *Widget) prettyDate() string { str, err := time.Parse(utils.TimestampFormat, widget.Date) if err != nil { return err.Error() } return str.Format("Jan _2, 15:04") } ================================================ FILE: modules/textfile/keyboard.go ================================================ package textfile import ( "github.com/gdamore/tcell/v2" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(nil) widget.SetKeyboardChar("l", widget.NextSource, "Select next file") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous file") widget.SetKeyboardChar("o", widget.openFile, "Open file") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next file") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous file") widget.SetKeyboardKey(tcell.KeyEnter, widget.openFile, "Open file") } func (widget *Widget) openFile() { src := widget.CurrentSource() utils.OpenFile(src) } ================================================ FILE: modules/textfile/settings.go ================================================ package textfile import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Textfile" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common filePaths []interface{} format bool formatStyle string wrapText bool } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), filePaths: ymlConfig.UList("filePaths"), format: ymlConfig.UBool("format", false), formatStyle: ymlConfig.UString("formatStyle", "vim"), wrapText: ymlConfig.UBool("wrapText", true), } return &settings } ================================================ FILE: modules/textfile/widget.go ================================================ package textfile import ( "bytes" "fmt" "io" "os" "path/filepath" "time" "github.com/alecthomas/chroma/formatters" "github.com/alecthomas/chroma/lexers" "github.com/alecthomas/chroma/styles" "github.com/radovskyb/watcher" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( pollingIntervalms = 100 ) type Widget struct { view.MultiSourceWidget view.TextWidget settings *Settings fileWatcher *watcher.Watcher } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "filePath", "filePaths"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } // Don't use a timer for this widget, watch for filesystem changes instead widget.settings.RefreshInterval = 0 widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.Refresh) widget.View.SetWordWrap(true) widget.View.SetWrap(settings.wrapText) go widget.watchForFileChanges() return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh is only called once on start-up. Its job is to display the // text files that first time. After that, the watcher takes over func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf( "[%s]%s[white]", widget.settings.Colors.Title, widget.CurrentSource(), ) _, _, width, _ := widget.View.GetRect() text := widget.settings.PaginationMarker(len(widget.Sources), widget.Idx, width) + "\n" if widget.settings.format { text += widget.formattedText() } else { text += widget.plainText() } return title, text, widget.settings.wrapText } func (widget *Widget) formattedText() string { filePath, _ := utils.ExpandHomeDir(widget.CurrentSource()) file, err := os.Open(filepath.Clean(filePath)) if err != nil { return err.Error() } defer func() { _ = file.Close() }() lexer := lexers.Match(filePath) if lexer == nil { lexer = lexers.Fallback } style := styles.Get(widget.settings.formatStyle) if style == nil { style = styles.Fallback } formatter := formatters.Get("terminal256") if formatter == nil { formatter = formatters.Fallback } contents, _ := io.ReadAll(file) str := string(contents) str = tview.Escape(str) iterator, _ := lexer.Tokenise(nil, str) var buf bytes.Buffer err = formatter.Format(&buf, style, iterator) if err != nil { return err.Error() } return tview.TranslateANSI(buf.String()) } func (widget *Widget) plainText() string { filePath, _ := utils.ExpandHomeDir(filepath.Clean(widget.CurrentSource())) text, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { return err.Error() } return tview.Escape(string(text)) } func (widget *Widget) watchForFileChanges() { widget.fileWatcher = watcher.New() watch := widget.fileWatcher watch.FilterOps(watcher.Write) go func() { for { select { case <-watch.Event: widget.Refresh() case err := <-watch.Error: fmt.Println(err) os.Exit(1) case <-watch.Closed: return case quit := <-widget.QuitChan(): if quit { watch.Close() return } } } }() // Watch each textfile for changes for _, source := range widget.Sources { fullPath, err := utils.ExpandHomeDir(source) if err == nil { e := watch.Add(fullPath) if e != nil { fmt.Println(e) os.Exit(1) } } } // Start the watching process - it'll check for changes every pollingIntervalms. if err := watch.Start(time.Millisecond * pollingIntervalms); err != nil { fmt.Println(err) os.Exit(1) } } ================================================ FILE: modules/todo/display.go ================================================ package todo import ( "fmt" "strings" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/checklist" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { str := "" hidden := 0 switch widget.settings.checkedPos { case "last": str, hidden = widget.sortListByChecked(widget.list.UncheckedItems(), widget.list.CheckedItems()) case "first": str, hidden = widget.sortListByChecked(widget.list.CheckedItems(), widget.list.UncheckedItems()) default: str, hidden = widget.sortListByChecked(widget.list.Items, []*checklist.ChecklistItem{}) } if widget.Error != "" { str = widget.Error } title := widget.CommonSettings().Title if widget.showTagPrefix != "" { title += " #" + widget.showTagPrefix } if widget.showFilter != "" { title += fmt.Sprintf(" /%s", widget.showFilter) } if widget.settings.hiddenNumInTitle { title += fmt.Sprintf(" (%d hidden)", hidden) } return title, str, false } func (widget *Widget) sortListByChecked(firstGroup []*checklist.ChecklistItem, secondGroup []*checklist.ChecklistItem) (string, int) { str := "" hidden := 0 newList := checklist.NewChecklist( widget.settings.Checkbox.Checked, widget.settings.Checkbox.Unchecked, ) offset := 0 selectedItem := widget.SelectedItem() for idx, item := range firstGroup { if widget.shouldShowItem(item) { str += widget.formattedItemLine(idx, hidden, item) } else { hidden = hidden + 1 } newList.Items = append(newList.Items, item) offset++ } for idx, item := range secondGroup { if widget.shouldShowItem(item) { str += widget.formattedItemLine(idx+offset, hidden, item) } else { hidden = hidden + 1 } newList.Items = append(newList.Items, item) } if idx, ok := newList.IndexByItem(selectedItem); ok { widget.Selected = idx } widget.SetList(newList) return str, hidden } func (widget *Widget) shouldShowItem(item *checklist.ChecklistItem) bool { if widget.showFilter != "" && !strings.Contains(strings.ToLower(item.Text), widget.showFilter) { return false } if !widget.settings.parseTags { return true } if len(item.Tags) == 0 { return widget.showTagPrefix == "" } for _, tag := range item.Tags { for _, hideTag := range widget.settings.hideTags { if widget.showTagPrefix == "" && tag == hideTag { return false } } if widget.showTagPrefix == "" || strings.HasPrefix(tag, widget.showTagPrefix) { return true } } return false } func (widget *Widget) RowColor(idx int, hidden int, checked bool) string { if widget.View.HasFocus() && (idx == widget.Selected) { foreground := widget.CommonSettings().Colors.HighlightedForeground if checked { foreground = widget.settings.Colors.Checked } return fmt.Sprintf( "%s:%s", foreground, widget.CommonSettings().Colors.HighlightedBackground, ) } if checked { return widget.settings.Colors.Checked } else { return widget.CommonSettings().RowColor(idx - hidden) } } func (widget *Widget) formattedItemLine(idx int, hidden int, currItem *checklist.ChecklistItem) string { rowColor := widget.RowColor(idx, hidden, currItem.Checked) todoDate := currItem.Date row := fmt.Sprintf( ` [%s]|%s| `, rowColor, currItem.CheckMark(), ) if widget.settings.parseDates && todoDate != nil { row += fmt.Sprintf( `[%s]%s `, widget.settings.dateColor, widget.getDateString(todoDate), ) } tagsPart := "" if len(currItem.Tags) > 0 { tagsPart = fmt.Sprintf( `[%s]%s[white]`, widget.settings.tagColor, currItem.TagString(), ) } textPart := fmt.Sprintf( `[%s]%s[white]`, rowColor, tview.Escape(currItem.Text), ) if widget.settings.parseTags && widget.settings.tagsAtEnd { row += textPart + " " + tagsPart } else if widget.settings.parseTags { row += tagsPart + textPart } else { row += textPart } return utils.HighlightableHelper(widget.View, row, idx-hidden, len(currItem.Text)) } func (widget *Widget) getDateString(date *time.Time) string { now := getNowDate() diff := int(date.Sub(now).Hours() / 24) if diff == 0 { return "today" } else if diff == 1 { return "tomorrow" } else if diff <= widget.settings.switchToInDaysIn { return fmt.Sprintf("in %d days", diff) } else { dateStr := "" y, m, d := date.Year(), date.Month(), date.Day() switch widget.settings.dateFormat { case "yyyy-mm-dd": dateStr = fmt.Sprintf("%d-%02d-%02d", y, m, d) case "yy-mm-dd": dateStr = fmt.Sprintf("%d-%02d-%02d", y-2000, m, d) case "dd-mm-yyyy": dateStr = fmt.Sprintf("%02d-%02d-%d", d, m, y) case "dd-mm-yy": dateStr = fmt.Sprintf("%02d-%02d-%d", d, m, y-2000) case "dd M yyyy": dateStr = fmt.Sprintf("%02d %s %d", d, date.Month().String()[:3], y) // date case "dd M yy": dateStr = fmt.Sprintf("%02d %s %d", d, date.Month().String()[:3], y-2000) // dateStr = "aaasdada" default: dateStr = fmt.Sprintf("%d-%02d-%02d", y, m, d) // dateStr = fmt.Sprintf("%d-%02d-%02d", y, m, d) } if widget.settings.hideYearIfCurrent && date.Year() == now.Year() { if widget.settings.dateFormat[:1] == "y" { dateStr = dateStr[strings.Index(dateStr, "-")+1:] } else if widget.settings.dateFormat[3:4] == "-" { dateStr = dateStr[:5] } else { parts := strings.Split(dateStr, " ") dateStr = parts[0] + " " + parts[1] } } return dateStr } } func getNowDate() time.Time { now := time.Now() now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Now().Location()) return now } ================================================ FILE: modules/todo/keyboard.go ================================================ package todo import ( "fmt" "strings" "github.com/gdamore/tcell/v2" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.NextTodo, "Select next item") widget.SetKeyboardChar("k", widget.PrevTodo, "Select previous item") widget.SetKeyboardChar(" ", widget.toggleChecked, "Toggle checkmark") widget.SetKeyboardChar("n", widget.newItem, "Create new item") widget.SetKeyboardChar("o", widget.openFile, "Open file") widget.SetKeyboardChar("#", widget.setTag, "Set tag(s) to show") widget.SetKeyboardChar("f", widget.setFilter, "Filter shown items") widget.SetKeyboardKey(tcell.KeyDown, widget.NextTodo, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.PrevTodo, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelected, "Delete item") widget.SetKeyboardKey(tcell.KeyCtrlJ, widget.demoteSelected, "Demote item") widget.SetKeyboardKey(tcell.KeyCtrlL, widget.makeSelectedLast, "Make item last") widget.SetKeyboardKey(tcell.KeyCtrlK, widget.promoteSelected, "Promote item") widget.SetKeyboardKey(tcell.KeyCtrlF, widget.makeSelectedFirst, "Make item first") widget.SetKeyboardKey(tcell.KeyEnter, widget.updateSelected, "Edit item") } func (widget *Widget) NextTodo() { newIndex := widget.Selected + 1 for newIndex < len(widget.list.Items) && !widget.shouldShowItem(widget.list.Items[newIndex]) { newIndex = newIndex + 1 } if newIndex < len(widget.list.Items) { widget.Selected = newIndex } widget.display() } func (widget *Widget) PrevTodo() { newIndex := widget.Selected - 1 for newIndex >= 0 && !widget.shouldShowItem(widget.list.Items[newIndex]) { newIndex = newIndex - 1 } if newIndex >= 0 { widget.Selected = newIndex } widget.display() } func (widget *Widget) deleteSelected() { if !widget.isItemSelected() { return } widget.list.Delete(widget.Selected) widget.SetItemCount(len(widget.list.Items)) widget.Prev() widget.persist() widget.display() } func (widget *Widget) demoteSelected() { if !widget.isItemSelected() { return } j := widget.Selected + 1 if j >= len(widget.list.Items) { j = 0 } widget.list.Swap(widget.Selected, j) widget.Selected = j widget.persist() widget.display() } func (widget *Widget) makeSelectedLast() { if !widget.isItemSelected() { return } j := widget.Selected + 1 if j >= len(widget.list.Items) { return } for j < len(widget.list.Items) { widget.list.Swap(widget.Selected, j) widget.Selected = j j = j + 1 } if widget.settings.parseDates { widget.Selected = widget.placeItemBasedOnDate(widget.Selected) } widget.persist() widget.display() } func (widget *Widget) openFile() { confDir, _ := cfg.WtfConfigDir() utils.OpenFile(fmt.Sprintf("%s/%s", confDir, widget.filePath)) } func (widget *Widget) setTag() { if !widget.settings.parseTags { return } widget.processFormInput("Tag prefix:", "", func(filter string) { widget.showTagPrefix = filter }) } func (widget *Widget) setFilter() { widget.processFormInput("Filter:", "", func(filter string) { widget.showFilter = strings.ToLower(filter) }) } func (widget *Widget) promoteSelected() { if !widget.isItemSelected() { return } k := widget.Selected - 1 if k < 0 { k = len(widget.list.Items) - 1 } widget.list.Swap(widget.Selected, k) widget.Selected = k widget.persist() widget.display() } func (widget *Widget) makeSelectedFirst() { if !widget.isItemSelected() { return } j := widget.Selected - 1 if j < 0 { return } for j >= 0 { widget.list.Swap(widget.Selected, j) widget.Selected = j j = j - 1 } if widget.settings.parseDates { widget.Selected = widget.placeItemBasedOnDate(widget.Selected) } widget.persist() widget.display() } func (widget *Widget) toggleChecked() { selectedItem := widget.SelectedItem() if selectedItem == nil { return } selectedItem.Toggle() if !selectedItem.Checked { widget.Selected = widget.placeItemBasedOnDate(widget.Selected) } widget.persist() widget.display() } func (widget *Widget) unselect() { if widget.showFilter != "" { widget.showFilter = "" } else { widget.Selected = -1 } widget.display() } ================================================ FILE: modules/todo/settings.go ================================================ package todo import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Todo" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common filePath string checked string unchecked string newPos string checkedPos string parseDates bool dateColor string switchToInDaysIn int undatedAsDays int hideYearIfCurrent bool dateFormat string parseTags bool tagColor string tagsAtEnd bool hideTags []interface{} hiddenNumInTitle bool } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { common := cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig) settings := Settings{ Common: common, filePath: ymlConfig.UString("filename"), checked: ymlConfig.UString("checkedIcon", common.Checkbox.Checked), unchecked: ymlConfig.UString("uncheckedIcon", common.Checkbox.Unchecked), newPos: ymlConfig.UString("newPos", "first"), checkedPos: ymlConfig.UString("checkedPos", "last"), parseDates: ymlConfig.UBool("dates.enabled", true), dateColor: ymlConfig.UString("colors.date", "chartreuse"), switchToInDaysIn: ymlConfig.UInt("dates.switchToInDaysIn", 7), undatedAsDays: ymlConfig.UInt("dates.undatedAsDays", 7), hideYearIfCurrent: ymlConfig.UBool("dates.hideYearIfCurrent", true), dateFormat: ymlConfig.UString("dates.format", "yyyy-mm-dd"), parseTags: ymlConfig.UBool("tags.enabled", true), tagColor: ymlConfig.UString("colors.tags", "khaki"), tagsAtEnd: ymlConfig.UString("tags.pos", "end") == "end", hideTags: ymlConfig.UList("tags.hide"), hiddenNumInTitle: ymlConfig.UBool("tags.hiddenInTitle", true), } switch settings.newPos { case "first", "last": default: settings.newPos = "last" } switch settings.checkedPos { case "first", "last", "none": default: settings.checkedPos = "last" } switch settings.dateFormat { case "yyyy-mm-dd", "yy-mm-dd", "dd-mm-yyyy", "dd-mm-yy", "dd M yy", "dd M yyyy": default: settings.dateFormat = "yyyy-mm-dd" } return &settings } ================================================ FILE: modules/todo/widget.go ================================================ package todo import ( "fmt" "os" "regexp" "strconv" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/checklist" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" "github.com/wtfutil/wtf/wtf" "gopkg.in/yaml.v2" ) const ( modalHeight = 7 modalWidth = 80 offscreen = -1000 ) // A Widget represents a Todo widget type Widget struct { filePath string list checklist.Checklist pages *tview.Pages settings *Settings showTagPrefix string showFilter string tviewApp *tview.Application Error string view.ScrollableWidget // redrawChan chan bool } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), tviewApp: tviewApp, settings: settings, filePath: settings.filePath, showTagPrefix: "", list: checklist.NewChecklist(settings.Checkbox.Checked, settings.Checkbox.Unchecked), pages: pages, // redrawChan: redrawChan, } widget.init() widget.initializeKeyboardControls() widget.View.SetRegions(true) widget.View.SetScrollable(true) widget.SetRenderFunction(widget.display) return &widget } /* -------------------- Exported Functions -------------------- */ // SelectedItem returns the currently-selected checklist item or nil if no item is selected func (widget *Widget) SelectedItem() *checklist.ChecklistItem { var selectedItem *checklist.ChecklistItem if widget.isItemSelected() { selectedItem = widget.list.Items[widget.Selected] } return selectedItem } // Refresh updates the data for this widget and displays it onscreen func (widget *Widget) Refresh() { widget.Error = "" err := widget.load() if err != nil { widget.Error = err.Error() } widget.display() } func (widget *Widget) SetList(list checklist.Checklist) { widget.list = list } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) init() { _, err := cfg.CreateFile(widget.filePath) if err != nil { return } } // isItemSelected returns whether any item of the todo is selected or not func (widget *Widget) isItemSelected() bool { return widget.Selected >= 0 && widget.Selected < len(widget.list.Items) } // Loads the todo list from3 Yaml file func (widget *Widget) load() error { confDir, _ := cfg.WtfConfigDir() filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath) fileData, err := utils.ReadFileBytes(filePath) if err != nil { return err } err = yaml.Unmarshal(fileData, &widget.list) if err != nil { return err } // do initial sort based on dates to make sure everything is correct if widget.settings.parseDates { i := 0 for i < widget.list.Len() { for { newIndex := widget.placeItemBasedOnDate(i) if newIndex == i { break } } i += 1 } } widget.SetItemCount(len(widget.list.Items)) widget.setItemChecks() return nil } func (widget *Widget) newItem() { widget.processFormInput("New Todo:", "", func(t string) { text, date, tags := widget.getTextComponents(t) widget.list.Add(false, date, tags, text, widget.settings.newPos) widget.SetItemCount(len(widget.list.Items)) if widget.settings.parseDates { if widget.settings.newPos == "first" { widget.placeItemBasedOnDate(0) } else { widget.placeItemBasedOnDate(widget.list.Len() - 1) } } widget.persist() }) } func (widget *Widget) getTextComponents(text string) (string, *time.Time, []string) { var date *time.Time = nil if widget.settings.parseDates { text, date = widget.getTextAndDate(text) } tags := make([]string, 0) if widget.settings.parseTags { text, tags = getTodoTags(text) } text = strings.TrimSpace(text) return text, date, tags } func getTodoTags(text string) (string, []string) { tags := make([]string, 0) r, _ := regexp.Compile(`(?i)(^|\s)#[a-z0-9]+`) matches := r.FindAllString(text, -1) for _, tag := range matches { tag = strings.TrimSpace(tag) suffix := " " if strings.HasSuffix(text, tag) { suffix = "" } text = strings.Replace(text, tag+suffix, "", 1) tags = append(tags, tag[1:]) } return text, tags } type PatternDuration struct { pattern string d int m int y int } func (widget *Widget) getTextAndDate(text string) (string, *time.Time) { now := time.Now() textLower := strings.ToLower(text) // check for "in X days/weeks/months/years" pattern r, _ := regexp.Compile("(?i)^in [0-9]+ (day|week|month|year)(s|)") match := r.FindString(text) if len(match) > 0 && len(text) > len(match) { parts := strings.Split(text, " ") n, _ := strconv.Atoi(parts[1]) unit := parts[2][:1] var target time.Time switch unit { case "d": target = now.AddDate(0, 0, n) case "w": target = now.AddDate(0, 0, 7*n) case "m": target = now.AddDate(0, n, 0) default: target = now.AddDate(n, 0, 0) } return text[len(match):], &target } // check for "today / tomorrow / next X" patterns := [...]PatternDuration{ {pattern: "today", d: 0, m: 0, y: 0}, {pattern: "tomorrow", d: 1, m: 0, y: 0}, {pattern: "next week", d: 7, m: 0, y: 0}, {pattern: "next month", d: 0, m: 1, y: 0}, {pattern: "next year", d: 0, m: 0, y: 1}, } for _, pd := range patterns { if strings.HasPrefix(textLower, pd.pattern) && len(text) > len(pd.pattern) { date := now.AddDate(pd.y, pd.m, pd.d) return text[len(pd.pattern):], &date } } // check for "next X" where X is name of a day (monday, etc) if strings.HasPrefix(textLower, "next") { parts := strings.Split(textLower, " ") if parts[0] == "next" && len(parts) > 2 { for i, d := range []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} { if strings.ToLower(parts[1]) == d { date := now.AddDate(0, 0, int(now.Weekday())+7-i) return text[len(d)+5:], &date } } } } // check for YYYY-MM-DD prefix if len(text) > 10 { date, err := time.Parse("2006-01-02", text[:10]) if err == nil { return text[10:], &date } } // check for MM-DD prefix if len(text) > 5 { date, err := time.Parse("2006-01-02", strconv.FormatInt(int64(now.Year()), 10)+"-"+text[:5]) if err == nil { return text[5:], &date } } return text, nil } // persist writes the todo list to Yaml file func (widget *Widget) persist() { confDir, _ := cfg.WtfConfigDir() filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath) fileData, _ := yaml.Marshal(&widget.list) err := os.WriteFile(filePath, fileData, 0644) if err != nil { panic(err) } } // setItemChecks rolls through the checklist and ensures that all checklist // items have the correct checked/unchecked icon per the user's preferences func (widget *Widget) setItemChecks() { for _, item := range widget.list.Items { item.CheckedIcon = widget.settings.checked item.UncheckedIcon = widget.settings.unchecked } } // updateSelected sets the text of the currently-selected item to the provided text func (widget *Widget) updateSelected() { if !widget.isItemSelected() { return } widget.processFormInput("Edit:", widget.SelectedItem().EditText(), func(t string) { text, date, tags := widget.getTextComponents(t) widget.updateSelectedItem(text, date, tags) if widget.settings.parseDates { widget.Selected = widget.placeItemBasedOnDate(widget.Selected) } widget.persist() }) } // processFormInput is a helper function that creates a form and calls onSave on the received input func (widget *Widget) processFormInput(prompt string, initValue string, onSave func(string)) { form := widget.modalForm(prompt, initValue) saveFctn := func() { onSave(form.GetFormItem(0).(*tview.InputField).GetText()) widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() } widget.addButtons(form, saveFctn) widget.modalFocus(form) // Tell the app to force redraw the screen widget.RedrawChan <- true } // updateSelectedItem update the text of the selected item. func (widget *Widget) updateSelectedItem(text string, date *time.Time, tags []string) { selectedItem := widget.SelectedItem() if selectedItem == nil { return } selectedItem.Text = text selectedItem.Date = date selectedItem.Tags = tags } func (widget *Widget) placeItemBasedOnDate(index int) int { // potentially move todo up for index > 0 && widget.todoDateIsEarlier(index, index-1) { widget.list.Swap(index, index-1) index -= 1 } // potentially move todo down for index < widget.list.Len()-1 && widget.todoDateIsEarlier(index+1, index) { widget.list.Swap(index, index+1) index += 1 } return index } func (widget *Widget) todoDateIsEarlier(i, j int) bool { if widget.list.Items[i].Date == nil && widget.list.Items[j].Date == nil { return false } defaultVal := getNowDate().AddDate(0, 0, widget.settings.undatedAsDays) if widget.list.Items[i].Date == nil { return defaultVal.Before(*widget.list.Items[j].Date) } else if widget.list.Items[j].Date == nil { return widget.list.Items[i].Date.Before(defaultVal) } else { return widget.list.Items[i].Date.Before(*widget.list.Items[j].Date) } } /* -------------------- Modal Form -------------------- */ func (widget *Widget) addButtons(form *tview.Form, saveFctn func()) { widget.addSaveButton(form, saveFctn) widget.addCancelButton(form) } func (widget *Widget) addCancelButton(form *tview.Form) { cancelFn := func() { widget.pages.RemovePage("modal") widget.tviewApp.SetFocus(widget.View) widget.display() } form.AddButton("Cancel", cancelFn) form.SetCancelFunc(cancelFn) } func (widget *Widget) addSaveButton(form *tview.Form, fctn func()) { form.AddButton("Save", fctn) } func (widget *Widget) modalFocus(form *tview.Form) { frame := widget.modalFrame(form) widget.pages.AddPage("modal", frame, false, true) widget.tviewApp.SetFocus(frame) // Tell the app to force redraw the screen widget.RedrawChan <- true } func (widget *Widget) modalForm(lbl, text string) *tview.Form { form := tview.NewForm() form.SetFieldBackgroundColor(wtf.ColorFor(widget.settings.Colors.Background)) form.SetButtonsAlign(tview.AlignCenter) form.SetButtonTextColor(wtf.ColorFor(widget.settings.Colors.Text)) form.AddInputField(lbl, text, 60, nil, nil) return form } func (widget *Widget) modalFrame(form *tview.Form) *tview.Frame { frame := tview.NewFrame(form) frame.SetBorders(0, 0, 0, 0, 0, 0) frame.SetRect(offscreen, offscreen, modalWidth, modalHeight) frame.SetBorder(true) frame.SetBorders(1, 1, 0, 0, 1, 1) drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { w, h := screen.Size() frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height) return x, y, width, height } frame.SetDrawFunc(drawFunc) return frame } ================================================ FILE: modules/todo_plus/backend/backend.go ================================================ package backend import ( "github.com/olebedev/config" ) type Backend interface { Title() string Setup(*config.Config) BuildProjects() []*Project GetProject(string) *Project LoadTasks(string) ([]Task, error) CloseTask(*Task) error DeleteTask(*Task) error Sources() []string } ================================================ FILE: modules/todo_plus/backend/project.go ================================================ package backend type Task struct { ID string Completed bool Name string } type Project struct { ID string Name string Index int Tasks []Task Err error backend Backend } func (proj *Project) IsLast() bool { return proj.Index >= len(proj.Tasks)-1 } func (proj *Project) loadTasks() { Tasks, err := proj.backend.LoadTasks(proj.ID) proj.Err = err proj.Tasks = Tasks } func (proj *Project) LongestLine() int { maxLen := 0 for _, task := range proj.Tasks { if len(task.Name) > maxLen { maxLen = len(task.Name) } } return maxLen } func (proj *Project) currentTask() *Task { if proj.Index < 0 { return nil } return &proj.Tasks[proj.Index] } func (proj *Project) CloseSelectedTask() { currTask := proj.currentTask() if currTask != nil { _ = proj.backend.CloseTask(currTask) proj.loadTasks() } } func (proj *Project) DeleteSelectedTask() { currTask := proj.currentTask() if currTask != nil { _ = proj.backend.DeleteTask(currTask) proj.loadTasks() } } ================================================ FILE: modules/todo_plus/backend/todoist.go ================================================ package backend import ( "fmt" "github.com/gopherlibs/todoist/api" "github.com/olebedev/config" ) type Todoist struct { client *api.Client projects []interface{} } func (todo *Todoist) Title() string { return "Todoist" } func (todo *Todoist) Setup(config *config.Config) { todo.client = api.New(config.UString("apiKey")) todo.projects = config.UList("projects") } func (todo *Todoist) BuildProjects() []*Project { projects := []*Project{} for _, id := range todo.projects { i := fmt.Sprintf("%v", id) proj := todo.GetProject(i) projects = append(projects, proj) } return projects } func (todo *Todoist) GetProject(id string) *Project { // Todoist seems to experience a lot of network issues on their side // If we can't connect, handle it with an empty project until we can proj := &Project{ Index: -1, backend: todo, } proj.ID = id proj.Name = "Error" p, err := todo.client.Project(id) if err != nil { return proj } proj.Name = p.Name tasks, err := todo.LoadTasks(proj.ID) proj.Err = err proj.Tasks = tasks return proj } func toTask(task api.Task) Task { return Task{ ID: task.ID, Completed: task.Checked, Name: task.Content, } } func (todo *Todoist) LoadTasks(id string) ([]Task, error) { tasks, err := todo.client.Tasks(id) if err != nil { return nil, err } var finalTasks []Task for _, item := range tasks.Results { finalTasks = append(finalTasks, toTask(item)) } return finalTasks, nil } func (todo *Todoist) CloseTask(task *Task) error { if task != nil { _, err := todo.client.TaskClose(task.ID) return err } return nil } func (todo *Todoist) DeleteTask(task *Task) error { if task != nil { _, err := todo.client.TaskDelete(task.ID) return err } return nil } func (todo *Todoist) Sources() []string { var result []string for _, id := range todo.projects { i := fmt.Sprintf("%v", id) result = append(result, i) } return result } ================================================ FILE: modules/todo_plus/backend/trello.go ================================================ package backend import ( "fmt" "log" "github.com/adlio/trello" "github.com/olebedev/config" ) type Trello struct { username string boardName string client *trello.Client board string projects []interface{} } func (todo *Trello) Title() string { return "Trello" } func (todo *Trello) Setup(config *config.Config) { todo.username = config.UString("username") todo.boardName = config.UString("board") todo.client = trello.NewClient( config.UString("apiKey"), config.UString("accessToken"), ) board, err := getBoardID(todo.client, todo.username, todo.boardName) if err != nil { log.Fatal(err) } todo.board = board todo.projects = config.UList("lists") } func getBoardID(client *trello.Client, username, boardName string) (string, error) { member, err := client.GetMember(username, trello.Defaults()) if err != nil { return "", err } boards, err := member.GetBoards(trello.Defaults()) if err != nil { return "", err } for _, board := range boards { if board.Name == boardName { return board.ID, nil } } return "", fmt.Errorf("could not find board with name %s", boardName) } func getListId(client *trello.Client, boardID string, listName string) (string, error) { board, err := client.GetBoard(boardID, trello.Defaults()) if err != nil { return "", err } boardLists, err := board.GetLists(trello.Defaults()) if err != nil { return "", err } for _, list := range boardLists { if list.Name == listName { return list.ID, nil } } return "", nil } func getCardsOnList(client *trello.Client, listID string) ([]*trello.Card, error) { list, err := client.GetList(listID, trello.Defaults()) if err != nil { return nil, err } cards, err := list.GetCards(trello.Defaults()) if err != nil { return nil, err } return cards, nil } func (todo *Trello) BuildProjects() []*Project { projects := []*Project{} for _, id := range todo.projects { proj := todo.GetProject(id.(string)) projects = append(projects, proj) } return projects } func (todo *Trello) GetProject(id string) *Project { proj := &Project{ Index: -1, backend: todo, } listId, err := getListId(todo.client, todo.board, id) if err != nil { proj.Err = err return proj } proj.ID = listId proj.Name = id tasks, err := todo.LoadTasks(listId) proj.Err = err proj.Tasks = tasks return proj } func fromTrello(task *trello.Card) Task { return Task{ ID: task.ID, Completed: task.Closed, Name: task.Name, } } func (todo *Trello) LoadTasks(id string) ([]Task, error) { tasks, err := getCardsOnList(todo.client, id) if err != nil { return nil, err } var finalTasks []Task for _, item := range tasks { finalTasks = append(finalTasks, fromTrello(item)) } return finalTasks, nil } func (todo *Trello) CloseTask(task *Task) error { args := trello.Arguments{ "closed": "true", } if task != nil { // Card has an internal client rep which we can't access // Just force a lookup internal, err := todo.client.GetCard(task.ID, trello.Arguments{}) if err != nil { return err } return internal.Update(args) } return nil } func (todo *Trello) DeleteTask(_ *Task) error { return nil } func (todo *Trello) Sources() []string { var result []string for _, id := range todo.projects { result = append(result, id.(string)) } return result } ================================================ FILE: modules/todo_plus/display.go ================================================ package todo_plus import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) content() (string, string, bool) { proj := widget.CurrentProject() if proj == nil { return widget.CommonSettings().Title, "", false } if proj.Err != nil { return widget.CommonSettings().Title, proj.Err.Error(), true } title := fmt.Sprintf( "[%s]%s[white]", widget.settings.Colors.Title, proj.Name) str := "" for idx, item := range proj.Tasks { row := fmt.Sprintf( `[%s]| | %s[%s]`, widget.RowColor(idx), tview.Escape(item.Name), widget.RowColor(idx), ) str += utils.HighlightableHelper(widget.View, row, idx, len(item.Name)) } return title, str, false } func (widget *Widget) display() { widget.Redraw(widget.content) } ================================================ FILE: modules/todo_plus/keyboard.go ================================================ package todo_plus import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("d", widget.Delete, "Delete item") widget.SetKeyboardChar("j", widget.Prev, "Select previous item") widget.SetKeyboardChar("k", widget.Next, "Select next item") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous project") widget.SetKeyboardChar("c", widget.Close, "Close item") widget.SetKeyboardChar("l", widget.NextSource, "Select next project") widget.SetKeyboardChar("u", widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous project") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next project") } ================================================ FILE: modules/todo_plus/settings.go ================================================ package todo_plus import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultTitle = "Todo" defaultFocusable = true ) type Settings struct { *cfg.Common backendType string backendSettings *config.Config } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { backend, _ := ymlConfig.Get("backendSettings") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), backendType: ymlConfig.UString("backendType"), backendSettings: backend, } return &settings } func FromTodoist(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { apiKey := ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_TODOIST_TOKEN"))) cfg.ModuleSecret(name, globalConfig, &apiKey).Load() projects := ymlConfig.UList("projects") backend, _ := config.ParseYaml("apiKey: " + apiKey) _ = backend.Set(".projects", projects) settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), backendType: "todoist", backendSettings: backend, } return &settings } func FromTrello(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { accessToken := ymlConfig.UString("accessToken", ymlConfig.UString("apikey", os.Getenv("WTF_TRELLO_ACCESS_TOKEN"))) apiKey := ymlConfig.UString("apiKey", os.Getenv("WTF_TRELLO_API_KEY")) cfg.ModuleSecret(name, globalConfig, &apiKey).Load() board := ymlConfig.UString("board") username := ymlConfig.UString("username") var lists []interface{} list, err := ymlConfig.String("list") if err == nil { lists = append(lists, list) } else { lists = ymlConfig.UList("list") } backend, _ := config.ParseYaml("apiKey: " + apiKey) _ = backend.Set(".accessToken", accessToken) _ = backend.Set(".board", board) _ = backend.Set(".username", username) _ = backend.Set(".lists", lists) settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), backendType: "trello", backendSettings: backend, } return &settings } ================================================ FILE: modules/todo_plus/widget.go ================================================ package todo_plus import ( "log" "github.com/rivo/tview" "github.com/wtfutil/wtf/modules/todo_plus/backend" "github.com/wtfutil/wtf/view" ) // A Widget represents a Todoist widget type Widget struct { view.MultiSourceWidget view.ScrollableWidget projects []*backend.Project settings *Settings backend backend.Backend } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "project", "projects"), ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.backend = getBackend(settings.backendType) widget.backend.Setup(settings.backendSettings) widget.CommonSettings().Title = widget.backend.Title() widget.SetRenderFunction(widget.display) widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.display) return &widget } func getBackend(backendType string) backend.Backend { switch backendType { case "trello": backend := &backend.Trello{} return backend case "todoist": backend := &backend.Todoist{} return backend default: log.Fatal(backendType + " is not a supported backend") return nil } } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) CurrentProject() *backend.Project { return widget.ProjectAt(widget.Idx) } func (widget *Widget) ProjectAt(idx int) *backend.Project { if len(widget.projects) == 0 { return nil } return widget.projects[idx] } func (widget *Widget) Refresh() { if widget.Disabled() { return } widget.projects = widget.backend.BuildProjects() widget.Sources = widget.backend.Sources() widget.SetItemCount(len(widget.CurrentProject().Tasks)) widget.display() } func (widget *Widget) NextSource() { widget.MultiSourceWidget.NextSource() widget.Selected = widget.CurrentProject().Index widget.SetItemCount(len(widget.CurrentProject().Tasks)) widget.RenderFunction() } func (widget *Widget) PrevSource() { widget.MultiSourceWidget.PrevSource() widget.Selected = widget.CurrentProject().Index widget.SetItemCount(len(widget.CurrentProject().Tasks)) widget.RenderFunction() } func (widget *Widget) Prev() { widget.ScrollableWidget.Prev() widget.CurrentProject().Index = widget.Selected } func (widget *Widget) Next() { widget.ScrollableWidget.Next() widget.CurrentProject().Index = widget.Selected } func (widget *Widget) Unselect() { widget.ScrollableWidget.Unselect() widget.CurrentProject().Index = -1 widget.RenderFunction() } /* -------------------- Keyboard Movement -------------------- */ // Close closes the currently-selected task in the currently-selected project func (w *Widget) Close() { w.CurrentProject().CloseSelectedTask() w.SetItemCount(len(w.CurrentProject().Tasks)) if w.CurrentProject().IsLast() { w.Prev() return } w.CurrentProject().Index = w.Selected w.RenderFunction() } // Delete deletes the currently-selected task in the currently-selected project func (w *Widget) Delete() { w.CurrentProject().DeleteSelectedTask() w.SetItemCount(len(w.CurrentProject().Tasks)) if w.CurrentProject().IsLast() { w.Prev() } w.CurrentProject().Index = w.Selected w.RenderFunction() } ================================================ FILE: modules/transmission/display.go ================================================ package transmission import ( "fmt" "strings" "github.com/hekmon/transmissionrpc/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { widget.mu.Lock() defer widget.mu.Unlock() title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } if len(widget.torrents) == 0 { return title, "No data", false } str := "" for idx, torrent := range widget.torrents { torrName := *torrent.Name row := fmt.Sprintf( "[%s] %s %s %s%s[white]", widget.RowColor(idx), widget.torrentPercentDone(torrent), widget.torrentSeedRatio(torrent), widget.torrentState(torrent), tview.Escape(widget.prettyTorrentName(torrName)), ) str += utils.HighlightableHelper(widget.View, row, idx, len(torrName)) } return title, str, false } func (widget *Widget) prettyTorrentName(name string) string { str := strings.ReplaceAll(name, "[", "(") str = strings.ReplaceAll(str, "]", ")") return str } func (widget *Widget) torrentPercentDone(torrent transmissionrpc.Torrent) string { pctDone := *torrent.PercentDone str := fmt.Sprintf("%3d%%↓", int(pctDone*100)) switch pctDone { case 0.0: str = "[gray::b]" + str case 1.0: str = "[green::b]" + str default: str = "[lightblue::b]" + str } return str + "[white]" } func (widget *Widget) torrentSeedRatio(torrent transmissionrpc.Torrent) string { seedRatio := *torrent.UploadRatio if seedRatio < 0 { seedRatio = 0 } return fmt.Sprintf("[green]%3d%%↑", int(seedRatio*100)) } func (widget *Widget) torrentState(torrent transmissionrpc.Torrent) string { str := "" switch *torrent.Status { case transmissionrpc.TorrentStatusStopped: str += "[gray]" case transmissionrpc.TorrentStatusDownload: str += "[lightblue]" case transmissionrpc.TorrentStatusSeed: str += "[green]" } return str } ================================================ FILE: modules/transmission/keyboard.go ================================================ package transmission import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(nil) widget.SetKeyboardChar("j", widget.Prev, "Select previous item") widget.SetKeyboardChar("k", widget.Next, "Select next item") widget.SetKeyboardChar("u", widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedTorrent, "Delete the selected torrent") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyEnter, widget.pauseUnpauseTorrent, "Pause/unpause torrent") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") } ================================================ FILE: modules/transmission/settings.go ================================================ package transmission import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Transmission" ) // Settings defines the configuration properties for this module type Settings struct { *cfg.Common host string `help:"The address of the machine the Transmission daemon is running on"` https bool `help:"Whether or not to connect to the host via HTTPS"` password string `help:"The password for the Transmission user"` port uint16 `help:"The port to connect to the Transmission daemon on"` url string `help:"The RPC URI that the daemon is accessible at"` username string `help:"The username of the Transmission user"` hideComplete bool `help:"Hide the torrents that are finished downloading"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), host: ymlConfig.UString("host"), https: ymlConfig.UBool("https", false), password: ymlConfig.UString("password"), port: uint16(ymlConfig.UInt("port", 9091)), url: ymlConfig.UString("url", ""), username: ymlConfig.UString("username", ""), hideComplete: ymlConfig.UBool("hideComplete", false), } return &settings } ================================================ FILE: modules/transmission/widget.go ================================================ package transmission import ( "context" "errors" "sync" "github.com/hekmon/transmissionrpc/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget is the container for transmission data type Widget struct { view.ScrollableWidget client *transmissionrpc.Client settings *Settings mu sync.Mutex torrents []transmissionrpc.Torrent err error } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.display) widget.initializeKeyboardControls() go buildClient(&widget) return &widget } /* -------------------- Exported Functions -------------------- */ // Fetch retrieves torrent data from the Transmission daemon func (widget *Widget) Fetch() ([]transmissionrpc.Torrent, error) { if widget.client == nil { return nil, errors.New("client was not initialized") } torrents, err := widget.client.TorrentGetAll(context.Background()) if err != nil { return nil, err } out := make([]transmissionrpc.Torrent, 0) for _, torrent := range torrents { if widget.settings.hideComplete { if *torrent.PercentDone == 1.0 { continue } } out = append(out, torrent) } return out, nil } // Refresh updates the data for this widget and displays it onscreen func (widget *Widget) Refresh() { torrents, err := widget.Fetch() count := 0 if err == nil { count = len(torrents) } widget.mu.Lock() widget.err = err widget.torrents = torrents widget.SetItemCount(count) widget.mu.Unlock() widget.display() } // Next selects the next item in the list func (widget *Widget) Next() { widget.ScrollableWidget.Next() } // Prev selects the previous item in the list func (widget *Widget) Prev() { widget.ScrollableWidget.Prev() } // Unselect clears the selection of list items func (widget *Widget) Unselect() { widget.ScrollableWidget.Unselect() widget.RenderFunction() } /* -------------------- Unexported Functions -------------------- */ // buildClient creates a persisten transmission client func buildClient(widget *Widget) { widget.mu.Lock() defer widget.mu.Unlock() client, err := transmissionrpc.New(widget.settings.host, widget.settings.username, widget.settings.password, &transmissionrpc.AdvancedConfig{ Port: widget.settings.port, RPCURI: widget.settings.url, HTTPS: widget.settings.https, }) if err != nil { client = nil } widget.client = client } func (widget *Widget) currentTorrent() *transmissionrpc.Torrent { if len(widget.torrents) == 0 { return nil } if len(widget.torrents) <= widget.Selected { return nil } return &widget.torrents[widget.Selected] } // deleteSelected removes the selected torrent from transmission // This action is non-destructive, it does not delete the files on the host func (widget *Widget) deleteSelectedTorrent() { if widget.client == nil { return } currTorrent := widget.currentTorrent() if currTorrent == nil { return } ids := []int64{*currTorrent.ID} removePayload := transmissionrpc.TorrentRemovePayload{ IDs: ids, DeleteLocalData: false, } err := widget.client.TorrentRemove(context.Background(), removePayload) if err != nil { return } widget.display() } // pauseUnpauseTorrent either pauses or unpauses the downloading and seeding of the selected torrent func (widget *Widget) pauseUnpauseTorrent() { if widget.client == nil { return } currTorrent := widget.currentTorrent() if currTorrent == nil { return } ids := []int64{*currTorrent.ID} var err error if *currTorrent.Status == transmissionrpc.TorrentStatusStopped { err = widget.client.TorrentStartIDs(context.Background(), ids) } else { err = widget.client.TorrentStopIDs(context.Background(), ids) } if err != nil { return } widget.display() } ================================================ FILE: modules/travisci/client.go ================================================ package travisci import ( "fmt" "net/http" "net/url" "github.com/wtfutil/wtf/utils" ) var TRAVIS_HOSTS = map[bool]string{ false: "travis-ci.org", true: "travis-ci.com", } func BuildsFor(settings *Settings) (*Builds, error) { builds := &Builds{} travisAPIURL.Host = "api." + TRAVIS_HOSTS[settings.pro] if settings.baseURL != "" { travisAPIURL.Host = settings.baseURL } resp, err := travisBuildRequest(settings) if err != nil { return builds, err } err = utils.ParseJSON(&builds, resp.Body) if err != nil { return builds, err } return builds, nil } /* -------------------- Unexported Functions -------------------- */ var ( travisAPIURL = &url.URL{Scheme: "https", Path: "/"} ) func travisBuildRequest(settings *Settings) (*http.Response, error) { path := "builds" if settings.baseURL != "" { travisAPIURL.Path = "/api/" } params := url.Values{} params.Add("limit", settings.limit) params.Add("sort_by", settings.sort_by) requestUrl := travisAPIURL.ResolveReference(&url.URL{Path: path, RawQuery: params.Encode()}) req, err := http.NewRequest("GET", requestUrl.String(), http.NoBody) if err != nil { return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Travis-API-Version", "3") bearer := fmt.Sprintf("token %s", settings.apiKey) req.Header.Add("Authorization", bearer) httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s", resp.Status) } return resp, nil } ================================================ FILE: modules/travisci/keyboard.go ================================================ package travisci import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openBuild, "Open item in browser") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyEnter, widget.openBuild, "Open item in browser") } ================================================ FILE: modules/travisci/settings.go ================================================ package travisci import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "TravisCI" ) type Settings struct { *cfg.Common apiKey string baseURL string `help:"Your TravisCI Enterprise API URL." optional:"true"` compact bool limit string pro bool sort_by string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_TRAVIS_API_TOKEN"))), baseURL: ymlConfig.UString("baseURL", ymlConfig.UString("baseURL", os.Getenv("WTF_TRAVIS_BASE_URL"))), pro: ymlConfig.UBool("pro", false), compact: ymlConfig.UBool("compact", false), limit: ymlConfig.UString("limit", "10"), sort_by: ymlConfig.UString("sort_by", "id:desc"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service(settings.baseURL).Load() return &settings } ================================================ FILE: modules/travisci/travis.go ================================================ package travisci type Builds struct { Builds []Build `json:"builds"` } type Build struct { ID int `json:"id"` CreatedBy Owner `json:"created_by"` Branch Branch `json:"branch"` Number string `json:"number"` Repository Repository `json:"repository"` Commit Commit `json:"commit"` State string `json:"state"` } type Owner struct { Login string `json:"login"` } type Branch struct { Name string `json:"name"` } type Repository struct { Name string `json:"name"` Slug string `json:"slug"` } type Commit struct { Message string `json:"message"` } ================================================ FILE: modules/travisci/widget.go ================================================ package travisci import ( "fmt" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget builds *Builds settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } builds, err := BuildsFor(widget.settings) if err != nil { widget.err = err widget.builds = nil widget.SetItemCount(0) } else { widget.err = nil widget.builds = builds widget.SetItemCount(len(builds.Builds)) } widget.Render() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s - Builds", widget.CommonSettings().Title) var str string if widget.err != nil { str = widget.err.Error() } else { var rowFormat = "[%s] [%s] %s-%s (%s) [%s]%s - [blue]%s" if !widget.settings.compact { rowFormat += "\n" } for idx, build := range widget.builds.Builds { row := fmt.Sprintf( rowFormat, widget.RowColor(idx), buildColor(build), build.Repository.Name, build.Number, build.Branch.Name, widget.RowColor(idx), strings.Split(build.Commit.Message, "\n")[0], build.CreatedBy.Login, ) str += utils.HighlightableHelper(widget.View, row, idx, len(build.Branch.Name)) } } return title, str, false } func buildColor(build Build) string { switch build.State { case "broken": return "red" case "failed": return "red" case "failing": return "red" case "pending": return "yellow" case "started": return "yellow" case "fixed": return "green" case "passed": return "green" default: return "white" } } func (widget *Widget) openBuild() { sel := widget.GetSelected() if sel >= 0 && widget.builds != nil && sel < len(widget.builds.Builds) { build := &widget.builds.Builds[sel] travisHost := TRAVIS_HOSTS[widget.settings.pro] utils.OpenFile(fmt.Sprintf("https://%s/%s/%s/%d", travisHost, build.Repository.Slug, "builds", build.ID)) } } ================================================ FILE: modules/twitch/client.go ================================================ package twitch import ( helix "github.com/nicklaw5/helix/v2" ) type Twitch struct { client *helix.Client UserRefreshToken string UserID string Streams string } type ClientOpts struct { ClientID string ClientSecret string AppAccessToken string UserAccessToken string UserRefreshToken string RedirectURI string Streams string UserID string } func NewClient(opts *ClientOpts) (*Twitch, error) { client, err := helix.NewClient(&helix.Options{ ClientID: opts.ClientID, ClientSecret: opts.ClientSecret, AppAccessToken: opts.AppAccessToken, RedirectURI: opts.RedirectURI, }) if err != nil { return nil, err } // Only set user access token if user has selected followed streams. Otherwise it will supercede app access token. // https://github.com/nicklaw5/helix/pull/131 Seems like it should be fixed in this PR of the helix API, but it hasnt been merged for a long time. if opts.Streams == "followed" { client.SetUserAccessToken(opts.UserAccessToken) } t := &Twitch{client: client} t.UserRefreshToken = opts.UserRefreshToken t.UserID = opts.UserID t.Streams = opts.Streams if opts.AppAccessToken == "" && opts.ClientSecret != "" { if err := t.RefreshOAuthToken(); err != nil { return nil, err } } return t, nil } func (t *Twitch) RefreshOAuthToken() error { switch t.Streams { case "followed": userResp, err := t.client.RefreshUserAccessToken(t.UserRefreshToken) if err != nil { return err } t.client.SetUserAccessToken(userResp.Data.AccessToken) t.UserRefreshToken = userResp.Data.RefreshToken case "top": appResp, err := t.client.RequestAppAccessToken([]string{}) if err != nil { return err } t.client.SetAppAccessToken(appResp.Data.AccessToken) } return nil } func (t *Twitch) TopStreams(params *helix.StreamsParams) (*helix.StreamsResponse, error) { if params == nil { params = &helix.StreamsParams{} } return t.client.GetStreams(params) } func (t *Twitch) FollowedStreams(params *helix.FollowedStreamsParams) (*helix.StreamsResponse, error) { if params == nil { params = &helix.FollowedStreamsParams{} } return t.client.GetFollowedStream(params) } ================================================ FILE: modules/twitch/keyboard.go ================================================ package twitch import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openTwitch, "Open target URL in browser") widget.SetKeyboardChar("s", widget.openStreamlink, "Open target stream via streamlink (github.com/streamlink/streamlink)") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEnter, widget.openTwitch, "Open stream in browser") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") } ================================================ FILE: modules/twitch/settings.go ================================================ package twitch import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true ) type Settings struct { *cfg.Common numberOfResults int `help:"Number of results to show. Default is 10." optional:"true"` clientId string `help:"Client Id (default is env var TWITCH_CLIENT_ID)"` clientSecret string `help:"Client secret (default is env var TWITCH_CLIENT_SECRET)"` appAccessToken string `help:"App access token (default is env var TWITCH_APP_ACCESS_TOKEN)"` userAccessToken string `help:"User access token (default is env var TWITCH_USER_ACCESS_TOKEN)"` userRefreshToken string `help:"User refresh token (default is env var TWITCH_USER_REFRESH_TOKEN)"` streams string `help:"Which streams to display. Options: 'top' and 'followed'. Followed requires user access token, user refresh token and user id. Defaults to top."` userId string `help:"Your twitch user ID"` redirectURI string `help:"The redirect URI of your twitch app, mandatory if you wish to see followed streams (default is env var TWITCH_REDIRECT_URI)"` languages []string `help:"Stream languages" optional:"true"` gameIds []string `help:"Twitch Game IDs" optional:"true"` streamType string `help:"Type of stream 'live' (default), 'all', 'vodcast'" optional:"true"` userIds []string `help:"Twitch user ids" optional:"true"` userLogins []string `help:"Twitch user names" optional:"true"` } func defaultLanguage() []interface{} { var defaults []interface{} defaults = append(defaults, "en") return defaults } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { twitch := ymlConfig.UString("twitch") settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, twitch, defaultFocusable, ymlConfig, globalConfig), numberOfResults: ymlConfig.UInt("numberOfResults", 10), clientId: ymlConfig.UString("clientId", os.Getenv("TWITCH_CLIENT_ID")), clientSecret: ymlConfig.UString("clientSecret", os.Getenv("TWITCH_CLIENT_SECRET")), appAccessToken: ymlConfig.UString("appAccessToken", os.Getenv("TWITCH_APP_ACCESS_TOKEN")), userAccessToken: ymlConfig.UString("userAccessToken", os.Getenv("TWITCH_USER_ACCESS_TOKEN")), userRefreshToken: ymlConfig.UString("userRefreshToken", os.Getenv("TWITCH_USER_REFRESH_TOKEN")), streams: ymlConfig.UString("streams", "top"), userId: ymlConfig.UString("userId", ""), redirectURI: ymlConfig.UString("redirectURI", os.Getenv("TWITCH_REDIRECT_URI")), languages: utils.ToStrs(ymlConfig.UList("languages", defaultLanguage())), streamType: ymlConfig.UString("streamType", "live"), gameIds: utils.ToStrs(ymlConfig.UList("gameIds", make([]interface{}, 0))), userIds: utils.ToStrs(ymlConfig.UList("userIds", make([]interface{}, 0))), userLogins: utils.ToStrs(ymlConfig.UList("userLogins", make([]interface{}, 0))), } return &settings } ================================================ FILE: modules/twitch/widget.go ================================================ package twitch import ( "errors" "fmt" "os/exec" helix "github.com/nicklaw5/helix/v2" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget settings *Settings err error twitch *Twitch topStreams []*Stream } type Stream struct { Streamer string ViewerCount int Language string GameID string Title string } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { clientOpts := &ClientOpts{ ClientID: settings.clientId, ClientSecret: settings.clientSecret, AppAccessToken: settings.appAccessToken, UserAccessToken: settings.userAccessToken, UserRefreshToken: settings.userRefreshToken, RedirectURI: settings.redirectURI, Streams: settings.streams, UserID: settings.userId, } twitchClient, err := NewClient(clientOpts) if err != nil { fmt.Println(err) } widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, twitch: twitchClient, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } func (widget *Widget) Refresh() { var err error var response *helix.StreamsResponse // Refresh the auth token on each refresh to be sure we aren't using an expired one. if err = widget.twitch.RefreshOAuthToken(); err != nil { handleError(widget, err) } switch widget.twitch.Streams { case "followed": response, err = widget.twitch.FollowedStreams(&helix.FollowedStreamsParams{ UserID: widget.twitch.UserID, }) case "top": response, err = widget.twitch.TopStreams(&helix.StreamsParams{ First: widget.settings.numberOfResults, GameIDs: widget.settings.gameIds, Language: widget.settings.languages, Type: widget.settings.streamType, UserIDs: widget.settings.userIds, UserLogins: widget.settings.userLogins, }) } if err != nil { handleError(widget, err) } else if response.ErrorMessage != "" { handleError(widget, errors.New(response.ErrorMessage)) } else { streams := makeStreams(response) widget.topStreams = streams widget.err = nil if len(streams) <= widget.settings.numberOfResults { widget.SetItemCount(len(widget.topStreams)) } else { widget.topStreams = streams[:widget.settings.numberOfResults] widget.SetItemCount(len(widget.topStreams)) } } widget.Render() } func (widget *Widget) Render() { widget.Redraw(widget.content) } func makeStreams(response *helix.StreamsResponse) []*Stream { streams := make([]*Stream, len(response.Data.Streams)) for i, b := range response.Data.Streams { streams[i] = &Stream{ b.UserName, b.ViewerCount, b.Language, b.GameID, b.Title, } } return streams } func handleError(widget *Widget, err error) { widget.err = err widget.topStreams = nil widget.SetItemCount(0) } func (widget *Widget) content() (string, string, bool) { var title = "Twitch Streams" if widget.CommonSettings().Title != "" { title = widget.CommonSettings().Title } if widget.err != nil { return title, widget.err.Error(), true } if len(widget.topStreams) == 0 { return title, "No data", false } var str string locPrinter, _ := widget.settings.LocalizedPrinter() for idx, stream := range widget.topStreams { row := fmt.Sprintf( "[%s]%2d. [red]%s [white]%s - %s", widget.RowColor(idx), idx+1, utils.PrettyNumber(locPrinter, float64(stream.ViewerCount)), stream.Streamer, stream.Title, ) str += utils.HighlightableHelper(widget.View, row, idx, len(stream.Streamer)) } return title, str, false } // Opens stream in the browser func (widget *Widget) openTwitch() { sel := widget.GetSelected() if sel >= 0 && widget.topStreams != nil && sel < len(widget.topStreams) { stream := widget.topStreams[sel] fullLink := "https://twitch.com/" + stream.Streamer utils.OpenFile(fullLink) } } func (widget *Widget) openStreamlink() { sel := widget.GetSelected() if sel >= 0 && widget.topStreams != nil && sel < len(widget.topStreams) { stream := widget.topStreams[sel] fullLink := "https://twitch.tv/" + stream.Streamer cmd := exec.Command("streamlink", fullLink, "best") err := cmd.Start() if err != nil { handleError(widget, err) } } } ================================================ FILE: modules/twitter/client.go ================================================ package twitter import ( "context" "encoding/json" "fmt" "net/http" "strconv" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" ) /* NOTE: Currently single application ONLY * bearer tokens are only supported for applications, not single-users */ // Client represents the data required to connect to the Twitter API type Client struct { apiBase string count int screenName string httpClient *http.Client } // NewClient creates and returns a new Twitter client func NewClient(settings *Settings) *Client { var httpClient *http.Client // If a bearer token is supplied, use that directly. Otherwise, let the Oauth client fetch a token // using the consumer key and secret. if settings.bearerToken == "" { conf := &clientcredentials.Config{ ClientID: settings.consumerKey, ClientSecret: settings.consumerSecret, TokenURL: "https://api.twitter.com/oauth2/token", } httpClient = conf.Client(context.Background()) } else { ctx := context.Background() httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: settings.bearerToken, TokenType: "Bearer", })) } client := Client{ apiBase: "https://api.twitter.com/1.1/", count: settings.count, screenName: "", httpClient: httpClient, } return &client } /* -------------------- Public Functions -------------------- */ // Tweets returns a list of tweets of a user func (client *Client) Tweets() []Tweet { tweets, err := client.getTweets() if err != nil { return []Tweet{} } return tweets } /* -------------------- Private Functions -------------------- */ // tweets is the private interface for retrieving the list of user tweets func (client *Client) getTweets() (tweets []Tweet, err error) { apiURL := fmt.Sprintf( "%s/statuses/user_timeline.json?screen_name=%s&count=%s", client.apiBase, client.screenName, strconv.Itoa(client.count), ) data, err := Request(client.httpClient, apiURL) if err != nil { return tweets, err } err = json.Unmarshal(data, &tweets) return } ================================================ FILE: modules/twitter/keyboard.go ================================================ package twitter import ( "github.com/gdamore/tcell/v2" "github.com/wtfutil/wtf/utils" ) func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("l", widget.NextSource, "Select next source") widget.SetKeyboardChar("h", widget.PrevSource, "Select previous source") widget.SetKeyboardChar("o", widget.openFile, "Open source") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next source") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous source") widget.SetKeyboardKey(tcell.KeyEnter, widget.openFile, "Open source") } func (widget *Widget) openFile() { src := widget.currentSourceURI() utils.OpenFile(src) } ================================================ FILE: modules/twitter/request.go ================================================ package twitter import ( "bytes" "net/http" ) func Request(httpClient *http.Client, apiURL string) ([]byte, error) { resp, err := httpClient.Get(apiURL) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() data, err := ParseBody(resp) if err != nil { return nil, err } return data, err } func ParseBody(resp *http.Response) ([]byte, error) { var buffer bytes.Buffer _, err := buffer.ReadFrom(resp.Body) if err != nil { return nil, err } return buffer.Bytes(), nil } ================================================ FILE: modules/twitter/settings.go ================================================ package twitter import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Twitter" ) type Settings struct { *cfg.Common bearerToken string consumerKey string consumerSecret string count int screenNames []interface{} } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), bearerToken: ymlConfig.UString("bearerToken", os.Getenv("WTF_TWITTER_BEARER_TOKEN")), consumerKey: ymlConfig.UString("consumerKey", os.Getenv("WTF_TWITTER_CONSUMER_KEY")), consumerSecret: ymlConfig.UString("consumerSecret", os.Getenv("WTF_TWITTER_CONSUMER_SECRET")), count: ymlConfig.UInt("count", 5), screenNames: ymlConfig.UList("screenName"), } settings.SetDocumentationPath("twitter/tweets") return &settings } ================================================ FILE: modules/twitter/tweet.go ================================================ package twitter import ( "fmt" "time" ) type Tweet struct { User User `json:"user"` Text string `json:"text"` CreatedAt string `json:"created_at"` } func (tweet *Tweet) String() string { return fmt.Sprintf("Tweet: %s at %s by %s", tweet.Text, tweet.CreatedAt, tweet.User.ScreenName) } /* -------------------- Exported Functions -------------------- */ func (tweet *Tweet) Username() string { return tweet.User.ScreenName } func (tweet *Tweet) Created() time.Time { newTime, _ := time.Parse(time.RubyDate, tweet.CreatedAt) return newTime } func (tweet *Tweet) PrettyCreatedAt() string { newTime := tweet.Created() return fmt.Sprint(newTime.Format("Jan 2, 2006")) } ================================================ FILE: modules/twitter/user.go ================================================ package twitter // User is used as part of the Tweet struct to get user information type User struct { ScreenName string `json:"screen_name"` } ================================================ FILE: modules/twitter/widget.go ================================================ package twitter import ( "fmt" "html" "regexp" "github.com/dustin/go-humanize" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) type Widget struct { view.MultiSourceWidget view.TextWidget client *Client idx int settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "screenName", "screenNames"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), idx: 0, settings: settings, } widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.Refresh) widget.client = NewClient(settings) widget.View.SetBorderPadding(1, 1, 1, 1) widget.View.SetWrap(true) widget.View.SetWordWrap(true) return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh is called on the interval and refreshes the data func (widget *Widget) Refresh() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { widget.client.screenName = widget.CurrentSource() tweets := widget.client.Tweets() title := fmt.Sprintf("Twitter - [green]@%s[white]", widget.CurrentSource()) if len(tweets) == 0 { str := fmt.Sprintf("\n\n\n%s", utils.CenterText("[lightblue]No Tweets[white]", 50)) return title, str, true } _, _, width, _ := widget.View.GetRect() str := widget.settings.PaginationMarker(len(widget.Sources), widget.Idx, width-2) + "\n" for _, tweet := range tweets { str += widget.format(tweet) } return title, str, true } // If the tweet's Username is the same as the account we're watching, no // need to display the username func (widget *Widget) displayName(tweet Tweet) string { if widget.CurrentSource() == tweet.User.ScreenName { return "" } return tweet.User.ScreenName } func (widget *Widget) formatText(text string) string { result := text // Convert HTML entities result = html.UnescapeString(result) // RT indicator rtRegExp := regexp.MustCompile(`^RT`) result = rtRegExp.ReplaceAllString(result, "[olive]${0}[white::-]") // @name mentions atRegExp := regexp.MustCompile(`@[0-9A-Za-z_]*`) result = atRegExp.ReplaceAllString(result, "[lightblue]${0}[white]") // HTTP(S) links linkRegExp := regexp.MustCompile(`http[s:\/.0-9A-Za-z]*`) result = linkRegExp.ReplaceAllString(result, "[lightblue::u]${0}[white::-]") // Hash tags hashRegExp := regexp.MustCompile(`#[0-9A-Za-z_]*`) result = hashRegExp.ReplaceAllString(result, "[yellow]${0}[white]") return result } func (widget *Widget) format(tweet Tweet) string { body := widget.formatText(tweet.Text) name := widget.displayName(tweet) var attribution string if name == "" { attribution = humanize.Time(tweet.Created()) } else { attribution = fmt.Sprintf( "%s, %s", name, humanize.Time(tweet.Created()), ) } return fmt.Sprintf("%s\n[grey]%s[white]\n\n", body, attribution) } func (widget *Widget) currentSourceURI() string { src := "https://twitter.com/" + widget.CurrentSource() return src } ================================================ FILE: modules/twitterstats/client.go ================================================ package twitterstats import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" ) // Client contains state that allows stats to be fetched about a list of Twitter users type Client struct { httpClient *http.Client screenNames []string } // TwitterStats Represents a stats snapshot for a single Twitter user at a point in time type TwitterStats struct { FollowerCount int64 `json:"followers_count"` TweetCount int64 `json:"statuses_count"` } const ( userTimelineURL = "https://api.twitter.com/1.1/users/show.json" ) // NewClient creates a new twitterstats client that contains an OAuth2 HTTP client which can be used func NewClient(settings *Settings) *Client { usernames := make([]string, len(settings.screenNames)) for i, username := range settings.screenNames { var ok bool if usernames[i], ok = username.(string); !ok { log.Fatalf("All `screenName`s in twitterstats config must be of type string") } } var httpClient *http.Client // If a bearer token is supplied, use that directly. Otherwise, let the Oauth client fetch a token // using the consumer key and secret. if settings.bearerToken == "" { conf := &clientcredentials.Config{ ClientID: settings.consumerKey, ClientSecret: settings.consumerSecret, TokenURL: "https://api.twitter.com/oauth2/token", } httpClient = conf.Client(context.Background()) } else { ctx := context.Background() httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: settings.bearerToken, TokenType: "Bearer", })) } client := Client{ httpClient: httpClient, screenNames: usernames, } return &client } // GetStatsForUser Fetches stats for a single user. If there is an error fetching or parsing the response // from the Twitter API, an empty stats struct will be returned. func (client *Client) GetStatsForUser(username string) TwitterStats { stats := TwitterStats{ FollowerCount: 0, TweetCount: 0, } url := fmt.Sprintf("%s?screen_name=%s", userTimelineURL, username) resp, err := client.httpClient.Get(url) if err != nil { return stats } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return stats } // If there is an error while parsing, just discard the error and return the empty stats err = json.Unmarshal(body, &stats) if err != nil { return stats } return stats } // GetStats Returns a slice of `TwitterStats` structs for each username in `client.screenNames` in the same // order of `client.screenNames` func (client *Client) GetStats() []TwitterStats { stats := make([]TwitterStats, len(client.screenNames)) for i, username := range client.screenNames { stats[i] = client.GetStatsForUser(username) } return stats } ================================================ FILE: modules/twitterstats/settings.go ================================================ package twitterstats import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Twitter Stats" ) type Settings struct { *cfg.Common bearerToken string consumerKey string consumerSecret string screenNames []interface{} } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), bearerToken: ymlConfig.UString("bearerToken", os.Getenv("WTF_TWITTER_BEARER_TOKEN")), consumerKey: ymlConfig.UString("consumerKey", os.Getenv("WTF_TWITTER_CONSUMER_KEY")), consumerSecret: ymlConfig.UString("consumerSecret", os.Getenv("WTF_TWITTER_CONSUMER_SECRET")), screenNames: ymlConfig.UList("screenNames"), } settings.SetDocumentationPath("twitter/stats") return &settings } ================================================ FILE: modules/twitterstats/widget.go ================================================ package twitterstats import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget client *Client settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, _ *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), client: NewClient(settings), settings: settings, } widget.View.SetBorderPadding(1, 1, 1, 1) widget.View.SetWrap(false) widget.View.SetWordWrap(true) return &widget } func (widget *Widget) Refresh() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { // Add header row str := fmt.Sprintf( "[%s]%-12s %10s %8s[white]\n", widget.settings.Colors.Subheading, "Username", "Followers", "Tweets", ) stats := widget.client.GetStats() // Add rows for each of the followed usernames for i, username := range widget.client.screenNames { str += fmt.Sprintf( "%-12s %10d %8d\n", username, stats[i].FollowerCount, stats[i].TweetCount, ) } return "Twitter Stats", str, false } ================================================ FILE: modules/unknown/settings.go ================================================ package unknown import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Unknown" ) type Settings struct { *cfg.Common } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), } return &settings } ================================================ FILE: modules/unknown/widget.go ================================================ package unknown import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { content := fmt.Sprintf("Widget %s and/or type %s does not exist", widget.Name(), widget.CommonSettings().Type) widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, content, true }) } ================================================ FILE: modules/updown/keyboard.go ================================================ package updown func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) } ================================================ FILE: modules/updown/settings.go ================================================ package updown import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) const ( defaultFocusable = true defaultTitle = "Updown.io" ) type Settings struct { *cfg.Common apiKey string `help:"An Updown API key." optional:"false"` tokens []string `help:"Filters the checks and returns only the checks with the specified tokens"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_UPDOWN_APIKEY")), tokens: utils.ToStrs(ymlConfig.UList("tokens")), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/updown/widget.go ================================================ package updown import ( "fmt" "net/http" "net/url" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) const ( userAgent = "WTFUtil" apiURLBase = "https://updown.io" ) type Widget struct { view.ScrollableWidget checks []Check settings *Settings tokenSet map[string]struct{} err error } // Taken from https://github.com/AntoineAugusti/updown/blob/d590ab97f115302c73ecf21647909d8fd06ed6ac/checks.go#L17 type Check struct { Token string `json:"token,omitempty"` URL string `json:"url,omitempty"` Alias string `json:"alias,omitempty"` LastStatus int `json:"last_status,omitempty"` Uptime float64 `json:"uptime,omitempty"` Down bool `json:"down"` DownSince string `json:"down_since,omitempty"` Error string `json:"error,omitempty"` Period int `json:"period,omitempty"` Apdex float64 `json:"apdex_t,omitempty"` Enabled bool `json:"enabled"` Published bool `json:"published"` LastCheckAt time.Time `json:"last_check_at,omitempty"` NextCheckAt time.Time `json:"next_check_at,omitempty"` FaviconURL string `json:"favicon_url,omitempty"` SSL SSL `json:"ssl,omitempty"` StringMatch string `json:"string_match,omitempty"` MuteUntil string `json:"mute_until,omitempty"` DisabledLocations []string `json:"disabled_locations,omitempty"` CustomHeaders map[string]string `json:"custom_headers,omitempty"` } // Taken from https://github.com/AntoineAugusti/updown/blob/d590ab97f115302c73ecf21647909d8fd06ed6ac/checks.go#L10 type SSL struct { TestedAt string `json:"tested_at,omitempty"` Valid bool `json:"valid,omitempty"` Error string `json:"error,omitempty"` } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, tokenSet: make(map[string]struct{}), } for _, t := range settings.tokens { widget.tokenSet[t] = struct{}{} } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } func (widget *Widget) Refresh() { if widget.Disabled() { return } checks, err := widget.getExistingChecks() widget.checks = checks widget.err = err widget.SetItemCount(len(checks)) widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { numUp := 0 for _, check := range widget.checks { if !check.Down { numUp++ } } title := fmt.Sprintf("Updown (%d/%d)", numUp, len(widget.checks)) if widget.err != nil { return title, widget.err.Error(), true } if widget.checks == nil { return title, "No checks to display", false } str := widget.contentFrom(widget.checks) return title, str, false } func (widget *Widget) contentFrom(checks []Check) string { var str string for _, check := range checks { prefix := "" if !check.Enabled { prefix += "[yellow] ~ " } else if check.Down { prefix += "[red] - " } else { prefix += "[green] + " } str += fmt.Sprintf(`%s%s [gray](%0.2f|%s)[white]%s`, prefix, check.Alias, check.Uptime, timeSincePing(check.LastCheckAt), "\n", ) } return str } func timeSincePing(ts time.Time) string { dur := time.Since(ts) return dur.Truncate(time.Second).String() } func makeURL(baseurl string, path string) (string, error) { u, err := url.Parse(baseurl) if err != nil { return "", err } u.Path = path return u.String(), nil } func filterChecks(checks []Check, tokenSet map[string]struct{}) []Check { j := 0 for i := 0; i < len(checks); i++ { if _, ok := tokenSet[checks[i].Token]; ok { checks[j] = checks[i] j++ } } return checks[:j] } func (widget *Widget) getExistingChecks() ([]Check, error) { // See: https://updown.io/api#rest u, err := makeURL(apiURLBase, "/api/checks") if err != nil { return nil, err } req, err := http.NewRequest("GET", u, http.NoBody) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("X-API-KEY", widget.settings.apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } defer func() { _ = resp.Body.Close() }() var checks []Check err = utils.ParseJSON(&checks, resp.Body) if err != nil { return nil, err } if len(widget.tokenSet) > 0 { checks = filterChecks(checks, widget.tokenSet) } return checks, nil } ================================================ FILE: modules/uptimekuma/keyboard.go ================================================ package uptimekuma func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) } ================================================ FILE: modules/uptimekuma/settings.go ================================================ package uptimekuma import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Uptime Kuma" ) type Settings struct { common *cfg.Common url string `help:"Status page URL; e.g. https://uptimekuma.example.com/status/overview"` } // NewSettingsFromYAML creates a new settings instance from a YAML config block func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), // Configure your settings attributes here. See http://github.com/olebedev/config for type details url: ymlConfig.UString("url"), } return &settings } ================================================ FILE: modules/uptimekuma/widget.go ================================================ package uptimekuma import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // HeartbeatStatus represents the status of a heartbeat // Matches JS: DOWN=0, UP=1, PENDING=2, MAINTENANCE=3 type HeartbeatStatus int const ( DOWN HeartbeatStatus = iota UP PENDING MAINTENANCE ) // StatusPageData represents the data from the /api/status-page/ endpoint type StatusPageData struct { Incident *Incident `json:"incident"` } // Incident represents an incident in Uptime Kuma type Incident struct { CreatedDate string `json:"createdDate"` } // HeartbeatData represents the data from the /api/status-page/heartbeat/ endpoint type HeartbeatData struct { HeartbeatList map[string][]*Heartbeat `json:"heartbeatList"` UptimeList map[string]float64 `json:"uptimeList"` } // Heartbeat represents a single heartbeat event type Heartbeat struct { Status int `json:"status"` } // Widget is the container for your module's data type Widget struct { view.TextWidget settings *Settings statusData *StatusPageData heartbeatData *HeartbeatData err error } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common), settings: settings, } widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { widget.err = nil baseURL, slug, err := parseURL(widget.settings.url) if err != nil { widget.err = err widget.display() return } statusData, err := widget.fetchStatusData(baseURL, slug) if err != nil { widget.err = err widget.display() return } widget.statusData = statusData heartbeatData, err := widget.fetchHeartbeatData(baseURL, slug) if err != nil { widget.err = err widget.display() return } widget.heartbeatData = heartbeatData widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() string { if widget.err != nil { return fmt.Sprintf("[red]Error: %v", widget.err) } if widget.statusData == nil || widget.heartbeatData == nil { return "Loading..." } // Use a single indexed variable for status counts statusCounts := [4]int{} for _, siteList := range widget.heartbeatData.HeartbeatList { if len(siteList) > 0 { lastHeartbeat := siteList[len(siteList)-1] status := HeartbeatStatus(lastHeartbeat.Status) if status >= 0 && int(status) < len(statusCounts) { statusCounts[status]++ } } } var totalUptime float64 numMonitors := len(widget.heartbeatData.UptimeList) if numMonitors > 0 { for _, uptime := range widget.heartbeatData.UptimeList { totalUptime += uptime } } var avgUptime float64 if numMonitors > 0 { avgUptime = (totalUptime / float64(numMonitors)) * 100 } // Adapted from https://github.com/gethomepage/homepage/blob/00bb1a3f37940a0c3c681c3eef0a10d3e1fa0053/src/widgets/uptimekuma/component.jsx#L41C1-L48C1 var builder strings.Builder var textColor = widget.settings.common.Colors.Text downColor := "red" if statusCounts[DOWN] == 0 { downColor = "green" } fmt.Fprintf(&builder, "[%s] Up: [green]%d", textColor, statusCounts[UP]) fmt.Fprintf(&builder, "[%s] (%.1f%%)", textColor, avgUptime) fmt.Fprintf(&builder, "[%s], Down: [%s]%d", textColor, downColor, statusCounts[DOWN]) if statusCounts[MAINTENANCE] > 0 { fmt.Fprintf(&builder, "[%s], Maint: [%s]%d", textColor, "blue", statusCounts[MAINTENANCE]) } if statusCounts[PENDING] > 0 { fmt.Fprintf(&builder, "[%s], Pend: [%s]%d", textColor, "orange", statusCounts[PENDING]) } if widget.statusData.Incident != nil { // Uptime Kuma's API returns dates like "2023-10-27 10:30:00.123" layout := "2006-01-02 15:04:05.999" created, err := time.Parse(layout, widget.statusData.Incident.CreatedDate) if err == nil { hoursAgo := time.Since(created).Hours() fmt.Fprintf(&builder, "[%s]\n Incident: %.0fh ago", textColor, hoursAgo) } else { fmt.Fprintf(&builder, "[%s]\n Incident [unparsable date]", textColor) } } return builder.String() } func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.content(), false }) } func (*Widget) fetchStatusData(baseURL, slug string) (*StatusPageData, error) { apiURL := fmt.Sprintf("%s/api/status-page/%s", baseURL, slug) resp, err := http.Get(apiURL) if resp != nil && resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } if resp == nil || err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var data StatusPageData if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } return &data, nil } func (*Widget) fetchHeartbeatData(baseURL, slug string) (*HeartbeatData, error) { apiURL := fmt.Sprintf("%s/api/status-page/heartbeat/%s", baseURL, slug) resp, err := http.Get(apiURL) if resp != nil && resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } if resp == nil || err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var data HeartbeatData if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } return &data, nil } func parseURL(rawURL string) (string, string, error) { if rawURL == "" { return "", "", fmt.Errorf("URL is not defined") } u, err := url.Parse(rawURL) if err != nil { return "", "", fmt.Errorf("invalid URL: %w", err) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 2 || parts[0] != "status" { return "", "", fmt.Errorf("invalid status page URL format. Expected '.../status/'") } slug := parts[1] baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) return baseURL, slug, nil } ================================================ FILE: modules/uptimerobot/keyboard.go ================================================ package uptimerobot func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) } ================================================ FILE: modules/uptimerobot/settings.go ================================================ package uptimerobot import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Uptime Robot" ) type Settings struct { *cfg.Common apiKey string `help:"An UptimeRobot API key."` uptimePeriods string `help:"The periods over which to display uptime (in days, dash-separated)." optional:"true"` offlineFirst bool `help:"Display offline monitors at the top." optional:"true"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_UPTIMEROBOT_APIKEY")), uptimePeriods: ymlConfig.UString("uptimePeriods", "30"), offlineFirst: ymlConfig.UBool("offlineFirst", false), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey). Service("https://api.uptimerobot.com").Load() return &settings } ================================================ FILE: modules/uptimerobot/widget.go ================================================ package uptimerobot import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.ScrollableWidget monitors []Monitor settings *Settings err error } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := &Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { if widget.Disabled() { return } monitors, err := widget.getMonitors() if widget.settings.offlineFirst { var tmp Monitor var next int for i := 0; i < len(monitors); i++ { if monitors[i].State != 2 { tmp = monitors[i] for j := i; j > next; j-- { monitors[j] = monitors[j-1] } monitors[next] = tmp next++ } } } widget.monitors = monitors widget.err = err widget.SetItemCount(len(monitors)) widget.Render() } // Render sets up the widget data for redrawing to the screen func (widget *Widget) Render() { widget.Redraw(widget.content) } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) content() (string, string, bool) { numUp := 0 for _, monitor := range widget.monitors { if monitor.State == 2 { numUp++ } } title := fmt.Sprintf("%s (%d/%d)", widget.CommonSettings().Title, numUp, len(widget.monitors)) if widget.err != nil { return title, widget.err.Error(), true } if widget.monitors == nil { return title, "No monitors to display", false } str := widget.contentFrom(widget.monitors) return title, str, false } func (widget *Widget) contentFrom(monitors []Monitor) string { var str string for _, monitor := range monitors { prefix := "" switch monitor.State { case 2: prefix += "[green] + " case 8: case 9: prefix += "[red] - " default: prefix += "[yellow] ~ " } str += fmt.Sprintf(`%s%s [gray](%s)[white] `, prefix, monitor.Name, formatUptimes(monitor.Uptime), ) } return str } func formatUptimes(str string) string { splits := strings.Split(str, "-") str = "" for i, s := range splits { if i != 0 { str += "|" } s = s[:5] s = strings.TrimRight(s, "0") s = strings.TrimRight(s, ".") + "%" str += s } return str } type Monitor struct { Name string `json:"friendly_name"` // Monitor state, see: https://uptimerobot.com/api/#parameters State int8 `json:"status"` // Uptime ratio, preformatted, e.g.: 100.000-97.233-96.975 Uptime string `json:"custom_uptime_ratio"` } func (widget *Widget) getMonitors() ([]Monitor, error) { // See: https://uptimerobot.com/api/#getMonitorsWrap resp, errh := http.PostForm("https://api.uptimerobot.com/v2/getMonitors", url.Values{ "api_key": {widget.settings.apiKey}, "format": {"json"}, "custom_uptime_ratios": {widget.settings.uptimePeriods}, }, ) if errh != nil { return nil, errh } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) // First pass to read the status c := make(map[string]json.RawMessage) errj1 := json.Unmarshal(body, &c) if errj1 != nil { return nil, errj1 } if string(c["stat"]) != `"ok"` { return nil, errors.New(string(body)) } // Second pass to get the actual info var monitors []Monitor errj2 := json.Unmarshal(c["monitors"], &monitors) if errj2 != nil { return nil, errj2 } return monitors, nil } ================================================ FILE: modules/urlcheck/client.go ================================================ package urlcheck import ( "context" "errors" "fmt" "net/http" "time" "github.com/wtfutil/wtf/logger" ) // Perform the requet of the header for a given URL func DoRequest(urlRequest string, timeout time.Duration, client *http.Client) (int, string) { // Define a Context with the timeout for the request ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Request req, err := http.NewRequest(http.MethodHead, urlRequest, nil) if err != nil { logger.Log(fmt.Sprintf("[urlcheck] ERROR %s: %s", urlRequest, err.Error())) return InvalidResultCode, "New Request Error" } req = req.WithContext(ctx) // Send the request res, err := client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { status := "Timeout" logger.Log(fmt.Sprintf("[urlcheck] %s: %s", urlRequest, status)) return InvalidResultCode, status } logger.Log(fmt.Sprintf("[urlcheck] %s: %s", urlRequest, err.Error())) return InvalidResultCode, "Error" } defer res.Body.Close() return res.StatusCode, res.Status } ================================================ FILE: modules/urlcheck/client_test.go ================================================ package urlcheck import ( "net/http" "net/http/httptest" "testing" "time" "gotest.tools/assert" ) func TestTimeout(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(time.Second * 1) })) defer ts.Close() client := &http.Client{ Timeout: time.Millisecond * 10, } timeout := 1 * time.Microsecond statusCode, statusMsg := DoRequest(ts.URL, timeout, client) assert.Equal(t, 999, statusCode) assert.Equal(t, "Timeout", statusMsg) } ================================================ FILE: modules/urlcheck/settings.go ================================================ package urlcheck import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "URLcheck" ) type Settings struct { Common *cfg.Common requestTimeout int `help:"Max Request duration in seconds"` urls []string `help:"A list of URL to check"` } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), requestTimeout: ymlConfig.UInt("timeout", 30), } settings.urls = cfg.ParseAsMapOrList(ymlConfig, "urls") return &settings } ================================================ FILE: modules/urlcheck/urlResult.go ================================================ package urlcheck import ( "net/url" ) const InvalidResultCode = 999 // Collect useful properties of each given URL type urlResult struct { Url string ResultCode int ResultMessage string IsValid bool } // Create a UrlResult instance from an urls occurence in the settings func newUrlResult(urlString string) *urlResult { uResult := urlResult{ Url: urlString, } _, err := url.ParseRequestURI(urlString) if err != nil { uResult.ResultMessage = err.Error() uResult.ResultCode = InvalidResultCode uResult.IsValid = false return &uResult } uResult.IsValid = true return &uResult } ================================================ FILE: modules/urlcheck/urlResult_test.go ================================================ package urlcheck import ( "testing" "github.com/stretchr/testify/assert" ) func checkValid(t *testing.T, got *urlResult) { assert.True(t, got.IsValid) assert.Less(t, got.ResultCode, 500) assert.Len(t, got.ResultMessage, 0) } func checkInvalid(t *testing.T, got *urlResult) { assert.False(t, got.IsValid) assert.GreaterOrEqual(t, got.ResultCode, 500) assert.Greater(t, len(got.ResultMessage), 0) } func Test_newUrlResult(t *testing.T) { type args struct { urlString string } type checks func(t *testing.T, res *urlResult) tests := []struct { name string args args checks checks }{ {"good", args{"http://www.go.dev"}, checkValid}, {"good_with_page", args{"https://go.dev/doc/install"}, checkValid}, {"good_with_args", args{"https://mysite.com?var=1"}, checkValid}, {"no_url", args{""}, checkInvalid}, {"no_escape_chars", args{"http://not\nurl.com?var=1"}, checkInvalid}, {"no_protocol", args{"go.dev"}, checkInvalid}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.checks != nil { tt.checks(t, newUrlResult(tt.args.urlString)) } }) } } ================================================ FILE: modules/urlcheck/view.go ================================================ package urlcheck import ( "bytes" "fmt" "net/http" "text/template" ) // Prepare the text template at the moment of the widget creation and stores it in the widget instance func (widget *Widget) PrepareTemplate() { textColor := fmt.Sprintf(" [%s]", widget.settings.Common.Colors.Text) labelColor := fmt.Sprintf(" [%s]", widget.settings.Common.Colors.Label) widget.templateString = "{{range .}} " + "{{. | getResultColor}}" + "[{{if eq .ResultCode 999}}---{{else}}{{.ResultCode}}{{end}}]" + textColor + "{{.Url}}" + labelColor + "{{.ResultMessage}}" + "\n{{end}}" widget.PreparedTemplate = template.New("tmpl").Funcs(template.FuncMap{"getResultColor": getResultColor}) } // Parse the results at each refresh of the widge func (widget *Widget) parseTemplate() *template.Template { return template.Must(widget.PreparedTemplate.Parse(widget.templateString)) } // Format the parsed results accordingly to the app style func (widget *Widget) FormatResult() string { if len(widget.urlList) < 1 { return "empty URL list" } t := widget.parseTemplate() resultBuffer := new(bytes.Buffer) err := t.Execute(resultBuffer, widget.urlList) if err != nil { return err.Error() } return resultBuffer.String() } // URLs with no issues will have their result code in green, otherways in red. func getResultColor(ur urlResult) string { if !ur.IsValid { return "[red]" } if ur.ResultCode < http.StatusInternalServerError { return "[green]" } return "[red]" } ================================================ FILE: modules/urlcheck/widget.go ================================================ package urlcheck import ( "net/http" "text/template" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget settings *Settings // settings from the configuration file urlList []*urlResult // list of a collection of useful properies of the url client *http.Client // the http client shared with all the requestes across all the refreshes timeout time.Duration // the timeout for a single request PreparedTemplate *template.Template // the test template shared across the refreshes templateString string // the string needed to parse the template and shared across all the widget refreshes } // NewWidget creates and returns an instance of Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { maxUrl := len(settings.urls) widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, urlList: make([]*urlResult, maxUrl), client: &http.Client{}, timeout: time.Duration(settings.requestTimeout) + time.Second, } widget.init() widget.View.SetWrap(false) return &widget } /* -------------------- Exported Functions -------------------- */ // Refresh updates the onscreen contents of the widget func (widget *Widget) Refresh() { widget.check() widget.display() } /* -------------------- Unexported Functions -------------------- */ // The string passed from the settings are checked and prepared for processing func (widget *Widget) init() { // Prepare the template for the results widget.PrepareTemplate() for i, urlString := range widget.settings.urls { widget.urlList[i] = newUrlResult(urlString) } } // Do the actual requests and check the responses at every widget refresh func (widget *Widget) check() { for _, urlRes := range widget.urlList { if urlRes.IsValid { urlRes.ResultCode, urlRes.ResultMessage = DoRequest(urlRes.Url, widget.timeout, widget.client) } } } // Format and displays the results at every refresh func (widget *Widget) display() { widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.FormatResult(), false }) } ================================================ FILE: modules/victorops/client.go ================================================ package victorops import ( "encoding/json" "fmt" "net/http" "strings" "github.com/wtfutil/wtf/logger" ) // Fetch gets the current oncall users func Fetch(apiID, apiKey string) ([]OnCallTeam, error) { scheduleURL := "https://api.victorops.com/api-public/v1/oncall/current" response, err := victorOpsRequest(scheduleURL, apiID, apiKey) return response, err } /* ---------------- Unexported Functions ---------------- */ func victorOpsRequest(url string, apiID string, apiKey string) ([]OnCallTeam, error) { req, err := http.NewRequest("GET", url, http.NoBody) if err != nil { logger.Log(fmt.Sprintf("Failed to initialize sessions to VictorOps. ERROR: %s", err)) return nil, err } req.Header.Set("X-VO-Api-Id", apiID) req.Header.Set("X-VO-Api-Key", apiKey) client := &http.Client{} resp, err := client.Do(req) if err != nil { logger.Log(fmt.Sprintf("Failed to make request to VictorOps. ERROR: %s", err)) return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } defer func() { _ = resp.Body.Close() }() response := &OnCallResponse{} if err := json.NewDecoder(resp.Body).Decode(response); err != nil { logger.Log(fmt.Sprintf("Failed to decode JSON response. ERROR: %s", err)) return nil, err } teams := parseTeams(response) return teams, nil } func parseTeams(input *OnCallResponse) []OnCallTeam { var teamResults []OnCallTeam for _, data := range input.TeamsOnCall { var team OnCallTeam team.Name = data.Team.Name team.Slug = data.Team.Slug var userList []string for _, userData := range data.OnCallNow { escalationPolicy := userData.EscalationPolicy.Name for _, user := range userData.Users { userList = append(userList, user.OnCallUser.Username) } team.OnCall = append(team.OnCall, OnCall{escalationPolicy, strings.Join(userList, ", ")}) } teamResults = append(teamResults, team) } return teamResults } ================================================ FILE: modules/victorops/oncallresponse.go ================================================ package victorops // OnCallResponse object type OnCallResponse struct { TeamsOnCall []struct { Team struct { Name string `json:"name"` Slug string `json:"slug"` } `json:"team"` OnCallNow []struct { EscalationPolicy struct { Name string `json:"name"` Slug string `json:"slug"` } `json:"escalationPolicy"` Users []struct { OnCallUser struct { Username string `json:"username"` } `json:"onCalluser"` } `json:"users"` } `json:"oncallNow"` } `json:"teamsOnCall"` } ================================================ FILE: modules/victorops/oncallteam.go ================================================ package victorops // OnCallTeam object to make // managing objects easier type OnCallTeam struct { Name string Slug string OnCall []OnCall } // OnCall object to handle // different on call policies type OnCall struct { Policy string Userlist string } ================================================ FILE: modules/victorops/settings.go ================================================ package victorops import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "VictorOps" ) type Settings struct { *cfg.Common apiID string apiKey string team string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiID: ymlConfig.UString("apiID", os.Getenv("WTF_VICTOROPS_API_ID")), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_VICTOROPS_API_KEY"))), team: ymlConfig.UString("team"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/victorops/widget.go ================================================ package victorops import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) // Widget contains text info type Widget struct { view.TextWidget teams []OnCallTeam settings *Settings err error } // NewWidget creates a new widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } widget.View.SetScrollable(true) widget.View.SetRegions(true) return &widget } // Refresh gets latest content for the widget func (widget *Widget) Refresh() { if widget.Disabled() { return } teams, err := Fetch(widget.settings.apiID, widget.settings.apiKey) widget.err = err widget.teams = teams widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := widget.CommonSettings().Title if widget.err != nil { return title, widget.err.Error(), true } teams := widget.teams var str string if len(teams) == 0 { return title, "No teams specified", false } for _, team := range teams { if len(widget.settings.team) > 0 && widget.settings.team != team.Slug { continue } str = fmt.Sprintf("%s[green]%s\n", str, team.Name) if len(team.OnCall) == 0 { str = fmt.Sprintf("%s[grey]no one\n", str) } for _, onCall := range team.OnCall { str = fmt.Sprintf("%s[white]%s - %s\n", str, onCall.Policy, onCall.Userlist) } str = fmt.Sprintf("%s\n", str) } if str == "" { str = "Could not find any teams to display" } return title, str, false } ================================================ FILE: modules/weatherservices/arpansagovau/client.go ================================================ package arpansagovau import ( "encoding/xml" "fmt" "io" "net/http" ) type Stations struct { XMLName xml.Name `xml:"stations"` Text string `xml:",chardata"` Location []struct { Text string `xml:",chardata"` ID string `xml:"id,attr"` Name string `xml:"name"` // adl, ali, bri, can, cas, ... Index float32 `xml:"index"` // 0.0, 0.0, 0.0, 0.0, 0.0, ... Time string `xml:"time"` // 7:24 PM, 7:24 PM, 7:54 PM... Date string `xml:"date"` // 29/08/2019, 29/08/2019, 2... Fulldate string `xml:"fulldate"` // Thursday, 29 August 2019,... Utcdatetime string `xml:"utcdatetime"` // 2019/08/29 09:54, 2019/08... Status string `xml:"status"` // ok, ok, ok, ok, ok, ok, o... } `xml:"location"` } type location struct { name string index float32 time string date string status string } func getLocationData(cityname string) (*location, error) { var locdata location resp, err := apiRequest() if err != nil { return nil, err } stations, err := parseXML(resp.Body) if err != nil { return nil, err } for _, city := range stations.Location { if city.ID == cityname { locdata = location{name: city.ID, index: city.Index, time: city.Time, date: city.Date, status: city.Status} break } } return &locdata, err } /* -------------------- Unexported Functions -------------------- */ func apiRequest() (*http.Response, error) { req, err := http.NewRequest("GET", "https://uvdata.arpansa.gov.au/xml/uvvalues.xml", http.NoBody) if err != nil { return nil, err } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { return nil, fmt.Errorf("%s", resp.Status) } return resp, nil } func parseXML(text io.Reader) (Stations, error) { dec := xml.NewDecoder(text) dec.Strict = false var v Stations err := dec.Decode(&v) return v, err } ================================================ FILE: modules/weatherservices/arpansagovau/settings.go ================================================ package arpansagovau import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "ARPANSA UV Data" ) type Settings struct { *cfg.Common city string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), city: ymlConfig.UString("locationid"), } settings.SetDocumentationPath("weather_services/arpansagovau") return &settings } ================================================ FILE: modules/weatherservices/arpansagovau/widget.go ================================================ package arpansagovau import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" ) type Widget struct { view.TextWidget location *location lastError error settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { locationData, err := getLocationData(settings.city) widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), location: locationData, lastError: err, settings: settings, } widget.View.SetWrap(true) return &widget } func (widget *Widget) content() (string, string, bool) { locationData, err := getLocationData(widget.settings.city) widget.location = locationData widget.lastError = err if widget.lastError != nil { return widget.CommonSettings().Title, fmt.Sprintf("Err: %s", widget.lastError.Error()), true } return widget.CommonSettings().Title, formatLocationData(widget.location), true } func (widget *Widget) Refresh() { widget.Redraw(widget.content) } func formatLocationData(location *location) string { var level string var color string var content string if location.name == "" { return "[red]No data?" } if location.status != "ok" { content = "[red]Data unavailable for " content += location.name return content } switch { case location.index < 2.5: color = "[green]" level = " (LOW)" case location.index >= 2.5 && location.index < 5.5: color = "[yellow]" level = " (MODERATE)" case location.index >= 5.5 && location.index < 7.5: color = "[orange]" level = " (HIGH)" case location.index >= 7.5 && location.index < 10.5: color = "[red]" level = " (VERY HIGH)" case location.index >= 10.5: color = "[fuchsia]" level = " (EXTREME)" } content = "Location: " content += location.name content += "\nUV index: " content += color content += fmt.Sprintf("%.2f", location.index) content += level content += "[white]\nLocal time: " content += location.time content += " " content += location.date content += "\nDetector status: " content += location.status return content } ================================================ FILE: modules/weatherservices/prettyweather/settings.go ================================================ package prettyweather import ( "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = false defaultTitle = "Pretty Weather" ) type Settings struct { *cfg.Common city string unit string view string language string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), city: ymlConfig.UString("city", "Barcelona"), language: ymlConfig.UString("language", "en"), unit: ymlConfig.UString("unit", "m"), view: ymlConfig.UString("view", "0"), } settings.SetDocumentationPath("weather_services/prettyweather") return &settings } ================================================ FILE: modules/weatherservices/prettyweather/widget.go ================================================ package prettyweather import ( "io" "net/http" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/view" "github.com/wtfutil/wtf/wtf" ) type Widget struct { view.TextWidget result string settings *Settings } func NewWidget(tviewApp *tview.Application, redrawChan chan bool, settings *Settings) *Widget { widget := Widget{ TextWidget: view.NewTextWidget(tviewApp, redrawChan, nil, settings.Common), settings: settings, } return &widget } func (widget *Widget) Refresh() { widget.prettyWeather() widget.Redraw(func() (string, string, bool) { return widget.CommonSettings().Title, widget.result, false }) } // this method reads the config and calls wttr.in for pretty weather func (widget *Widget) prettyWeather() { client := &http.Client{} city := widget.settings.city unit := widget.settings.unit view := widget.settings.view req, err := http.NewRequest("GET", "https://wttr.in/"+city+"?"+view+"?"+unit, http.NoBody) if err != nil { widget.result = err.Error() return } req.Header.Set("Accept-Language", widget.settings.language) req.Header.Set("User-Agent", "curl") response, err := client.Do(req) if err != nil { widget.result = err.Error() return } defer func() { _ = response.Body.Close() }() contents, err := io.ReadAll(response.Body) if err != nil { widget.result = err.Error() return } widget.result = strings.TrimSpace(wtf.ASCIItoTviewColors(string(contents))) } ================================================ FILE: modules/weatherservices/weather/display.go ================================================ package weather import ( "fmt" "strings" owm "github.com/briandowns/openweathermap" "github.com/wtfutil/wtf/wtf" ) func (widget *Widget) display() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { var err string if !widget.apiKeyValid() { err = " Environment variable WTF_OWM_API_KEY is not set\n" } cityData := widget.currentData() if err == "" && cityData == nil { err += " Weather data is unavailable: no city data\n" } if err == "" && len(cityData.Weather) == 0 { err += " Weather data is unavailable: no weather data\n" } title := widget.CommonSettings().Title setWrap := false var content string if err != "" { setWrap = true content = err } else { title = widget.buildTitle(cityData) _, _, width, _ := widget.View.GetRect() content = widget.settings.PaginationMarker(len(widget.Data), widget.Idx, width) + "\n" if widget.settings.compact { content += widget.description(cityData) + "\n" } else { content += widget.description(cityData) + "\n\n" } content += widget.temperatures(cityData) + "\n" content += widget.sunInfo(cityData) } return title, content, setWrap } func (widget *Widget) description(cityData *owm.CurrentWeatherData) string { descs := []string{} for _, weather := range cityData.Weather { descs = append(descs, fmt.Sprintf(" %s", weather.Description)) } return strings.Join(descs, ",") } func (widget *Widget) sunInfo(cityData *owm.CurrentWeatherData) string { sunriseTime := wtf.UnixTime(int64(cityData.Sys.Sunrise)) sunsetTime := wtf.UnixTime(int64(cityData.Sys.Sunset)) renderStr := fmt.Sprintf(" Rise: %s Set: %s", sunriseTime.Format("15:04 MST"), sunsetTime.Format("15:04 MST")) if widget.settings.compact { renderStr = fmt.Sprintf(" Sun: %s / %s", sunriseTime.Format("15:04"), sunsetTime.Format("15:04")) } return renderStr } func (widget *Widget) temperatures(cityData *owm.CurrentWeatherData) string { str := fmt.Sprintf("%8s: %4.1f° %s\n", "High", cityData.Main.TempMax, widget.settings.tempUnit) str += fmt.Sprintf( "%8s: [%s]%4.1f° %s[white]\n", "Current", widget.settings.current, cityData.Main.Temp, widget.settings.tempUnit, ) if widget.settings.compact { str += fmt.Sprintf("%8s: %4.1f° %s", "Low", cityData.Main.TempMin, widget.settings.tempUnit) } else { str += fmt.Sprintf("%8s: %4.1f° %s\n", "Low", cityData.Main.TempMin, widget.settings.tempUnit) } return str } func (widget *Widget) buildTitle(cityData *owm.CurrentWeatherData) string { if widget.settings.useEmoji { return fmt.Sprintf("%s %s", widget.emojiFor(cityData), cityData.Name) } return cityData.Name } ================================================ FILE: modules/weatherservices/weather/emoji.go ================================================ package weather import ( owm "github.com/briandowns/openweathermap" ) var weatherEmoji = map[string]string{ "default": "💥", "broken clouds": "🌤", "clear": "🌎", "clear sky": "🌎", "cloudy": "⛅️", "few clouds": "🌤", "fog": "🌫", "haze": "🌫", "heavy intensity rain": "💦", "heavy rain": "💦", "heavy snow": "⛄️", "light intensity drizzle": "🌧", "light intensity shower rain": "☔️", "light rain": "🌦", "light shower snow": "🌦⛄️", "light snow": "🌨", "mist": "🌬", "moderate rain": "🌧", "moderate snow": "🌨", "overcast": "🌥", "overcast clouds": "🌥", "partly cloudy": "🌤", "scattered clouds": "🌤", "shower rain": "☔️", "smoke": "🔥", "snow": "❄️", "sunny": "☀️", "thunderstorm": "⛈", } func (widget *Widget) emojiFor(data *owm.CurrentWeatherData) string { if len(data.Weather) == 0 { return "" } emoji := weatherEmoji[data.Weather[0].Description] if emoji == "" { emoji = weatherEmoji["default"] } return emoji } ================================================ FILE: modules/weatherservices/weather/keyboard.go ================================================ package weather import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("h", widget.PrevSource, "Select previous city") widget.SetKeyboardChar("l", widget.NextSource, "Select next city") widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous city") widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next city") } ================================================ FILE: modules/weatherservices/weather/settings.go ================================================ package weather import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Weather" ) type colors struct { current string } type Settings struct { colors *cfg.Common apiKey string cityIDs []interface{} language string tempUnit string useEmoji bool compact bool } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_OWM_API_KEY"))), cityIDs: ymlConfig.UList("cityids"), language: ymlConfig.UString("language", "EN"), tempUnit: ymlConfig.UString("tempUnit", "C"), useEmoji: ymlConfig.UBool("useEmoji", true), compact: ymlConfig.UBool("compact", false), } settings.SetDocumentationPath("weather_services/weather/") settings.current = ymlConfig.UString("colors.current", "green") return &settings } ================================================ FILE: modules/weatherservices/weather/widget.go ================================================ package weather import ( owm "github.com/briandowns/openweathermap" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // Widget is the container for weather data. type Widget struct { view.MultiSourceWidget view.TextWidget // APIKey string Data []*owm.CurrentWeatherData pages *tview.Pages settings *Settings } // NewWidget creates and returns a new instance of the weather Widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ MultiSourceWidget: view.NewMultiSourceWidget(settings.Common, "cityid", "cityids"), TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.Common), pages: pages, settings: settings, } widget.initializeKeyboardControls() widget.SetDisplayFunction(widget.display) return &widget } /* -------------------- Exported Functions -------------------- */ // Fetch retrieves OpenWeatherMap data from the OpenWeatherMap API. // It takes a list of OpenWeatherMap city IDs. // It returns a list of OpenWeatherMap CurrentWeatherData structs, one per valid city code. func (widget *Widget) Fetch(cityIDs []int) []*owm.CurrentWeatherData { data := []*owm.CurrentWeatherData{} for _, cityID := range cityIDs { result, err := widget.currentWeather(cityID) if err == nil { data = append(data, result) } } return data } // Refresh fetches new data from the OpenWeatherMap API and loads the new data into the. // widget's view for rendering func (widget *Widget) Refresh() { if widget.apiKeyValid() { widget.Data = widget.Fetch(utils.ToInts(widget.settings.cityIDs)) } widget.display() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) apiKeyValid() bool { if widget.settings.apiKey == "" { return false } if len(widget.settings.apiKey) != 32 { return false } return true } func (widget *Widget) currentData() *owm.CurrentWeatherData { if len(widget.Data) == 0 { return nil } if widget.Idx < 0 || widget.Idx >= len(widget.Data) { return nil } return widget.Data[widget.Idx] } func (widget *Widget) currentWeather(cityCode int) (*owm.CurrentWeatherData, error) { weather, err := owm.NewCurrent( widget.settings.tempUnit, widget.settings.language, widget.settings.apiKey, ) if err != nil { return nil, err } err = weather.CurrentByID(cityCode) if err != nil { return nil, err } return weather, nil } ================================================ FILE: modules/zendesk/client.go ================================================ package zendesk import ( "fmt" "io" "net/http" ) type Resource struct { Response interface{} Raw string } func (widget *Widget) api(meth string) (*Resource, error) { trn := &http.Transport{} client := &http.Client{ Transport: trn, } baseURL := fmt.Sprintf("https://%v.zendesk.com/api/v2", widget.settings.subdomain) URL := baseURL + "/tickets.json?sort_by=status" req, err := http.NewRequest(meth, URL, http.NoBody) if err != nil { return nil, err } req.Header.Add("Content-Type", "application/json") apiUser := fmt.Sprintf("%v/token", widget.settings.username) req.SetBasicAuth(apiUser, widget.settings.apiKey) resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return &Resource{Response: &resp, Raw: string(data)}, nil } ================================================ FILE: modules/zendesk/keyboard.go ================================================ package zendesk import "github.com/gdamore/tcell/v2" func (widget *Widget) initializeKeyboardControls() { widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) widget.InitializeRefreshKeyboardControl(widget.Refresh) widget.SetKeyboardChar("j", widget.Next, "Select next item") widget.SetKeyboardChar("k", widget.Prev, "Select previous item") widget.SetKeyboardChar("o", widget.openTicket, "Open item") widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") widget.SetKeyboardKey(tcell.KeyEnter, widget.openTicket, "Open item") } ================================================ FILE: modules/zendesk/settings.go ================================================ package zendesk import ( "os" "github.com/olebedev/config" "github.com/wtfutil/wtf/cfg" ) const ( defaultFocusable = true defaultTitle = "Zendesk" ) type Settings struct { *cfg.Common apiKey string status string subdomain string username string } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("ZENDESK_API"))), status: ymlConfig.UString("status"), subdomain: ymlConfig.UString("subdomain", os.Getenv("ZENDESK_SUBDOMAIN")), username: ymlConfig.UString("username"), } cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load() return &settings } ================================================ FILE: modules/zendesk/tickets.go ================================================ package zendesk import ( "encoding/json" "log" ) type TicketArray struct { Count int `json:"count"` Created string `json:"created"` Next_page string `json:"next_page"` Previous_page string `json:"previous_page"` Tickets []Ticket } type Ticket struct { Id uint64 `json:"id"` URL string `json:"url"` ExternalId string `json:"external_id"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Type string `json:"type"` Subject string `json:"subject"` RawSubject string `json:"raw_subject"` Description string `json:"description"` Priority string `json:"priority"` Status string `json:"status"` Recipient string `json:"recipient"` RequesterId uint64 `json:"requester_id"` SubmitterId uint64 `json:"submitter_id"` AssigneeId uint64 `json:"assignee_id"` OrganizationId uint32 `json:"organization_id"` GroupId uint32 `json:"group_id"` CollaboratorIds []int64 `json:"collaborator_ids"` ForumTopicId uint32 `json:"forum_topic_id"` ProblemId uint32 `json:"problem_id"` HasIncidents bool `json:"has_incidents"` DueAt string `json:"due_at"` Tags []string `json:"tags"` Satisfaction_rating string `json:"satisfaction_rating"` Ticket_form_id uint32 `json:"ticket_form_id"` Sharing_agreement_ids interface{} `json:"sharing_agreement_ids"` Via interface{} `json:"via"` Custom_Fields interface{} `json:"custom_fields"` Fields interface{} `json:"fields"` } func (widget *Widget) listTickets(pag ...string) (*TicketArray, error) { tickets := &TicketArray{} resource, err := widget.api("GET") if err != nil { return nil, err } err = json.Unmarshal([]byte(resource.Raw), tickets) if err != nil { return nil, err } return tickets, err } func (widget *Widget) newTickets() (*TicketArray, error) { newTicketArray := &TicketArray{} tickets, err := widget.listTickets(widget.settings.apiKey) if err != nil { log.Fatal(err) } for _, Ticket := range tickets.Tickets { if Ticket.Status == widget.settings.status && Ticket.Status != "closed" && Ticket.Status != "solved" { newTicketArray.Tickets = append(newTicketArray.Tickets, Ticket) } } return newTicketArray, nil } ================================================ FILE: modules/zendesk/widget.go ================================================ package zendesk import ( "fmt" "github.com/rivo/tview" "github.com/wtfutil/wtf/utils" "github.com/wtfutil/wtf/view" ) // A Widget represents a Zendesk widget type Widget struct { view.ScrollableWidget result *TicketArray settings *Settings err error } // NewWidget creates a new instance of a widget func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { widget := Widget{ ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), settings: settings, } widget.SetRenderFunction(widget.Render) widget.initializeKeyboardControls() return &widget } /* -------------------- Exported Functions -------------------- */ func (widget *Widget) Refresh() { ticketArray, err := widget.newTickets() ticketArray.Count = len(ticketArray.Tickets) widget.err = err widget.result = ticketArray widget.Render() } /* -------------------- Unexported Functions -------------------- */ func (widget *Widget) Render() { widget.Redraw(widget.content) } func (widget *Widget) content() (string, string, bool) { title := fmt.Sprintf("%s (%d)", widget.CommonSettings().Title, widget.result.Count) if widget.err != nil { return title, widget.err.Error(), true } items := widget.result.Tickets if len(items) == 0 { return title, "No unassigned tickets in queue - woop!!", false } str := "" for idx, data := range items { str += widget.format(data, idx) } return title, str, false } func (widget *Widget) format(ticket Ticket, idx int) string { textColor := widget.settings.Colors.Background if idx == widget.GetSelected() { textColor = widget.settings.Colors.Focused } requesterName := widget.parseRequester(ticket) str := fmt.Sprintf(" [%s:]%d - %s\n %s\n\n", textColor, ticket.Id, requesterName, ticket.Subject) return str } // this is a nasty means of extracting the actual name of the requester from the Via interface of the Ticket. // very very open to improvements on this func (widget *Widget) parseRequester(ticket Ticket) interface{} { viaMap := ticket.Via via := viaMap.(map[string]interface{}) source := via["source"] fromMap, _ := source.(map[string]interface{}) from := fromMap["from"] fromValMap := from.(map[string]interface{}) fromName := fromValMap["name"] return fromName } func (widget *Widget) openTicket() { sel := widget.GetSelected() if sel >= 0 && widget.result != nil && sel < len(widget.result.Tickets) { issue := &widget.result.Tickets[sel] ticketURL := fmt.Sprintf("https://%s.zendesk.com/agent/tickets/%d", widget.settings.subdomain, issue.Id) utils.OpenFile(ticketURL) } } ================================================ FILE: scripts/check-uncommitted-vendor-files.sh ================================================ #!/bin/bash set -euo pipefail GOPROXY="https://proxy.golang.org,direct" GOSUMDB=off GO111MODULE=on go mod tidy untracked_files=$(git ls-files --others --exclude-standard | wc -l) diff_stat=$(git diff --shortstat) if [[ "${untracked_files}" -ne 0 || -n "${diff_stat}" ]]; then echo 'Untracked or diff in tracked vendor files found. Please run "go mod tidy" and commit the changes' exit 1 fi ================================================ FILE: support/github.go ================================================ package support import ( "context" "errors" "net/http" ghb "github.com/google/go-github/v32/github" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" "golang.org/x/sync/errgroup" ) var sponsorQuery struct { User struct { SponsorshipsAsSponsor struct { Nodes []struct { Sponsorable struct { SponsorsListing struct { Slug string } } } } `graphql:"sponsorshipsAsSponsor(first: 10)"` } `graphql:"user(login: $loginName)"` } // GitHubUser represents a GitHub user account as defined by a GitHub API access key // This is used to determine whether or not the WTF user is a sponsor (via GitHub sponsors) // and/or a contributor to WTF type GitHubUser struct { apiKey string loginName string clientV3 *ghb.Client clientV4 *githubv4.Client IsContributor bool IsSponsor bool } // NewGitHubUser creates and returns an instance of GitHub user with the boolean fields // populated func NewGitHubUser(githubAPIKey string) *GitHubUser { ghUser := GitHubUser{ apiKey: githubAPIKey, clientV3: nil, clientV4: nil, loginName: "", IsContributor: false, IsSponsor: false, } if ghUser.hasAPIKey() { // Use the v3 API to get the contributors because this doesn't seem to be supported by the v4 API yet clientV3, _ := ghUser.authenticateV3() ghUser.clientV3 = clientV3 // Use the v4 API to get sponsors because this doesn't seem to be supported in v3 clientV4, _ := ghUser.authenticateV4() ghUser.clientV4 = clientV4 } return &ghUser } /* -------------------- Exported Functions -------------------- */ // Load loads the user's data from GitHub func (ghUser *GitHubUser) Load() error { err := ghUser.verifyGitHubClients() if err != nil { return err } err = ghUser.loadGitHubData() if err != nil { return err } return nil } /* -------------------- Unexported Functions -------------------- */ func (ghUser *GitHubUser) authenticateV3() (*ghb.Client, error) { src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: ghUser.apiKey}, ) oauthClient := oauth2.NewClient(context.Background(), src) client := ghb.NewClient(oauthClient) return client, nil } func (ghUser *GitHubUser) authenticateV4() (*githubv4.Client, error) { src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: ghUser.apiKey}, ) oauthClient := oauth2.NewClient(context.Background(), src) client := githubv4.NewClient(oauthClient) return client, nil } // hasAPIKey returns TRUE if the user has put a GitHub API key into their // configuration and we've managed to find and read it func (ghUser *GitHubUser) hasAPIKey() bool { return ghUser.apiKey != "" } func (ghUser *GitHubUser) loadGitHubData() error { var err error login, err := ghUser.loadLoginName() if err != nil { return err } ghUser.loginName = login var isContrib, isSponsor bool ctx := context.Background() g, ctx := errgroup.WithContext(ctx) g.Go(func() error { isContrib, err = ghUser.loadContributorStatus(ctx) return err }) g.Go(func() error { isSponsor, err = ghUser.loadSponsorStatus(ctx) return err }) err = g.Wait() if err != nil { return err } ghUser.IsContributor = isContrib ghUser.IsSponsor = isSponsor return nil } // loadLoginName figures out the GitHub user's login name from their API key func (ghUser *GitHubUser) loadLoginName() (string, error) { user, _, err := ghUser.clientV3.Users.Get(context.Background(), "") if err != nil { return "", err } login := user.GetLogin() return login, nil } // loadContributorStatus figures out if this GitHub account has contributed to WTF func (ghUser *GitHubUser) loadContributorStatus(ctx context.Context) (bool, error) { page := 1 isContributor := false for { opts := &ghb.ListContributorsOptions{ ListOptions: ghb.ListOptions{ Page: page, PerPage: 100, }, } contributors, resp, err := ghUser.clientV3.Repositories.ListContributors(ctx, "wtfutil", "wtf", opts) if err != nil { return false, err } if resp.StatusCode != http.StatusOK || len(contributors) < 1 { break } for _, contrib := range contributors { if contrib.GetLogin() == ghUser.loginName { isContributor = true break } } page++ } return isContributor, nil } // loadSponsorStatus figures out if this GitHub account has sponsored WTF func (ghUser *GitHubUser) loadSponsorStatus(ctx context.Context) (bool, error) { vars := map[string]interface{}{ "loginName": githubv4.String(ghUser.loginName), } err := ghUser.clientV4.Query(ctx, &sponsorQuery, vars) if err != nil { return false, err } isSponsor := false for _, spon := range sponsorQuery.User.SponsorshipsAsSponsor.Nodes { if spon.Sponsorable.SponsorsListing.Slug == "sponsors-felicianotech" { isSponsor = true break } } return isSponsor, nil } func (ghUser *GitHubUser) verifyGitHubClients() error { if ghUser.clientV3 == nil { return errors.New("github client v3 failed to load") } if ghUser.clientV4 == nil { return errors.New("github client v4 failed to load") } return nil } ================================================ FILE: utils/colors.go ================================================ package utils import "fmt" // ColorizePercent provides a standard way to colorize percentages for which // large numbers are good (green) and small numbers are bad (red). func ColorizePercent(percent float64) string { var color string switch { case percent >= 70: color = "green" case percent >= 35: color = "yellow" case percent < 0: color = "grey" default: color = "red" } return fmt.Sprintf("[%s]%v[%s]", color, percent, "white") } ================================================ FILE: utils/colors_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_ColorizePercent(t *testing.T) { tests := []struct { name string percent float64 expected string }{ { name: "with high percent", percent: 70, expected: "[green]70[white]", }, { name: "with medium percent", percent: 35, expected: "[yellow]35[white]", }, { name: "with low percent", percent: 1, expected: "[red]1[white]", }, { name: "with negative percent", percent: -5, expected: "[grey]-5[white]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := ColorizePercent(tt.percent) assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: utils/conversions.go ================================================ package utils import ( "strconv" ) /* -------------------- Map Conversion -------------------- */ // MapToStrs takes a map of interfaces and returns a map of strings func MapToStrs(aMap map[string]interface{}) map[string]string { results := make(map[string]string, len(aMap)) for key, val := range aMap { results[key] = val.(string) } return results } /* -------------------- Slice Conversion -------------------- */ // IntsToUints takes a slice of ints and returns a slice of uints func IntsToUints(slice []int) []uint { results := make([]uint, len(slice)) for i, val := range slice { results[i] = uint(val) } return results } // ToInts takes a slice of interfaces and returns a slice of ints func ToInts(slice []interface{}) []int { results := make([]int, len(slice)) for i, val := range slice { results[i] = val.(int) } return results } // ToStrs takes a slice of interfaces and returns a slice of strings func ToStrs(slice []interface{}) []string { results := make([]string, len(slice)) for i, val := range slice { switch t := val.(type) { case int: results[i] = strconv.Itoa(t) case string: results[i] = t } } return results } // ToUints takes a slice of interfaces and returns a slice of ints func ToUints(slice []interface{}) []uint { results := make([]uint, len(slice)) for i, val := range slice { results[i] = val.(uint) } return results } ================================================ FILE: utils/conversions_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_MapToStrs(t *testing.T) { expected := map[string]string{ "a": "a", "b": "b", "c": "c", } source := make(map[string]interface{}) for _, val := range expected { source[val] = val } assert.Equal(t, expected, MapToStrs(source)) } func Test_IntsToUints(t *testing.T) { tests := []struct { name string src []int expected []uint }{ { name: "empty set", src: []int{}, expected: []uint{}, }, { name: "full set", src: []int{1, 2, 3}, expected: []uint{1, 2, 3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := IntsToUints(tt.src) assert.Equal(t, tt.expected, actual) }) } } func Test_ToInts(t *testing.T) { expected := []int{1, 2, 3} source := make([]interface{}, len(expected)) for idx, val := range expected { source[idx] = val } assert.Equal(t, expected, ToInts(source)) } func Test_ToStrs(t *testing.T) { expectedInts := []int{1, 2, 3} expectedStrs := []string{"1", "2", "3"} fromInts := make([]interface{}, 3) for idx, val := range expectedInts { fromInts[idx] = val } fromStrs := make([]interface{}, 3) for idx, val := range expectedStrs { fromStrs[idx] = val } assert.Equal(t, expectedStrs, ToStrs(fromInts)) assert.Equal(t, expectedStrs, ToStrs(fromStrs)) } func Test_ToUints(t *testing.T) { expected := []uint{1, 2, 3} source := make([]interface{}, len(expected)) for idx, val := range expected { source[idx] = val } assert.Equal(t, expected, ToUints(source)) } ================================================ FILE: utils/email_addresses.go ================================================ package utils import ( "strings" "golang.org/x/text/cases" "golang.org/x/text/language" ) // NameFromEmail takes an email address and returns the part that comes before the @ symbol // // Example: // // NameFromEmail("test_user@example.com") // > "Test_user" func NameFromEmail(email string) string { parts := strings.Split(email, "@") name := strings.ReplaceAll(parts[0], ".", " ") c := cases.Title(language.English) return c.String(name) } // NamesFromEmails takes a slice of email addresses and returns a slice of the parts that // come before the @ symbol // // Example: // // NamesFromEmail("test_user@example.com", "other_user@example.com") // > []string{"Test_user", "Other_user"} func NamesFromEmails(emails []string) []string { names := make([]string, len(emails)) for i, email := range emails { names[i] = NameFromEmail(email) } return names } ================================================ FILE: utils/email_addresses_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_NameFromEmail(t *testing.T) { assert.Equal(t, "", NameFromEmail("")) assert.Equal(t, "Chris Cummer", NameFromEmail("chris.cummer@me.com")) } func Test_NamesFromEmails(t *testing.T) { var result []string result = NamesFromEmails([]string{}) assert.Equal(t, []string{}, result) result = NamesFromEmails([]string{"chris.cummer@me.com", "chriscummer@me.com"}) assert.Equal(t, []string{"Chris Cummer", "Chriscummer"}, result) } ================================================ FILE: utils/help_parser.go ================================================ package utils import ( "reflect" "regexp" "strconv" "unicode" "unicode/utf8" "github.com/wtfutil/wtf/cfg" ) /* -------------------- Exported Functions -------------------- */ func HelpFromInterface(item interface{}) string { result := "" t := reflect.TypeOf(item) for i := 0; i < t.NumField(); i++ { field := t.Field(i) kind := field.Type.Kind() if field.Type.Kind() == reflect.Ptr { kind = field.Type.Elem().Kind() } if field.Name == "Common" { result += HelpFromInterface(cfg.Common{}) } switch kind { case reflect.Interface: result += HelpFromInterface(field.Type.Elem()) default: result += helpFromValue(field) } } return result } // StripColorTags removes tcell color tags from a given string func StripColorTags(input string) string { openColorRegex := regexp.MustCompile(`\[.*?\]`) return openColorRegex.ReplaceAllString(input, "") } /* -------------------- Unexported Functions -------------------- */ func helpFromValue(field reflect.StructField) string { result := "" optional, err := strconv.ParseBool(field.Tag.Get("optional")) if err != nil { optional = false } help := field.Tag.Get("help") if optional { help = "Optional " + help } values := field.Tag.Get("values") if help != "" { result += "\n\n " + lowercaseTitle(field.Name) result += "\n " + help if values != "" { result += "\n Values: " + values } } return result } func lowercaseTitle(title string) string { if title == "" { return "" } r, n := utf8.DecodeRuneInString(title) return string(unicode.ToLower(r)) + title[n:] } ================================================ FILE: utils/homedir.go ================================================ // Package homedir helps with detecting and expanding the user's home directory // Copied (mostly) verbatim from https://github.com/Atrox/homedir package utils import ( "errors" "os" "path/filepath" ) // ExpandHomeDir expands the path to include the home directory if the path // is prefixed with `~`. If it isn't prefixed with `~`, the path is // returned as-is. func ExpandHomeDir(path string) (string, error) { if path == "" { return path, nil } if path[0] != '~' { return path, nil } if len(path) > 1 && path[1] != '/' && path[1] != '\\' { return "", errors.New("cannot expand user-specific home dir") } dir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(dir, path[1:]), nil } ================================================ FILE: utils/homedir_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_ExpandHomeDir(t *testing.T) { tests := []struct { name string path string expectedStart string expectedContains string expectedError error }{ { name: "with empty path", path: "", expectedStart: "", expectedContains: "", expectedError: nil, }, { name: "with relative path", path: "~/test", expectedStart: "/", expectedContains: "/test", expectedError: nil, }, { name: "with absolute path", path: "/Users/test", expectedStart: "/", expectedContains: "/test", expectedError: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual, err := ExpandHomeDir(tt.path) if len(tt.path) > 0 { assert.Equal(t, tt.expectedStart, string(actual[0])) } assert.Contains(t, actual, tt.expectedContains) assert.Equal(t, tt.expectedError, err) }) } } ================================================ FILE: utils/init.go ================================================ package utils // OpenFileUtil defines the system utility to use to open files var OpenFileUtil = "open" var OpenUrlUtil = []string{} // Init initializes global settings in the wtf package func Init(openFileUtil string, openUrlUtil []string) { OpenFileUtil = openFileUtil OpenUrlUtil = openUrlUtil } ================================================ FILE: utils/init_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_Init(t *testing.T) { Init("cats", []string{"dogs"}) assert.Equal(t, OpenFileUtil, "cats") assert.Equal(t, OpenUrlUtil, []string{"dogs"}) } ================================================ FILE: utils/reflective.go ================================================ package utils import ( "fmt" "reflect" ) // StringValueForProperty returns a string value for the given property // If the property doesn't exist, it returns an error func StringValueForProperty(ref interface{}, propName string) (string, error) { v := reflect.ValueOf(ref) refVal := reflect.Indirect(v).FieldByName(propName) if !refVal.IsValid() { return "", fmt.Errorf("invalid property name: %s", propName) } strVal := fmt.Sprintf("%v", refVal) return strVal, nil } ================================================ FILE: utils/sums.go ================================================ package utils // SumInts takes a slice of ints and returns the sum of them func SumInts(vals []int) int { sum := 0 for _, a := range vals { sum += a } return sum } ================================================ FILE: utils/sums_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_SumInts(t *testing.T) { expected := 6 result := SumInts([]int{1, 3, 2}) assert.Equal(t, expected, result) expected = 46 result = SumInts([]int{4, 6, 7, 23, 6}) assert.Equal(t, expected, result) expected = 4 result = SumInts([]int{4}) assert.Equal(t, expected, result) } ================================================ FILE: utils/text.go ================================================ package utils import ( "fmt" "math" "strings" "golang.org/x/text/message" "github.com/rivo/tview" ) // CenterText takes a string and a width and pads the left and right of the string with // empty spaces to ensure that the string is in the middle of the returned value // // Example: // // x := CenterText("cat", 11) // > " cat " func CenterText(str string, width int) string { if width < 0 { width = 0 } return fmt.Sprintf("%[1]*s", -width, fmt.Sprintf("%[1]*s", (width+len(str))/2, str)) } // FindBetween finds and returns the text between two strings // // Example: // // a := "{ cat } { dog }" // b := FindBetween(a, "{", "}") // > [" cat ", " dog "] func FindBetween(input string, left string, right string) []string { out := []string{} i := 0 for i >= 0 { i = strings.Index(input, left) if i == -1 { break } i += len(left) e := strings.Index(input[i:], right) if e == -1 { break } if e <= i { break } chunk := input[i : e+1] input = input[i+e+1:] out = append(out, chunk) i = i + e } return out } // HighlightableHelper pads the given text with blank spaces to the width of the view // containing it. This is helpful for extending row highlighting across the entire width // of the view func HighlightableHelper(view *tview.TextView, input string, idx, offset int) string { _, _, w, _ := view.GetInnerRect() fmtStr := fmt.Sprintf(`["%d"][""]`, idx) fmtStr += input fmtStr += RowPadding(offset, w) fmtStr += `[""]` + "\n" return fmtStr } // RowPadding returns a padding for a row to make it the full width of the containing widget. // Useful for ensuring row highlighting spans the full width (I suspect tcell has a better // way to do this, but I haven't yet found it) func RowPadding(offset int, max int) string { padSize := max - offset if padSize < 0 { padSize = 0 } return strings.Repeat(" ", padSize) } // Truncate chops a given string at len length. Appends an ellipse character if warranted func Truncate(src string, maxLen int, withEllipse bool) string { if len(src) < 1 || maxLen < 1 { return "" } if maxLen == 1 { return src[:1] } var runeCount = 0 for idx := range src { runeCount++ if runeCount > maxLen { if withEllipse { return src[:idx-1] + "…" } return src[:idx] } } return src } // PrettyNumber formats number as string with 1000 delimiters and, if necessary, rounds it to 2 decimals func PrettyNumber(prtr *message.Printer, number float64) string { if number == math.Trunc(number) { return prtr.Sprintf("%.0f", number) } return prtr.Sprintf("%.2f", number) } ================================================ FILE: utils/text_test.go ================================================ package utils import ( "testing" "github.com/rivo/tview" "github.com/stretchr/testify/assert" "golang.org/x/text/language" "golang.org/x/text/message" ) func Test_CenterText(t *testing.T) { assert.Equal(t, "cat", CenterText("cat", -9)) assert.Equal(t, "cat", CenterText("cat", 0)) assert.Equal(t, " cat ", CenterText("cat", 9)) } func Test_FindBetween(t *testing.T) { tests := []struct { name string input string left string right string expected []string }{ { name: "with empty params", input: "", left: "", right: "", expected: []string{}, }, { name: "with empty input", input: "", left: "{", right: "}", expected: []string{}, }, { name: "with empty bounds", input: "{cat}{dog}", left: "", right: "", expected: []string{}, }, { name: "with no match left", input: "{cat}{dog}", left: "[", right: "}", expected: []string{}, }, { name: "with no match right", input: "{cat}{dog}", left: "{", right: "]", expected: []string{}, }, { name: "with right before left", input: "{cat}{dog}", left: "}", right: "{", expected: []string{}, }, { name: "with no match", input: "{cat}{dog}", left: "[", right: "]", expected: []string{}, }, { name: "with valid input", input: "{cat}{dog}", left: "{", right: "}", expected: []string{"cat", "dog"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := FindBetween(tt.input, tt.left, tt.right) assert.Equal(t, tt.expected, actual) }) } } func Test_HighlightableHelper(t *testing.T) { view := tview.NewTextView() actual := HighlightableHelper(view, "cats", 0, 5) assert.Equal(t, "[\"0\"][\"\"]cats [\"\"]\n", actual) } func Test_RowPadding(t *testing.T) { assert.Equal(t, "", RowPadding(0, 0)) assert.Equal(t, "", RowPadding(5, 2)) assert.Equal(t, " ", RowPadding(1, 2)) assert.Equal(t, " ", RowPadding(0, 5)) } func Test_Truncate(t *testing.T) { assert.Equal(t, "", Truncate("cat", 0, false)) assert.Equal(t, "c", Truncate("cat", 1, false)) assert.Equal(t, "ca", Truncate("cat", 2, false)) assert.Equal(t, "cat", Truncate("cat", 3, false)) assert.Equal(t, "cat", Truncate("cat", 4, false)) assert.Equal(t, "", Truncate("cat", 0, true)) assert.Equal(t, "c", Truncate("cat", 1, true)) assert.Equal(t, "c…", Truncate("cat", 2, true)) assert.Equal(t, "cat", Truncate("cat", 3, true)) assert.Equal(t, "cat", Truncate("cat", 4, true)) // Only supports non-ellipsed emoji assert.Equal(t, "🌮🚙", Truncate("🌮🚙💥👾", 2, false)) } func Test_PrettyNumber(t *testing.T) { locPrinter := message.NewPrinter(language.English) assert.Equal(t, "1,000,000", PrettyNumber(locPrinter, 1000000)) assert.Equal(t, "1,000,000.99", PrettyNumber(locPrinter, 1000000.99)) assert.Equal(t, "1,000,000", PrettyNumber(locPrinter, 1000000.00)) assert.Equal(t, "100,000", PrettyNumber(locPrinter, 100000)) assert.Equal(t, "100,000.01", PrettyNumber(locPrinter, 100000.009)) assert.Equal(t, "10,000", PrettyNumber(locPrinter, 10000)) assert.Equal(t, "1,000", PrettyNumber(locPrinter, 1000)) assert.Equal(t, "1,000", PrettyNumber(locPrinter, 1000)) assert.Equal(t, "100", PrettyNumber(locPrinter, 100)) assert.Equal(t, "0", PrettyNumber(locPrinter, 0)) assert.Equal(t, "0.10", PrettyNumber(locPrinter, 0.1)) } ================================================ FILE: utils/utils.go ================================================ package utils import ( "bytes" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "github.com/logrusorgru/aurora/v4" "github.com/olebedev/config" ) const ( SimpleDateFormat = "Jan 2" SimpleTimeFormat = "15:04 MST" MinimumTimeFormat12 = "3:04 PM" MinimumTimeFormat24 = "15:04" FullDateFormat = "Monday, Jan 2" FriendlyDateFormat = "Mon, Jan 2" FriendlyDateTimeFormat = "Mon, Jan 2, 15:04" TimestampFormat = "2006-01-02T15:04:05-0700" ) // DoesNotInclude takes a slice of strings and a target string and returns // TRUE if the slice does not include the target, FALSE if it does // // Example: // // x := DoesNotInclude([]string{"cat", "dog", "rat"}, "dog") // > false // // x := DoesNotInclude([]string{"cat", "dog", "rat"}, "pig") // > true func DoesNotInclude(strs []string, val string) bool { return !Includes(strs, val) } // ExecuteCommand executes an external command on the local machine as the current user func ExecuteCommand(cmd *exec.Cmd) string { if cmd == nil { return "" } buf := &bytes.Buffer{} cmd.Stdout = buf if err := cmd.Run(); err != nil { return err.Error() } return buf.String() } // FindMatch takes a regex pattern and a string of data and returns back all the matches // in that string func FindMatch(pattern string, data string) [][]string { r := regexp.MustCompile(pattern) return r.FindAllStringSubmatch(data, -1) } // Includes takes a slice of strings and a target string and returns // TRUE if the slice includes the target, FALSE if it does not // // Example: // // x := Includes([]string{"cat", "dog", "rat"}, "dog") // > true // // x := Includes([]string{"cat", "dog", "rat"}, "pig") // > false func Includes(strs []string, val string) bool { for _, str := range strs { if val == str { return true } } return false } // OpenFile opens the file defined in `path` via the operating system func OpenFile(path string) { if (strings.HasPrefix(path, "http://")) || (strings.HasPrefix(path, "https://")) { if len(OpenUrlUtil) > 0 { commands := append(OpenUrlUtil, path) cmd := exec.Command(commands[0], commands[1:]...) err := cmd.Start() if err != nil { return } return } var cmd *exec.Cmd switch runtime.GOOS { case "linux": cmd = exec.Command("xdg-open", path) case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", path) case "darwin": cmd = exec.Command("open", path) default: // for the BSDs cmd = exec.Command("xdg-open", path) } err := cmd.Start() if err != nil { return } return } filePath, _ := ExpandHomeDir(path) cmd := exec.Command(OpenFileUtil, filePath) ExecuteCommand(cmd) } // ReadFileBytes reads the contents of a file and returns those contents as a slice of bytes func ReadFileBytes(filePath string) ([]byte, error) { fileData, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { return []byte{}, err } return fileData, nil } // ParseJSON is a standard JSON reader from text func ParseJSON(obj interface{}, text io.Reader) error { d := json.NewDecoder(text) return d.Decode(obj) } // CalculateDimensions reads the module dimensions from the module and global config. The border is already subtracted. func CalculateDimensions(moduleConfig, globalConfig *config.Config) (int, int, error) { grid, err := globalConfig.Get("wtf.grid") if err != nil { return 0, 0, err } cols := ToInts(grid.UList("columns")) rows := ToInts(grid.UList("rows")) // If they're defined in the config, they cannot be empty if len(cols) == 0 || len(rows) == 0 { displayGridConfigError() os.Exit(1) } // Read the source data from the config left := moduleConfig.UInt("position.left", 0) top := moduleConfig.UInt("position.top", 0) width := moduleConfig.UInt("position.width", 0) height := moduleConfig.UInt("position.height", 0) // Make sure the values are in bounds left = Clamp(left, 0, len(cols)-1) top = Clamp(top, 0, len(rows)-1) width = Clamp(width, 0, len(cols)-left) height = Clamp(height, 0, len(rows)-top) // Start with the border subtracted and add all the spanned rows and cols w, h := -2, -2 for _, x := range cols[left : left+width] { w += x } for _, y := range rows[top : top+height] { h += y } // The usable space may be empty w = MaxInt(w, 0) h = MaxInt(h, 0) return w, h, nil } // MaxInt returns the larger of x or y // // Examples: // // MaxInt(3, 2) => 3 // MaxInt(2, 3) => 3 func MaxInt(x, y int) int { if x > y { return x } return y } // Clamp restricts values to a minimum and maximum value // // Examples: // // clamp(6, 3, 8) => 6 // clamp(1, 3, 8) => 3 // clamp(9, 3, 8) => 8 func Clamp(x, a, b int) int { if a > x { return a } if b < x { return b } return x } /* -------------------- Unexported Functions -------------------- */ func displayGridConfigError() { fmt.Printf("\n%s 'grid' config values are invalid. 'columns' and 'rows' cannot be empty.\n", aurora.Red("ERROR")) fmt.Println() fmt.Println("This is invalid:") fmt.Println() fmt.Println(" grid:") fmt.Println(" columns: []") fmt.Println(" rows: []") fmt.Println() fmt.Printf("%s If you want the columns and rows to be dynamically-determined, remove the 'grid' key and child keys from your config file.\n", aurora.Yellow("*")) fmt.Printf("%s If you want explicit widths and heights, add integer values to the 'columns' and 'rows' arrays.\n", aurora.Yellow("*")) fmt.Println() } ================================================ FILE: utils/utils_test.go ================================================ package utils import ( "os/exec" "reflect" "testing" "github.com/stretchr/testify/assert" ) func Test_DoesNotInclude(t *testing.T) { tests := []struct { name string strs []string val string expected bool }{ { name: "when included", strs: []string{"a", "b", "c"}, val: "b", expected: false, }, { name: "when not included", strs: []string{"a", "b", "c"}, val: "f", expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := DoesNotInclude(tt.strs, tt.val) if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_ExecuteCommand(t *testing.T) { tests := []struct { name string cmd *exec.Cmd expected string }{ { name: "with nil command", cmd: nil, expected: "", }, { name: "with defined command", cmd: exec.Command("echo", "cats"), expected: "cats\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := ExecuteCommand(tt.cmd) if tt.expected != actual { t.Errorf("\nexpected: %s\n got: %s", tt.expected, actual) } }) } } func Test_FindMatch(t *testing.T) { expected := [][]string{{"SSID: 7E5B5C", "7E5B5C"}} result := FindMatch(`s*SSID: (.+)s*`, "SSID: 7E5B5C") assert.Equal(t, expected, result) } func Test_Includes(t *testing.T) { tests := []struct { name string strs []string val string expected bool }{ { name: "when included", strs: []string{"a", "b", "c"}, val: "b", expected: true, }, { name: "when not included", strs: []string{"a", "b", "c"}, val: "f", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := Includes(tt.strs, tt.val) if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_ReadFileBytes(t *testing.T) { tests := []struct { name string file string expected []byte }{ { name: "with non-existent file", file: "/tmp/junk-daa6bf613f4c.md", expected: []byte{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual, _ := ReadFileBytes(tt.file) if reflect.DeepEqual(tt.expected, actual) == false { t.Errorf("\nexpected: %q\n got: %q", tt.expected, actual) } }) } } func Test_MaxInt(t *testing.T) { expected := 3 result := MaxInt(3, 2) assert.Equal(t, expected, result) expected = 3 result = MaxInt(2, 3) assert.Equal(t, expected, result) } func Test_Clamp(t *testing.T) { expected := 6 result := Clamp(6, 3, 8) assert.Equal(t, expected, result) expected = 3 result = Clamp(1, 3, 8) assert.Equal(t, expected, result) expected = 8 result = Clamp(9, 3, 8) assert.Equal(t, expected, result) } ================================================ FILE: view/bargraph.go ================================================ package view import ( "bytes" "fmt" "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/wtf" ) // BarGraph defines the data required to make a bar graph type BarGraph struct { maxStars int starChar string *Base *KeyboardWidget View *tview.TextView } // Bar defines a single row in the bar graph type Bar struct { Label string Percent int ValueLabel string LabelColor string } // NewBarGraph creates and returns an instance of BarGraph func NewBarGraph(tviewApp *tview.Application, redrawChan chan bool, _ string, commonSettings *cfg.Common) BarGraph { widget := BarGraph{ Base: NewBase(tviewApp, redrawChan, nil, commonSettings), KeyboardWidget: NewKeyboardWidget(commonSettings), maxStars: commonSettings.Config.UInt("graphStars", 20), starChar: commonSettings.Config.UString("graphIcon", "|"), } widget.View = widget.createView(widget.bordered) return widget } /* -------------------- Exported Functions -------------------- */ // BuildBars will build a string of * to represent your data of [time][value] // time should be passed as a int64 func (widget *BarGraph) BuildBars(data []Bar) { widget.View.SetText(BuildStars(data, widget.maxStars, widget.starChar)) widget.RedrawChan <- true } // BuildStars build the string to display func BuildStars(data []Bar, maxStars int, starChar string) string { var buffer bytes.Buffer // the number of characters in the longest label var longestLabel int //just getting min and max values for _, bar := range data { if len(bar.Label) > longestLabel { longestLabel = len(bar.Label) } } // each number = how many stars? var starRatio = float64(maxStars) / 100 //build the stars for _, bar := range data { //how many stars for this one? var starCount = int(float64(bar.Percent) * starRatio) label := bar.ValueLabel if label == "" { label = fmt.Sprint(bar.Percent) } labelColor := bar.LabelColor if labelColor == "" { labelColor = "default" } //write the line _, err := fmt.Fprintf( &buffer, "%s%s[[%s]%s[default]%s] %s\n", bar.Label, strings.Repeat(" ", longestLabel-len(bar.Label)), labelColor, strings.Repeat(starChar, starCount), strings.Repeat(" ", maxStars-starCount), label, ) if err != nil { return "" } } return buffer.String() } func (widget *BarGraph) TextView() *tview.TextView { return widget.View } /* -------------------- Unexported Functions -------------------- */ func (widget *BarGraph) createView(bordered bool) *tview.TextView { view := tview.NewTextView() view.SetBackgroundColor(wtf.ColorFor(widget.commonSettings.Colors.Background)) view.SetBorder(bordered) view.SetBorderColor(wtf.ColorFor(widget.BorderColor())) view.SetDynamicColors(true) view.SetTitle(widget.ContextualTitle(widget.CommonSettings().Title)) view.SetTitleColor(wtf.ColorFor(widget.commonSettings.Colors.Title)) view.SetWrap(false) return view } ================================================ FILE: view/bargraph_test.go ================================================ package view import ( "testing" "github.com/olebedev/config" "github.com/rivo/tview" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" ) // MakeData - Create sample data func makeData() []Bar { //this could come from config const lineCount = 3 var stats [lineCount]Bar stats[0] = Bar{ Label: "Jun 27, 2018", Percent: 20, } stats[1] = Bar{ Label: "Jul 09, 2018", Percent: 80, LabelColor: "red", } stats[2] = Bar{ Label: "Jul 09, 2018", Percent: 80, LabelColor: "green", } return stats[:] } func newTestGraph(graphStars int, graphIcon string) *BarGraph { widget := NewBarGraph( tview.NewApplication(), make(chan bool), "testapp", &cfg.Common{ Config: &config.Config{ Root: map[string]interface{}{ "graphStars": graphStars, "graphIcon": graphIcon, }, }, }, ) return &widget } func Test_NewBarGraph(t *testing.T) { widget := newTestGraph(15, "|") assert.NotNil(t, widget.View) assert.Equal(t, 15, widget.maxStars) assert.Equal(t, "|", widget.starChar) } // func Test_BuildBars(t *testing.T) { // widget := newTestGraph(15, "|") // before := widget.View.GetText(false) // widget.BuildBars(makeData()) // after := widget.View.GetText(false) // assert.NotEqual(t, before, after) // } func Test_TextView(t *testing.T) { widget := newTestGraph(15, "|") assert.NotNil(t, widget.TextView()) } func Test_BuildStars(t *testing.T) { result := BuildStars(makeData(), 20, "*") assert.Equal(t, "Jun 27, 2018[[default]****[default] ] 20\nJul 09, 2018[[red]****************[default] ] 80\nJul 09, 2018[[green]****************[default] ] 80\n", result, ) } ================================================ FILE: view/base.go ================================================ package view import ( "fmt" "sync" "time" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) type Base struct { bordered bool commonSettings *cfg.Common enabled bool enabledMutex *sync.Mutex focusChar string focusable bool helpTextFunc func() string name string pages *tview.Pages quitChan chan bool refreshInterval time.Duration refreshing bool tviewApp *tview.Application view *tview.TextView RedrawChan chan bool } // NewBase creates and returns an instance of the Base module, the lowest-level // primitive module from which all others are derived func NewBase(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, commonSettings *cfg.Common) *Base { base := &Base{ commonSettings: commonSettings, bordered: commonSettings.Bordered, enabled: commonSettings.Enabled, enabledMutex: &sync.Mutex{}, focusChar: commonSettings.FocusChar(), focusable: commonSettings.Focusable, name: commonSettings.Name, pages: pages, quitChan: make(chan bool), refreshInterval: commonSettings.RefreshInterval, refreshing: false, tviewApp: tviewApp, RedrawChan: redrawChan, } return base } /* -------------------- Exported Functions -------------------- */ // Bordered returns whether or not this widget should be drawn with a border func (base *Base) Bordered() bool { return base.bordered } // BorderColor returns the color that the border of this widget should be drawn in func (base *Base) BorderColor() string { if base.Focusable() { return base.commonSettings.Colors.Focusable } return base.commonSettings.Colors.Unfocusable } func (base *Base) CommonSettings() *cfg.Common { return base.commonSettings } func (base *Base) ConfigText() string { return utils.HelpFromInterface(cfg.Common{}) } func (base *Base) ContextualTitle(defaultStr string) string { switch { case defaultStr == "" && base.FocusChar() == "": return "" case defaultStr != "" && base.FocusChar() == "": return fmt.Sprintf(" %s ", defaultStr) case defaultStr == "" && base.FocusChar() != "": return fmt.Sprintf(" [darkgray::u]%s[::-][white] ", base.FocusChar()) } return fmt.Sprintf(" %s [darkgray::u]%s[::-][white] ", defaultStr, base.FocusChar()) } func (base *Base) Disable() { base.enabledMutex.Lock() base.enabled = false base.enabledMutex.Unlock() } func (base *Base) Disabled() bool { base.enabledMutex.Lock() result := !base.enabled base.enabledMutex.Unlock() return result } func (base *Base) Enabled() bool { base.enabledMutex.Lock() result := base.enabled base.enabledMutex.Unlock() return result } func (base *Base) Focusable() bool { base.enabledMutex.Lock() result := base.enabled && base.focusable base.enabledMutex.Unlock() return result } func (base *Base) FocusChar() string { return base.focusChar } func (base *Base) Name() string { return base.name } func (base *Base) QuitChan() chan bool { return base.quitChan } // Refreshing returns TRUE if the base is currently refreshing its data, FALSE if it is not func (base *Base) Refreshing() bool { return base.refreshing } // RefreshInterval returns how often the base will return its data func (base *Base) RefreshInterval() time.Duration { return base.refreshInterval } func (base *Base) SetFocusChar(char string) { base.focusChar = char } // SetView assigns the passed-in tview.TextView view to this widget func (base *Base) SetView(view *tview.TextView) { base.view = view } // ShowHelp displays the modal help dialog for a module func (base *Base) ShowHelp() { if base.pages == nil { return } closeFunc := func() { base.pages.RemovePage("help") base.tviewApp.SetFocus(base.view) } modal := NewBillboardModal(base.helpTextFunc(), closeFunc) base.pages.AddPage("help", modal, false, true) base.tviewApp.SetFocus(modal) // Tell the app to force redraw the screen base.RedrawChan <- true } func (base *Base) Stop() { base.enabledMutex.Lock() base.enabled = false base.enabledMutex.Unlock() base.quitChan <- true } func (base *Base) String() string { return base.name } ================================================ FILE: view/base_test.go ================================================ package view import ( "testing" "github.com/rivo/tview" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" ) func Benchmark_ContextualTitle(b *testing.B) { b.ReportAllocs() base := NewBase( tview.NewApplication(), make(chan bool), tview.NewPages(), &cfg.Common{}, ) base.SetFocusChar("a") defaultStr := "This is test" for i := 0; i < b.N; i++ { _ = base.ContextualTitle(defaultStr) } } func Test_ContextualTitle(t *testing.T) { tests := []struct { name string defaultStr string focusChar string expected string }{ { name: "with empty defaultStr and empty focusChar", defaultStr: "", focusChar: "", expected: "", }, { name: "with valid defaultStr and empty focusChar", defaultStr: "cats", focusChar: "", expected: " cats ", }, { name: "with empty defaultStr and valid focusChar", defaultStr: "", focusChar: "a", expected: " [darkgray::u]a[::-][white] ", }, { name: "with valid defaultStr and valid focusChar", defaultStr: "cats", focusChar: "a", expected: " cats [darkgray::u]a[::-][white] ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { base := NewBase( tview.NewApplication(), make(chan bool), tview.NewPages(), &cfg.Common{}, ) base.SetFocusChar(tt.focusChar) actual := base.ContextualTitle(tt.defaultStr) assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: view/billboard_modal.go ================================================ package view import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) const offscreen = -1000 const modalWidth = 80 const modalHeight = 22 // NewBillboardModal creates and returns a modal dialog suitable for displaying // a wall of text // An example of this is the keyboard help modal that shows up for all widgets // that support keyboard control when '/' is pressed func NewBillboardModal(text string, closeFunc func()) *tview.Frame { keyboardIntercept := func(event *tcell.EventKey) *tcell.EventKey { if string(event.Rune()) == "/" { closeFunc() return nil } switch event.Key() { case tcell.KeyEsc: closeFunc() return nil case tcell.KeyTab: return nil default: return event } } textView := tview.NewTextView() textView.SetDynamicColors(true) textView.SetInputCapture(keyboardIntercept) textView.SetText(text) textView.SetWrap(true) frame := tview.NewFrame(textView) frame.SetRect(offscreen, offscreen, modalWidth, modalHeight) drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { w, h := screen.Size() frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height) return x, y, width, height } frame.SetBorder(true) frame.SetBorders(1, 1, 0, 0, 1, 1) frame.SetDrawFunc(drawFunc) return frame } ================================================ FILE: view/info_table.go ================================================ package view import ( "bytes" "sort" "github.com/olekukonko/tablewriter" ) /* An InfoTable is a two-column table of properties/values: -------------------------- ------------------------------------------------- PROPERTY VALUE -------------------------- ------------------------------------------------- CPUs 1 Created 2019-12-12T18:39:09Z Disk 25 Features ipv6 Image 18.04.3 (LTS) x64 (Ubuntu) Memory 1024 Region Toronto 1 (tor1) -------------------------- ------------------------------------------------- */ // InfoTable contains the internal guts of an InfoTable type InfoTable struct { buf *bytes.Buffer tblWriter *tablewriter.Table } // NewInfoTable creates and returns the stringified contents of a two-column table func NewInfoTable(headers []string, dataMap map[string]string, colWidth0, colWidth1, tableHeight int) *InfoTable { tbl := &InfoTable{ buf: new(bytes.Buffer), } tbl.tblWriter = tablewriter.NewWriter(tbl.buf) tbl.tblWriter.SetHeader(headers) tbl.tblWriter.SetBorder(true) tbl.tblWriter.SetCenterSeparator(" ") tbl.tblWriter.SetColumnSeparator(" ") tbl.tblWriter.SetRowSeparator("-") tbl.tblWriter.SetAlignment(tablewriter.ALIGN_LEFT) tbl.tblWriter.SetColMinWidth(0, colWidth0) tbl.tblWriter.SetColMinWidth(1, colWidth1) keys := []string{} for key := range dataMap { keys = append(keys, key) } sort.Strings(keys) // Enumerate over the alphabetically-sorted keys to render the property values for _, key := range keys { tbl.tblWriter.Append([]string{key, dataMap[key]}) } // Pad the table with extra rows to push it to the bottom paddingAmt := tableHeight - len(dataMap) - 1 if paddingAmt > 0 { for i := 0; i < paddingAmt; i++ { tbl.tblWriter.Append([]string{"", ""}) } } return tbl } // Render returns the stringified version of the table func (tbl *InfoTable) Render() string { tbl.tblWriter.Render() return tbl.buf.String() } ================================================ FILE: view/info_table_test.go ================================================ package view import ( "testing" "github.com/stretchr/testify/assert" ) func makeMap() map[string]string { m := make(map[string]string) m["foo"] = "val1" m["bar"] = "val2" m["baz"] = "val3" return m } func newTestTable(height, colWidth0, colWidth1 int) *InfoTable { var headers [2]string headers[0] = "hdr0" headers[1] = "hdr1" table := NewInfoTable( headers[:], makeMap(), colWidth0, colWidth1, height, ) return table } func Test_RenderSimpleInfoTable(t *testing.T) { table := newTestTable(4, 1, 1).Render() assert.Equal(t, " ----- ------ \n HDR0 HDR1 \n ----- ------ \n bar val2 \n baz val3 \n foo val1 \n ----- ------ \n", table) } func Test_RenderPaddedInfoTable(t *testing.T) { table := newTestTable(6, 1, 1).Render() assert.Equal(t, " ----- ------ \n HDR0 HDR1 \n ----- ------ \n bar val2 \n baz val3 \n foo val1 \n \n \n ----- ------ \n", table) } func Test_RenderWithSpecifiedWidthLeftColumn(t *testing.T) { table := newTestTable(4, 10, 1).Render() assert.Equal(t, " ------------ ------ \n HDR0 HDR1 \n ------------ ------ \n bar val2 \n baz val3 \n foo val1 \n ------------ ------ \n", table) } func Test_RenderWithSpecifiedWidthRightColumn(t *testing.T) { table := newTestTable(4, 1, 10).Render() assert.Equal(t, " ----- ------------ \n HDR0 HDR1 \n ----- ------------ \n bar val2 \n baz val3 \n foo val1 \n ----- ------------ \n", table) } func Test_RenderWithSpecifiedWidthBothColumns(t *testing.T) { table := newTestTable(4, 15, 10).Render() assert.Equal(t, " ----------------- ------------ \n HDR0 HDR1 \n ----------------- ------------ \n bar val2 \n baz val3 \n foo val1 \n ----------------- ------------ \n", table) } ================================================ FILE: view/keyboard_widget.go ================================================ package view import ( "fmt" "github.com/gdamore/tcell/v2" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" "golang.org/x/text/cases" "golang.org/x/text/language" ) const helpKeyChar = "/" const refreshKeyChar = "r" type helpItem struct { Key string Text string } // KeyboardWidget manages keyboard control for a widget type KeyboardWidget struct { settings *cfg.Common charMap map[string]func() keyMap map[tcell.Key]func() charHelp []helpItem keyHelp []helpItem maxKey int } // NewKeyboardWidget creates and returns a new instance of KeyboardWidget // func NewKeyboardWidget(tviewApp *tview.Application, pages *tview.Pages, settings *cfg.Common) *KeyboardWidget { func NewKeyboardWidget(settings *cfg.Common) *KeyboardWidget { keyWidget := &KeyboardWidget{ settings: settings, charMap: make(map[string]func()), keyMap: make(map[tcell.Key]func()), charHelp: []helpItem{}, keyHelp: []helpItem{}, } keyWidget.initializeCommonKeyboardControls() return keyWidget } /* -------------------- Exported Functions --------------------- */ // AssignedChars returns a list of all the text characters assigned to an operation func (widget *KeyboardWidget) AssignedChars() []string { chars := []string{} for char := range widget.charMap { chars = append(chars, char) } return chars } // HelpText returns the help text and keyboard command info for this widget func (widget *KeyboardWidget) HelpText() string { c := cases.Title(language.English) str := " [green::b]Keyboard commands for " + c.String(widget.settings.Type) + "[white]\n\n" for _, item := range widget.charHelp { str += fmt.Sprintf(" %s\t%s\n", item.Key, item.Text) } str += "\n\n" for _, item := range widget.keyHelp { str += fmt.Sprintf(" %-*s\t%s\n", widget.maxKey, item.Key, item.Text) } return str } // InitializeHelpTextKeyboardControl assigns the function that displays help text to the // common help text key value func (widget *KeyboardWidget) InitializeHelpTextKeyboardControl(helpFunc func()) { if helpFunc != nil { widget.SetKeyboardChar(helpKeyChar, helpFunc, "Show/hide this help prompt") } } // InitializeRefreshKeyboardControl assigns the module's explicit refresh function to // the commom refresh key value func (widget *KeyboardWidget) InitializeRefreshKeyboardControl(refreshFunc func()) { if refreshFunc != nil { widget.SetKeyboardChar(refreshKeyChar, refreshFunc, "Refresh widget") } } // InputCapture is the function passed to tview's SetInputCapture() function // This is done during the main widget's creation process using the following code: // // widget.View.SetInputCapture(widget.InputCapture) func (widget *KeyboardWidget) InputCapture(event *tcell.EventKey) *tcell.EventKey { if event == nil { return nil } fn := widget.charMap[string(event.Rune())] if fn != nil { fn() return nil } fn = widget.keyMap[event.Key()] if fn != nil { fn() return nil } return event } // LaunchDocumentation opens the module docs in a browser func (widget *KeyboardWidget) LaunchDocumentation() { path := widget.settings.DocPath if path == "" { path = widget.settings.Type } url := "https://wtfutil.com/modules/" + path utils.OpenFile(url) } // SetKeyboardChar sets a character/function combination that responds to key presses // Example: // // widget.SetKeyboardChar("d", widget.deleteSelectedItem) func (widget *KeyboardWidget) SetKeyboardChar(char string, fn func(), helpText string) { if char == "" { return } // Check to ensure that the key trying to be used isn't already being used for something if _, ok := widget.charMap[char]; ok { panic(fmt.Sprintf("Key is already mapped to a keyboard command: %s\n", char)) } widget.charMap[char] = fn widget.charHelp = append(widget.charHelp, helpItem{char, helpText}) } // SetKeyboardKey sets a tcell.Key/function combination that responds to key presses // Example: // // widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedItem) func (widget *KeyboardWidget) SetKeyboardKey(key tcell.Key, fn func(), helpText string) { widget.keyMap[key] = fn widget.keyHelp = append(widget.keyHelp, helpItem{tcell.KeyNames[key], helpText}) if len(tcell.KeyNames[key]) > widget.maxKey { widget.maxKey = len(tcell.KeyNames[key]) } } /* -------------------- Unexported Functions -------------------- */ // initializeCommonKeyboardControls sets up the keyboard controls that are common to // all widgets that accept keyboard input func (widget *KeyboardWidget) initializeCommonKeyboardControls() { widget.SetKeyboardChar("\\", widget.LaunchDocumentation, "Open the documentation for this module in a browser") } ================================================ FILE: view/keyboard_widget_test.go ================================================ package view import ( "testing" "github.com/gdamore/tcell/v2" "github.com/stretchr/testify/assert" "github.com/wtfutil/wtf/cfg" ) func test() {} func testKeyboardWidget() *KeyboardWidget { keyWid := NewKeyboardWidget( &cfg.Common{ Module: cfg.Module{ Name: "testWidget", Type: "testType", }, }, ) return keyWid } func Test_SetKeyboardChar(t *testing.T) { tests := []struct { name string char string fn func() helpText string mapChar string expected bool }{ { name: "with blank char", char: "", fn: test, helpText: "help", mapChar: "", expected: false, }, { name: "with undefined char", char: "d", fn: test, helpText: "help", mapChar: "m", expected: false, }, { name: "with defined char", char: "d", fn: test, helpText: "help", mapChar: "d", expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { keyWid := testKeyboardWidget() keyWid.SetKeyboardChar(tt.char, tt.fn, tt.helpText) actual := keyWid.charMap[tt.mapChar] if tt.expected != (actual != nil) { t.Errorf("\nexpected: %s\n got: %T", "actual != nil", actual) } }) } } func Test_SetKeyboardKey(t *testing.T) { tests := []struct { name string key tcell.Key fn func() helpText string mapKey tcell.Key expected bool }{ { name: "with undefined key", key: tcell.KeyCtrlA, fn: test, helpText: "help", mapKey: tcell.KeyCtrlZ, expected: false, }, { name: "with defined key", key: tcell.KeyCtrlA, fn: test, helpText: "help", mapKey: tcell.KeyCtrlA, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { keyWid := testKeyboardWidget() keyWid.SetKeyboardKey(tt.key, tt.fn, tt.helpText) actual := keyWid.keyMap[tt.mapKey] if tt.expected != (actual != nil) { t.Errorf("\nexpected: %s\n got: %T", "actual != nil", actual) } }) } } func Test_InputCapture(t *testing.T) { tests := []struct { name string before func(keyWid *KeyboardWidget) *KeyboardWidget event *tcell.EventKey expected *tcell.EventKey }{ { name: "with nil event", before: func(keyWid *KeyboardWidget) *KeyboardWidget { return keyWid }, event: nil, expected: nil, }, { name: "with undefined event", before: func(keyWid *KeyboardWidget) *KeyboardWidget { return keyWid }, event: tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), expected: tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), }, { name: "with defined event and char handler", before: func(keyWid *KeyboardWidget) *KeyboardWidget { keyWid.SetKeyboardChar("a", test, "help") return keyWid }, event: tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), expected: nil, }, { name: "with defined event and key handler", before: func(keyWid *KeyboardWidget) *KeyboardWidget { keyWid.SetKeyboardKey(tcell.KeyRune, test, "help") return keyWid }, event: tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { keyWid := testKeyboardWidget() keyWid = tt.before(keyWid) actual := keyWid.InputCapture(tt.event) if tt.expected == nil { if actual != nil { t.Errorf("\nexpected: %v\n got: %v", tt.expected, actual.Rune()) } return } if tt.expected.Rune() != actual.Rune() { t.Errorf("\nexpected: %v\n got: %v", tt.expected.Rune(), actual.Rune()) } }) } } func Test_initializeCommonKeyboardControls(t *testing.T) { t.Run("nil refreshFunc", func(t *testing.T) { keyWid := testKeyboardWidget() assert.NotNil(t, keyWid.charMap["\\"]) }) } func Test_InitializeRefreshKeyboardControl(t *testing.T) { t.Run("nil refreshFunc", func(t *testing.T) { keyWid := testKeyboardWidget() keyWid.InitializeRefreshKeyboardControl(nil) assert.Nil(t, keyWid.charMap["r"]) }) t.Run("non-nil refreshFunc", func(t *testing.T) { keyWid := testKeyboardWidget() keyWid.InitializeRefreshKeyboardControl(func() {}) assert.NotNil(t, keyWid.charMap["r"]) }) } func Test_HelpText(t *testing.T) { keyWid := testKeyboardWidget() keyWid.SetKeyboardChar("a", test, "a help") keyWid.SetKeyboardKey(tcell.KeyCtrlO, test, "keyCtrlO help") assert.NotNil(t, keyWid.HelpText()) } ================================================ FILE: view/multisource_widget.go ================================================ package view import ( "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/utils" ) // MultiSourceWidget is a widget that supports displaying data from multiple sources type MultiSourceWidget struct { moduleConfig *cfg.Common singular string plural string DisplayFunction func() Idx int Sources []string } // NewMultiSourceWidget creates and returns an instance of MultiSourceWidget func NewMultiSourceWidget(moduleConfig *cfg.Common, singular, plural string) MultiSourceWidget { widget := MultiSourceWidget{ moduleConfig: moduleConfig, singular: singular, plural: plural, } widget.loadSources() return widget } /* -------------------- Exported Functions -------------------- */ // CurrentSource returns the string representations of the currently-displayed source func (widget *MultiSourceWidget) CurrentSource() string { if widget.Idx >= len(widget.Sources) { return "" } return widget.Sources[widget.Idx] } // NextSource displays the next source in the source list. If the current source is the last // source it wraps around to the first source func (widget *MultiSourceWidget) NextSource() { widget.Idx++ if widget.Idx == len(widget.Sources) { widget.Idx = 0 } if widget.DisplayFunction != nil { widget.DisplayFunction() } } // PrevSource displays the previous source in the source list. If the current source is the first // source, it wraps around to the last source func (widget *MultiSourceWidget) PrevSource() { widget.Idx-- if widget.Idx < 0 { widget.Idx = len(widget.Sources) - 1 } if widget.DisplayFunction != nil { widget.DisplayFunction() } } // SetDisplayFunction stores the function that should be called when the source is // changed. This is typically called from within the initializer for the struct that // embeds MultiSourceWidget // // Example: // // widget := Widget{ // MultiSourceWidget: wtf.NewMultiSourceWidget(settings.common, "person", "people") // } // // widget.SetDisplayFunction(widget.display) func (widget *MultiSourceWidget) SetDisplayFunction(displayFunc func()) { widget.DisplayFunction = displayFunc } /* -------------------- Unexported Functions -------------------- */ func (widget *MultiSourceWidget) loadSources() { var empty []interface{} single := widget.moduleConfig.Config.UString(widget.singular, "") multiple := widget.moduleConfig.Config.UList(widget.plural, empty) asStrs := utils.ToStrs(multiple) if single != "" { asStrs = append(asStrs, single) } widget.Sources = asStrs } ================================================ FILE: view/scrollable_widget.go ================================================ package view import ( "strconv" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" ) type ScrollableWidget struct { TextWidget Selected int maxItems int RenderFunction func() } func NewScrollableWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, commonSettings *cfg.Common) ScrollableWidget { widget := ScrollableWidget{ TextWidget: NewTextWidget(tviewApp, redrawChan, pages, commonSettings), } widget.Unselect() widget.View.SetScrollable(true) widget.View.SetRegions(true) return widget } /* -------------------- Exported Functions -------------------- */ func (widget *ScrollableWidget) SetRenderFunction(displayFunc func()) { widget.RenderFunction = displayFunc } func (widget *ScrollableWidget) SetItemCount(items int) { widget.maxItems = items if items == 0 { widget.Selected = -1 } } func (widget *ScrollableWidget) GetSelected() int { return widget.Selected } func (widget *ScrollableWidget) RowColor(idx int) string { if widget.View.HasFocus() && (idx == widget.Selected) { return widget.CommonSettings().DefaultFocusedRowColor() } return widget.CommonSettings().RowColor(idx) } func (widget *ScrollableWidget) Next() { widget.Selected++ if widget.Selected >= widget.maxItems { widget.Selected = 0 } if widget.maxItems == 0 { widget.Selected = -1 } widget.RenderFunction() } func (widget *ScrollableWidget) Prev() { widget.Selected-- if widget.Selected < 0 { widget.Selected = widget.maxItems - 1 } if widget.maxItems == 0 { widget.Selected = -1 } widget.RenderFunction() } func (widget *ScrollableWidget) Unselect() { widget.Selected = -1 if widget.RenderFunction != nil { widget.RenderFunction() } } func (widget *ScrollableWidget) Redraw(data func() (string, string, bool)) { widget.TextWidget.Redraw(data) widget.View.Highlight(strconv.Itoa(widget.Selected)) widget.View.ScrollToHighlight() } ================================================ FILE: view/text_widget.go ================================================ package view import ( "strings" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" "github.com/wtfutil/wtf/wtf" ) // TextWidget defines the data necessary to make a text widget type TextWidget struct { *Base *KeyboardWidget View *tview.TextView } // NewTextWidget creates and returns an instance of TextWidget func NewTextWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, commonSettings *cfg.Common) TextWidget { widget := TextWidget{ Base: NewBase(tviewApp, redrawChan, pages, commonSettings), KeyboardWidget: NewKeyboardWidget(commonSettings), } widget.View = widget.createView(widget.bordered) widget.View.SetInputCapture(widget.InputCapture) widget.SetView(widget.View) widget.helpTextFunc = widget.HelpText return widget } /* -------------------- Exported Functions -------------------- */ // TextView returns the tview.TextView instance func (widget *TextWidget) TextView() *tview.TextView { return widget.View } // Redraw forces a refresh of the onscreen text content of this widget func (widget *TextWidget) Redraw(data func() (string, string, bool)) { title, content, wrap := data() widget.View.Clear() widget.View.SetWrap(wrap) widget.View.SetTitle(widget.ContextualTitle(title)) widget.View.SetText(strings.TrimRight(content, "\n")) widget.RedrawChan <- true } /* -------------------- Unexported Functions -------------------- */ func (widget *TextWidget) createView(bordered bool) *tview.TextView { view := tview.NewTextView() view.SetBackgroundColor(wtf.ColorFor(widget.commonSettings.Colors.Background)) view.SetBorder(bordered) view.SetBorderColor(wtf.ColorFor(widget.BorderColor())) view.SetDynamicColors(true) view.SetTextColor(wtf.ColorFor(widget.commonSettings.Colors.Text)) view.SetTitleColor(wtf.ColorFor(widget.commonSettings.Colors.Title)) view.SetWrap(false) return view } ================================================ FILE: view/text_widget_test.go ================================================ package view import ( "testing" "github.com/rivo/tview" "github.com/wtfutil/wtf/cfg" ) func testTextWidget() TextWidget { txtWid := NewTextWidget( tview.NewApplication(), make(chan bool), tview.NewPages(), &cfg.Common{ Module: cfg.Module{ Name: "test widget", }, }, ) return txtWid } func Test_Bordered(t *testing.T) { tests := []struct { name string before func(txtWid TextWidget) TextWidget expected bool }{ { name: "without border", before: func(txtWid TextWidget) TextWidget { txtWid.bordered = false return txtWid }, expected: false, }, { name: "with border", before: func(txtWid TextWidget) TextWidget { txtWid.bordered = true return txtWid }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { txtWid := testTextWidget() txtWid = tt.before(txtWid) actual := txtWid.Bordered() if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_Disabled(t *testing.T) { tests := []struct { name string before func(txtWid TextWidget) TextWidget expected bool }{ { name: "when not enabled", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = false return txtWid }, expected: true, }, { name: "when enabled", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = true return txtWid }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { txtWid := testTextWidget() txtWid = tt.before(txtWid) actual := txtWid.Disabled() if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_Enabled(t *testing.T) { tests := []struct { name string before func(txtWid TextWidget) TextWidget expected bool }{ { name: "when not enabled", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = false return txtWid }, expected: false, }, { name: "when enabled", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = true return txtWid }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { txtWid := testTextWidget() txtWid = tt.before(txtWid) actual := txtWid.Enabled() if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_Focusable(t *testing.T) { tests := []struct { name string before func(txtWid TextWidget) TextWidget expected bool }{ { name: "when not focusable", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = false txtWid.focusable = false return txtWid }, expected: false, }, { name: "when not focusable", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = false txtWid.focusable = true return txtWid }, expected: false, }, { name: "when not focusable", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = true txtWid.focusable = false return txtWid }, expected: false, }, { name: "when focusable", before: func(txtWid TextWidget) TextWidget { txtWid.enabled = true txtWid.focusable = true return txtWid }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { txtWid := testTextWidget() txtWid = tt.before(txtWid) actual := txtWid.Focusable() if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_Name(t *testing.T) { txtWid := testTextWidget() actual := txtWid.Name() expected := "test widget" if expected != actual { t.Errorf("\nexpected: %s\n got: %s", expected, actual) } } func Test_String(t *testing.T) { txtWid := testTextWidget() actual := txtWid.String() expected := "test widget" if expected != actual { t.Errorf("\nexpected: %s\n got: %s", expected, actual) } } ================================================ FILE: wtf/colors.go ================================================ package wtf import ( "regexp" "strconv" "strings" "github.com/gdamore/tcell/v2" ) var colorMap = map[int]string{ 0: "#000000", 1: "#800000", 2: "#008000", 3: "#808000", 4: "#000080", 5: "#800080", 6: "#008080", 7: "#c0c0c0", 8: "#808080", 9: "#ff0000", 10: "#00ff00", 11: "#ffff00", 12: "#0000ff", 13: "#ff00ff", 14: "#00ffff", 15: "#ffffff", 16: "#000000", 17: "#00005f", 18: "#000087", 19: "#0000af", 20: "#0000d7", 21: "#0000ff", 22: "#005f00", 23: "#005f5f", 24: "#005f87", 25: "#005faf", 26: "#005fd7", 27: "#005fff", 28: "#008700", 29: "#00875f", 30: "#008787", 31: "#0087af", 32: "#0087d7", 33: "#0087ff", 34: "#00af00", 35: "#00af5f", 36: "#00af87", 37: "#00afaf", 38: "#00afd7", 39: "#00afff", 40: "#00d700", 41: "#00d75f", 42: "#00d787", 43: "#00d7af", 44: "#00d7d7", 45: "#00d7ff", 46: "#00ff00", 47: "#00ff5f", 48: "#00ff87", 49: "#00ffaf", 50: "#00ffd7", 51: "#00ffff", 52: "#5f0000", 53: "#5f005f", 54: "#5f0087", 55: "#5f00af", 56: "#5f00d7", 57: "#5f00ff", 58: "#5f5f00", 59: "#5f5f5f", 60: "#5f5f87", 61: "#5f5faf", 62: "#5f5fd7", 63: "#5f5fff", 64: "#5f8700", 65: "#5f875f", 66: "#5f8787", 67: "#5f87af", 68: "#5f87d7", 69: "#5f87ff", 70: "#5faf00", 71: "#5faf5f", 72: "#5faf87", 73: "#5fafaf", 74: "#5fafd7", 75: "#5fafff", 76: "#5fd700", 77: "#5fd75f", 78: "#5fd787", 79: "#5fd7af", 80: "#5fd7d7", 81: "#5fd7ff", 82: "#5fff00", 83: "#5fff5f", 84: "#5fff87", 85: "#5fffaf", 86: "#5fffd7", 87: "#5fffff", 88: "#870000", 89: "#87005f", 90: "#870087", 91: "#8700af", 92: "#8700d7", 93: "#8700ff", 94: "#875f00", 95: "#875f5f", 96: "#875f87", 97: "#875faf", 98: "#875fd7", 99: "#875fff", 100: "#878700", 101: "#87875f", 102: "#878787", 103: "#8787af", 104: "#8787d7", 105: "#8787ff", 106: "#87af00", 107: "#87af5f", 108: "#87af87", 109: "#87afaf", 110: "#87afd7", 111: "#87afff", 112: "#87d700", 113: "#87d75f", 114: "#87d787", 115: "#87d7af", 116: "#87d7d7", 117: "#87d7ff", 118: "#87ff00", 119: "#87ff5f", 120: "#87ff87", 121: "#87ffaf", 122: "#87ffd7", 123: "#87ffff", 124: "#af0000", 125: "#af005f", 126: "#af0087", 127: "#af00af", 128: "#af00d7", 129: "#af00ff", 130: "#af5f00", 131: "#af5f5f", 132: "#af5f87", 133: "#af5faf", 134: "#af5fd7", 135: "#af5fff", 136: "#af8700", 137: "#af875f", 138: "#af8787", 139: "#af87af", 140: "#af87d7", 141: "#af87ff", 142: "#afaf00", 143: "#afaf5f", 144: "#afaf87", 145: "#afafaf", 146: "#afafd7", 147: "#afafff", 148: "#afd700", 149: "#afd75f", 150: "#afd787", 151: "#afd7af", 152: "#afd7d7", 153: "#afd7ff", 154: "#afff00", 155: "#afff5f", 156: "#afff87", 157: "#afffaf", 158: "#afffd7", 159: "#afffff", 160: "#d70000", 161: "#d7005f", 162: "#d70087", 163: "#d700af", 164: "#d700d7", 165: "#d700ff", 166: "#d75f00", 167: "#d75f5f", 168: "#d75f87", 169: "#d75faf", 170: "#d75fd7", 171: "#d75fff", 172: "#d78700", 173: "#d7875f", 174: "#d78787", 175: "#d787af", 176: "#d787d7", 177: "#d787ff", 178: "#d7af00", 179: "#d7af5f", 180: "#d7af87", 181: "#d7afaf", 182: "#d7afd7", 183: "#d7afff", 184: "#d7d700", 185: "#d7d75f", 186: "#d7d787", 187: "#d7d7af", 188: "#d7d7d7", 189: "#d7d7ff", 190: "#d7ff00", 191: "#d7ff5f", 192: "#d7ff87", 193: "#d7ffaf", 194: "#d7ffd7", 195: "#d7ffff", 196: "#ff0000", 197: "#ff005f", 198: "#ff0087", 199: "#ff00af", 200: "#ff00d7", 201: "#ff00ff", 202: "#ff5f00", 203: "#ff5f5f", 204: "#ff5f87", 205: "#ff5faf", 206: "#ff5fd7", 207: "#ff5fff", 208: "#ff8700", 209: "#ff875f", 210: "#ff8787", 211: "#ff87af", 212: "#ff87d7", 213: "#ff87ff", 214: "#ffaf00", 215: "#ffaf5f", 216: "#ffaf87", 217: "#ffafaf", 218: "#ffafd7", 219: "#ffafff", 220: "#ffd700", 221: "#ffd75f", 222: "#ffd787", 223: "#ffd7af", 224: "#ffd7d7", 225: "#ffd7ff", 226: "#ffff00", 227: "#ffff5f", 228: "#ffff87", 229: "#ffffaf", 230: "#ffffd7", 231: "#ffffff", 232: "#080808", 233: "#121212", 234: "#1c1c1c", 235: "#262626", 236: "#303030", 237: "#3a3a3a", 238: "#444444", 239: "#4e4e4e", 240: "#585858", 241: "#626262", 242: "#6c6c6c", 243: "#767676", 244: "#808080", 245: "#8a8a8a", 246: "#949494", 247: "#9e9e9e", 248: "#a8a8a8", 249: "#b2b2b2", 250: "#bcbcbc", 251: "#c6c6c6", 252: "#d0d0d0", 253: "#dadada", 254: "#e4e4e4", 255: "#eeeeee", } func ASCIItoTviewColors(text string) string { boldRegExp := regexp.MustCompile(`\033\[1m`) fgColorRegExp := regexp.MustCompile(`\033\[38;5;(?P\d+);*\d*m`) resColorRegExp := regexp.MustCompile(`\033\[0m`) return resColorRegExp.ReplaceAllString( boldRegExp.ReplaceAllString( fgColorRegExp.ReplaceAllStringFunc( text, replaceWithHexColorString), `[::b]`), `[-]`) } func ColorFor(label string) tcell.Color { return tcell.GetColor(label) } /* -------------------- Unexported Functions -------------------- */ func replaceWithHexColorString(substring string) string { colorID, err := strconv.Atoi(strings.Trim( strings.Split(substring, ";")[2], "m")) if err != nil { return substring } hexColor := "[" + colorMap[colorID] + "]" return hexColor } ================================================ FILE: wtf/colors_test.go ================================================ package wtf import ( "testing" "github.com/gdamore/tcell/v2" ) func Test_ASCIItoTviewColors(t *testing.T) { tests := []struct { name string text string expected string }{ { name: "with blank text", text: "", expected: "", }, { name: "with no color", text: "cat", expected: "cat", }, { name: "with defined color", text: "[38;5;226mcat/\x1b[0m", expected: "[38;5;226mcat/[-]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := ASCIItoTviewColors(tt.text) if tt.expected != actual { t.Errorf("\nexpected: %q\n got: %q", tt.expected, actual) } }) } } func Test_ColorFor(t *testing.T) { tests := []struct { name string label string expected tcell.Color }{ { name: "with no label", label: "", expected: tcell.ColorDefault, }, { name: "with missing label", label: "cat", expected: tcell.ColorDefault, }, { name: "with defined label", label: "tomato", expected: tcell.ColorTomato, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := ColorFor(tt.label) if tt.expected != actual { t.Errorf("\nexpected: %q\n got: %q", tt.expected, actual) } }) } } ================================================ FILE: wtf/datetime.go ================================================ package wtf import ( "fmt" "time" ) const ( // DateFormat defines the format we expect to receive dates from BambooHR in DateFormat = "2006-01-02" // TimeFormat defines the format we expect to receive times from BambooHR in TimeFormat = "15:04" ) // IsToday returns TRUE if the date is today, FALSE if the date is not today func IsToday(date time.Time) bool { now := time.Now().Local() return (date.Year() == now.Year()) && (date.Month() == now.Month()) && (date.Day() == now.Day()) } // PrettyDate takes a programmer-style date string and converts it // in a friendlier-to-read format func PrettyDate(dateStr string) string { newTime, err := time.Parse(DateFormat, dateStr) if err != nil { return dateStr } return fmt.Sprint(newTime.Format("Jan 2, 2006")) } // UnixTime takes a Unix epoch time (in seconds) and returns a // time.Time instance func UnixTime(unix int64) time.Time { return time.Unix(unix, 0) } ================================================ FILE: wtf/datetime_test.go ================================================ package wtf import ( "testing" "time" ) func Test_IsToday(t *testing.T) { tests := []struct { name string date time.Time expected bool }{ { name: "when yesterday", date: time.Now().Local().AddDate(0, 0, -1), expected: false, }, { name: "when today", date: time.Now().Local(), expected: true, }, { name: "when tomorrow", date: time.Now().Local().AddDate(0, 0, +1), expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := IsToday(tt.date) if tt.expected != actual { t.Errorf("\nexpected: %t\n got: %t", tt.expected, actual) } }) } } func Test_PrettyDate(t *testing.T) { tests := []struct { name string date string expected string }{ { name: "with empty date", date: "", expected: "", }, { name: "with invalid date", date: "10-21-1999", expected: "10-21-1999", }, { name: "with valid date", date: "1999-10-21", expected: "Oct 21, 1999", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := PrettyDate(tt.date) if tt.expected != actual { t.Errorf("\nexpected: %s\n got: %s", tt.expected, actual) } }) } } func Test_UnixTime(t *testing.T) { tests := []struct { name string unixVal int64 expected string }{ { name: "with 0 time", unixVal: 0, expected: "1970-01-01 00:00:00 +0000 UTC", }, { name: "with explicit time", unixVal: 1564883266, expected: "2019-08-04 01:47:46 +0000 UTC", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := UnixTime(tt.unixVal).UTC() if tt.expected != actual.String() { t.Errorf("\nexpected: %s\n got: %s", tt.expected, actual) } }) } } ================================================ FILE: wtf/enablable.go ================================================ package wtf // Enablable is the interface that enforces enable/disable capabilities on a module type Enablable interface { Disable() Disabled() bool Enabled() bool } ================================================ FILE: wtf/numbers.go ================================================ package wtf import "math" // Round rounds a float to an integer func Round(num float64) int { return int(num + math.Copysign(0.5, num)) } // TruncateFloat64 truncates the decimal places of a float64 to the specified precision func TruncateFloat64(num float64, precision int) float64 { output := math.Pow(10, float64(precision)) return float64(Round(num*output)) / output } ================================================ FILE: wtf/numbers_test.go ================================================ package wtf import ( "testing" "gotest.tools/assert" ) func Test_Round(t *testing.T) { tests := []struct { name string input float64 expected int }{ { name: "negative", input: -3, expected: -3, }, { name: "integer", input: 3, expected: 3, }, { name: "float down", input: 3.123456, expected: 3, }, { name: "float up", input: 3.998786, expected: 4, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := Round(tt.input) assert.Equal(t, tt.expected, actual) }) } } func Test_TruncateFloat(t *testing.T) { tests := []struct { name string input float64 precision int expected float64 }{ { name: "negative precision", input: 23.234567, precision: -2, expected: 0, }, { name: "zero precision", input: 23.234567, precision: 0, expected: 23, }, { name: "positive precision", input: 23.234567, precision: 2, expected: 23.23, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := TruncateFloat64(tt.input, tt.precision) assert.Equal(t, tt.expected, actual) }) } } ================================================ FILE: wtf/schedulable.go ================================================ package wtf import "time" // Schedulable is the interface that enforces scheduling capabilities on a module type Schedulable interface { Refresh() Refreshing() bool RefreshInterval() time.Duration } ================================================ FILE: wtf/stoppable.go ================================================ package wtf // Stoppable is the interface that enforces a stoppable state type Stoppable interface { Stop() } ================================================ FILE: wtf/terminal.go ================================================ package wtf import ( "fmt" "os" "github.com/logrusorgru/aurora/v4" "github.com/olebedev/config" ) // SetTerminal sets the TERM environment variable, defaulting to whatever the OS // has as the current value if none is specified. // See https://www.gnu.org/software/gettext/manual/html_node/The-TERM-variable.html for // more details. func SetTerminal(config *config.Config) { term := config.UString("wtf.term", os.Getenv("TERM")) err := os.Setenv("TERM", term) if err != nil { fmt.Printf("\n%s Failed to set $TERM to %s.\n", aurora.Red("ERROR"), aurora.Yellow(term)) os.Exit(1) } } ================================================ FILE: wtf/wtfable.go ================================================ package wtf import ( "github.com/wtfutil/wtf/cfg" "github.com/rivo/tview" ) // Wtfable is the interface that enforces WTF system capabilities on a module type Wtfable interface { Enablable Schedulable Stoppable BorderColor() string ConfigText() string FocusChar() string Focusable() bool HelpText() string Name() string QuitChan() chan bool SetFocusChar(string) TextView() *tview.TextView CommonSettings() *cfg.Common }