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
================================================
[](https://github.com/wtfutil/wtf/releases)
[](https://goreportcard.com/report/github.com/wtfutil/wtf)
[](https://discuss.linodians.com/c/projects/wtf/7)
[](https://bsky.app/profile/wtfutil.bsky.social)
[](https://social.linodians.com/@WTFutil)

---
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
================================================
[](http://godoc.org/github.com/yfronto/newrelic)
[](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
}