diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e38b28b..fd751e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,9 @@ -name: Release +name: CI on: push: - tags: ["v*"] - -permissions: - contents: write + branches: [main] + pull_request: jobs: test: @@ -13,86 +11,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - run: go build ./... - - run: go test ./... + - uses: dtolnay/rust-toolchain@stable + - run: cargo build + - run: cargo test lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - uses: golangci/golangci-lint-action@v7 - - release: - name: Release - runs-on: ubuntu-latest - needs: [test, lint] - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - name: Install quill - run: curl -sSfL https://get.anchore.io/quill | sudo sh -s -- -b /usr/local/bin - - uses: goreleaser/goreleaser-action@v6 - with: - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - QUILL_SIGN_P12: ${{ secrets.QUILL_SIGN_P12 }} - QUILL_SIGN_PASSWORD: ${{ secrets.QUILL_SIGN_PASSWORD }} - QUILL_NOTARY_KEY: ${{ secrets.QUILL_NOTARY_KEY }} - QUILL_NOTARY_KEY_ID: ${{ secrets.QUILL_NOTARY_KEY_ID }} - QUILL_NOTARY_ISSUER: ${{ secrets.QUILL_NOTARY_ISSUER }} - - update-flake: - name: Update flake.nix - runs-on: ubuntu-latest - needs: release - steps: - - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable with: - ref: main - - name: Download release artifacts and compute hashes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - - # Download all tarballs - gh release download "${GITHUB_REF_NAME}" \ - --pattern "mailerlite-cli_${VERSION}_*.tar.gz" \ - --dir ./artifacts - - # Use sri format for nix - HASH_LINUX_AMD64="sha256-$(openssl dgst -sha256 -binary ./artifacts/mailerlite-cli_${VERSION}_linux_amd64.tar.gz | base64)" - HASH_LINUX_ARM64="sha256-$(openssl dgst -sha256 -binary ./artifacts/mailerlite-cli_${VERSION}_linux_arm64.tar.gz | base64)" - HASH_DARWIN_AMD64="sha256-$(openssl dgst -sha256 -binary ./artifacts/mailerlite-cli_${VERSION}_darwin_amd64.tar.gz | base64)" - HASH_DARWIN_ARM64="sha256-$(openssl dgst -sha256 -binary ./artifacts/mailerlite-cli_${VERSION}_darwin_arm64.tar.gz | base64)" - - # Update version - sed -i "s/version = \".*\";/version = \"${VERSION}\";/" flake.nix - - # Update hashes - sed -i "s|\"x86_64-linux\" = \".*\";|\"x86_64-linux\" = \"${HASH_LINUX_AMD64}\";|" flake.nix - sed -i "s|\"aarch64-linux\" = \".*\";|\"aarch64-linux\" = \"${HASH_LINUX_ARM64}\";|" flake.nix - sed -i "s|\"x86_64-darwin\" = \".*\";|\"x86_64-darwin\" = \"${HASH_DARWIN_AMD64}\";|" flake.nix - sed -i "s|\"aarch64-darwin\" = \".*\";|\"aarch64-darwin\" = \"${HASH_DARWIN_ARM64}\";|" flake.nix - - name: Commit and push - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add flake.nix - git diff --staged --quiet && exit 0 - git commit -m "chore: update flake.nix for ${GITHUB_REF_NAME}" - git push + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33b60b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,352 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: true + repository: "mailerlite/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + + custom-update-flake: + needs: + - plan + - announce + uses: ./.github/workflows/update-flake.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml new file mode 100644 index 0000000..d36bd75 --- /dev/null +++ b/.github/workflows/update-flake.yml @@ -0,0 +1,56 @@ +name: Update flake.nix + +on: + workflow_call: + inputs: + plan: + required: true + type: string + +jobs: + update-flake: + name: Update flake.nix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: main + - name: Download release artifacts and compute hashes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ fromJSON(inputs.plan).announcement_tag }} + run: | + VERSION="${TAG#v}" + + # Download all tarballs + gh release download "$TAG" \ + --pattern "mailerlite-*.tar.gz" \ + --dir ./artifacts + + # Use sri format for nix + sri() { + echo "sha256-$(openssl dgst -sha256 -binary "./artifacts/mailerlite-$1.tar.gz" | base64)" + } + HASH_LINUX_AMD64="$(sri x86_64-unknown-linux-gnu)" + HASH_LINUX_ARM64="$(sri aarch64-unknown-linux-gnu)" + HASH_DARWIN_AMD64="$(sri x86_64-apple-darwin)" + HASH_DARWIN_ARM64="$(sri aarch64-apple-darwin)" + + # Update version + sed -i "s/version = \".*\";/version = \"${VERSION}\";/" flake.nix + + # Update hashes + sed -i "s|\"x86_64-linux\" = \".*\";|\"x86_64-linux\" = \"${HASH_LINUX_AMD64}\";|" flake.nix + sed -i "s|\"aarch64-linux\" = \".*\";|\"aarch64-linux\" = \"${HASH_LINUX_ARM64}\";|" flake.nix + sed -i "s|\"x86_64-darwin\" = \".*\";|\"x86_64-darwin\" = \"${HASH_DARWIN_AMD64}\";|" flake.nix + sed -i "s|\"aarch64-darwin\" = \".*\";|\"aarch64-darwin\" = \"${HASH_DARWIN_ARM64}\";|" flake.nix + - name: Commit and push + env: + TAG: ${{ fromJSON(inputs.plan).announcement_tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add flake.nix + git diff --staged --quiet && exit 0 + git commit -m "chore: update flake.nix for ${TAG}" + git push diff --git a/.gitignore b/.gitignore index 2d0c076..d53284d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -mailerlite-cli dist/ .claude/settings.local.json test_cli.sh +target/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 5685345..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,57 +0,0 @@ -version: 2 - -builds: - - id: mailerlite - binary: mailerlite - main: . - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - - windows - goarch: - - amd64 - - arm64 - ignore: - - goos: windows - goarch: arm64 - ldflags: - - -s -w - - -X github.com/mailerlite/mailerlite-cli/cmd.version={{.Version}} - - -X github.com/mailerlite/mailerlite-cli/cmd.commit={{.ShortCommit}} - - -X github.com/mailerlite/mailerlite-cli/cmd.date={{.Date}} - hooks: - post: - - cmd: ./scripts/sign-macos.sh "{{ .Os }}" "{{ .Path }}" "{{ .IsSnapshot }}" - output: true - -archives: - - id: default - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - -checksum: - name_template: "checksums.txt" - algorithm: sha256 - -changelog: - sort: asc - -homebrew_casks: - - name: mailerlite - binaries: - - mailerlite - homepage: "https://github.com/mailerlite/mailerlite-cli" - description: "Command-line interface for the MailerLite API" - directory: Casks - repository: - owner: mailerlite - name: homebrew-tap - branch: main - token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" diff --git a/CLAUDE.md b/CLAUDE.md index 796da0f..0cd0cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,75 +2,72 @@ ## Project -MailerLite CLI — a Go CLI and interactive TUI dashboard for the MailerLite API. +MailerLite CLI — a Rust CLI and interactive TUI dashboard for the MailerLite API. -Module: `github.com/mailerlite/mailerlite-cli` -Go version: 1.25.5 +Crate: `mailerlite` (binary), Rust 2021 edition. ## Build & Test ```bash -go build ./... # build all packages -go test ./... # run all tests -go build -o mailerlite . # build binary +cargo build # debug build +cargo test # run all tests +cargo build --release # release binary at target/release/mailerlite ``` ## Lint & Format ```bash -gofmt -w . # format all Go files -golangci-lint run # lint (used in CI and pre-commit hooks) -golangci-lint run --fix # lint with auto-fix +cargo fmt # format all Rust files +cargo clippy --all-targets -- -D warnings # lint (used in CI and pre-commit hooks) ``` -Always run `gofmt -w .` and `golangci-lint run` after making changes. CI runs both `golangci-lint run` and `go build ./...` on every push/PR. +Always run `cargo fmt` and `cargo clippy` after making changes. CI runs fmt check, clippy, `cargo build`, and `cargo test` on pushes to main and PRs. ## Project Structure ``` -main.go # entry point -cmd/ # cobra commands, one subdir per feature - root.go # root command, registers all subcommands - dashboard/ # TUI launcher - subscriber/, group/... # ~20 command groups - shop/, product/, ... # e-commerce commands (raw HTTP) -internal/ - config/ # YAML config + multi-profile management - sdkclient/ # SDK wrapper: retry, rate-limit, verbose logging - output/ # table/JSON/plain formatters - prompt/ # interactive prompts (charmbracelet/huh) - cmdutil/ # flag helpers, SDK client factory - ecommerce/ # e-commerce types (raw HTTP endpoints) - tui/ # bubbletea TUI dashboard - app.go # main app model - keys.go # key bindings - theme/ # centralized color palette (AdaptiveColor) +src/ + main.rs # entry point (restores SIGPIPE, exits with cli::run()) + cli.rs # clap root: global flags, Ctx, subcommand dispatch, version + config.rs # YAML config + multi-profile management + OAuth refresh + api.rs # HTTP client: retry, rate-limit, verbose logging, pagination + output.rs # table/JSON/plain formatters, NO_COLOR support + prompt.rs # interactive prompts (inquire) + util.rs # JSON field extraction (jstr/jint/jpath), key=value parsing + commands/ # one module per command group (~20 groups) + subscriber.rs # canonical pattern: Cmd (clap::Args) + Sub enum + run(ctx, cmd) + shop.rs, product.rs ... # e-commerce groups (raw /ecommerce/... endpoints) + tui/ # ratatui dashboard + mod.rs # entry: run(ctx), terminal lifecycle + panic hook + app.rs # main app state and event loop + keys.rs # key bindings + theme.rs # centralized color palette components/ # sidebar, table, statusbar, help, spinner, detail views/ # subscribers, campaigns, automations, groups, forms - types/ # shared message types and data structs ``` ## Key Dependencies -- `github.com/mailerlite/mailerlite-go` — official SDK -- `github.com/spf13/cobra` — CLI framework -- `github.com/charmbracelet/bubbletea` — TUI framework -- `github.com/charmbracelet/lipgloss` — terminal styling -- `github.com/charmbracelet/huh` — interactive prompts +- `clap` — CLI framework (derive) +- `ureq` — blocking HTTP client +- `ratatui` + `crossterm` — TUI framework +- `inquire` — interactive prompts +- `serde` / `serde_json` / `serde_yaml` — config and API payloads ## Architecture Notes -- Commands follow pattern: `cmd//.go` with cobra command, subcommands via `init()`, and handler functions. -- SDK client uses a custom `CLITransport` wrapping the HTTP client with retry (3 max, exponential backoff), rate-limit handling (429 + Retry-After), and verbose request logging. -- Config stored at `~/.config/mailerlite/config.yaml` with multi-profile support. Token resolution: env var `MAILERLITE_API_TOKEN` > `--profile` flag > active profile > first profile. -- E-commerce commands (shop, product, category, customer, order, cart, cartitem, import) use raw HTTP calls via `sdkclient.DoRaw()` since these endpoints aren't in the SDK. -- TUI colors are defined in `internal/tui/theme/theme.go` as `lipgloss.AdaptiveColor` values for light/dark terminal support. Use 256-palette values (16-255) for backgrounds and critical text. -- Output supports `--json` flag for machine-readable output and respects `NO_COLOR` env var. +- Command modules follow the pattern in `src/commands/subscriber.rs`: `Cmd` struct deriving `clap::Args` with a `Sub` subcommand enum, plus `run(ctx: &Ctx, cmd: Cmd) -> anyhow::Result<()>`. +- `api::Client` handles retry (3 max, exponential backoff), rate-limit handling (429 + Retry-After), verbose request logging, the `X-Acc-Id` account header, and `MAILERLITE_API_BASE_URL` override. All endpoints go through it — there is no SDK; responses are `serde_json::Value` and display fields are extracted with `util::jstr`/`jint`/`jpath`. +- Config stored at `~/.config/mailerlite/config.yaml` with multi-profile support. Token resolution: env var `MAILERLITE_API_TOKEN` > `--profile` flag > active profile > first profile. OAuth tokens auto-refresh 5 minutes before expiry. +- Pagination helpers in `api.rs`: `fetch_all_paged` (page numbers), `fetch_all_cursor` (string cursor, subscribers), `fetch_all_after` (integer cursor, segment subscribers). +- TUI colors live in `src/tui/theme.rs`. Data loading is non-blocking: background `std::thread` per fetch sending messages over `std::sync::mpsc`. +- Output supports `--json` (prints the raw API response) and respects the `NO_COLOR` env var. +- Version: `Cargo.toml` is authoritative (`CARGO_PKG_VERSION`); `build.rs` stamps the git commit and date into `MAILERLITE_COMMIT`/`MAILERLITE_BUILD_DATE`. ## Release -Tags matching `v*` trigger GoReleaser via `.github/workflows/release.yml`. Builds for Linux/macOS/Windows. +Releases use [cargo-dist](https://opensource.axo.dev/cargo-dist/) (`dist-workspace.toml`). Version tags (e.g. `v2.0.0`) MUST match the version in `Cargo.toml`; pushing the tag triggers `.github/workflows/release.yml` (generated by `dist init` — regenerate, do not hand-edit). It builds native binaries for Linux/macOS/Windows, publishes shell/PowerShell installers, pushes a Homebrew formula to `mailerlite/homebrew-tap`, and runs the `update-flake` job to refresh `flake.nix` hashes. CI quality gates (`ci.yml`) run on pushes to main and PRs. ## Pre-commit Hooks -Configured via `lefthook.yml`: runs `golangci-lint run --fix` and `go build ./...` before each commit. +Configured via `lefthook.yml`: runs `cargo fmt` (staging fixes) and `cargo clippy --all-targets -- -D warnings` before each commit. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..045638b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2089 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "mio 1.2.2", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.13.1", + "crossterm 0.25.0", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "mailerlite" +version = "2.0.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "clap", + "clap_complete", + "crossterm 0.28.1", + "inquire", + "libc", + "rand", + "ratatui", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tiny_http", + "unicode-width 0.2.0", + "ureq", + "webbrowser", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.1", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "mio 1.2.2", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ae79663 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "mailerlite" +version = "2.0.0" +edition = "2021" +description = "Command-line interface for the MailerLite API" +homepage = "https://github.com/mailerlite/mailerlite-cli" +repository = "https://github.com/mailerlite/mailerlite-cli" +license = "MIT" +build = "build.rs" + +[dependencies] +anyhow = "1" +base64 = "0.22" +chrono = "0.4" +clap = { version = "4", features = ["derive"] } +clap_complete = "4" +crossterm = "0.28" +inquire = "0.7" +libc = "0.2" +rand = "0.8" +ratatui = "0.29" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tiny_http = "0.12" +unicode-width = "0.2" +ureq = { version = "2", features = ["json"] } +webbrowser = "1" + +[profile.release] +lto = true +strip = true + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md index 4d27a80..821f0e7 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,70 @@ # MailerLite CLI -A command-line interface and interactive TUI dashboard for the [MailerLite API](https://www.mailerlite.com/). Manage subscribers, campaigns, automations, groups, forms, e-commerce, and more — all from your terminal. +A command-line interface and a TUI dashboard for the [MailerLite API](https://www.mailerlite.com/). Use it to manage subscribers, campaigns, automations, groups, forms, and e-commerce data from the terminal. ## Installation ### Homebrew ```bash -brew install --cask mailerlite/tap/mailerlite +brew install mailerlite/tap/mailerlite +``` + +### Installer script + +macOS and Linux: + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/mailerlite/mailerlite-cli/releases/latest/download/mailerlite-installer.sh | sh +``` + +Windows (PowerShell): + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://github.com/mailerlite/mailerlite-cli/releases/latest/download/mailerlite-installer.ps1 | iex" ``` ### GitHub Releases -Download pre-built binaries for Linux, macOS, and Windows from the [releases page](https://github.com/mailerlite/mailerlite-cli/releases). +Download binaries for Linux, macOS, and Windows from the [releases page](https://github.com/mailerlite/mailerlite-cli/releases). -### Go install +### Cargo ```bash -go install github.com/mailerlite/mailerlite-cli@latest +cargo install --git https://github.com/mailerlite/mailerlite-cli.git ``` ### From source -Requires Go 1.25+. +Install a Rust toolchain from [rustup.rs](https://rustup.rs) first. ```bash git clone https://github.com/mailerlite/mailerlite-cli.git cd mailerlite-cli -go build -o mailerlite . +cargo build --release ``` -Move the binary to somewhere on your `$PATH`: +Move the binary to a directory on your `$PATH`: ```bash -sudo mv mailerlite /usr/local/bin/ +sudo mv target/release/mailerlite /usr/local/bin/ ``` ### Nix -Run directly without installing: +Run the CLI without installation: ```bash nix run git+ssh://git@github.com/mailerlite/mailerlite-cli.git ``` -Or install into your profile: +Install the CLI into your profile: ```bash nix profile install git+ssh://git@github.com/mailerlite/mailerlite-cli.git ``` -Or add to a `flake.nix`: +Or add the CLI to a `flake.nix`: ```nix { @@ -61,7 +75,7 @@ Or add to a `flake.nix`: ## Authentication -The CLI supports two authentication methods: **OAuth** (recommended) and **API token**. +The CLI has two authentication methods: **OAuth** (recommended) and **API token**. ### OAuth (recommended) @@ -69,7 +83,7 @@ The CLI supports two authentication methods: **OAuth** (recommended) and **API t mailerlite auth login ``` -Running `mailerlite auth login` opens your browser to authorize the CLI with your MailerLite account via OAuth. This is the default and recommended method — no need to manually create or paste tokens. OAuth tokens are automatically refreshed when they expire. +The command opens the browser and authorizes the CLI with your MailerLite account. You do not create or paste tokens - the CLI does this for you. The CLI refreshes OAuth tokens automatically when they expire. ### API Token @@ -79,11 +93,11 @@ You can also authenticate with an API token: mailerlite auth login --method token ``` -You'll be prompted to enter your MailerLite API token. You can generate one from your [MailerLite dashboard](https://www.mailerlite.com/) under API Tokens. +The CLI asks for your MailerLite API token. Create a token in your [MailerLite dashboard](https://www.mailerlite.com/) under API Tokens. ### Auth status and logout -Check auth status: +Show the authentication status: ```bash mailerlite auth status @@ -97,7 +111,7 @@ mailerlite auth logout ### Multiple profiles -You can manage multiple profiles: +You can keep more than one profile: ```bash mailerlite profile add staging @@ -106,7 +120,7 @@ mailerlite profile list mailerlite profile switch staging ``` -Use a specific profile for a single command: +Use a specific profile for one command: ```bash mailerlite subscriber list --profile production @@ -114,7 +128,7 @@ mailerlite subscriber list --profile production ### Multiple accounts -If your OAuth credentials have access to multiple accounts: +If your OAuth credentials have access to more than one account: ```bash mailerlite account list @@ -123,7 +137,7 @@ mailerlite account switch ### Environment variable -You can also set the API token via environment variable: +You can also set the API token with an environment variable: ```bash export MAILERLITE_API_TOKEN="your_token_here" @@ -131,7 +145,7 @@ export MAILERLITE_API_TOKEN="your_token_here" ## Global flags -Every command supports these flags: +Every command accepts these flags: | Flag | Description | |------|-------------| @@ -143,13 +157,13 @@ Every command supports these flags: ## Dashboard -Launch an interactive TUI dashboard with vim-style keybindings: +Start the interactive TUI dashboard: ```bash mailerlite dashboard ``` -The dashboard provides a lazygit-style interface with sidebar navigation between subscribers, campaigns, automations, groups, and forms. Press `?` for help or `q` to quit. +The dashboard gives you a lazygit-style interface with vim keybindings. Use the sidebar to move between subscribers, campaigns, automations, groups, and forms. Press `?` for help. Press `q` to quit. ## Commands @@ -512,10 +526,7 @@ All cart-item commands require `--shop` and `--cart`. # List cart items mailerlite cart-item list --shop --cart -# Get cart item details -mailerlite cart-item get --shop --cart - -# Add an item to a cart +# Create a cart item mailerlite cart-item create --shop --cart \ --product \ --quantity 2 \ @@ -546,7 +557,7 @@ mailerlite import orders --shop --file orders.json ## Shell completion -Generate shell completions for your shell: +Generate a completion script for your shell: ```bash # Bash @@ -564,14 +575,14 @@ mailerlite completion powershell | Out-String | Invoke-Expression ## JSON output -Add `--json` to any command to get raw JSON output, useful for scripting: +Add `--json` to a command to get raw JSON output. Use it in scripts: ```bash # Pipe to jq mailerlite subscriber list --json | jq '.[].email' # Extract an ID -mailerlite group create --name "Test" --json | jq -r '.id' +mailerlite group create --name "Test" --json | jq -r '.data.id' ``` ## License diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..40a82bd --- /dev/null +++ b/build.rs @@ -0,0 +1,24 @@ +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let s = s.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +fn main() { + println!("cargo:rerun-if-changed=.git/HEAD"); + let commit = git(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "none".into()); + let date = git(&["log", "-1", "--format=%cd", "--date=format:%Y-%m-%d"]) + .unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=MAILERLITE_COMMIT={commit}"); + println!("cargo:rustc-env=MAILERLITE_BUILD_DATE={date}"); +} diff --git a/cmd/account/account.go b/cmd/account/account.go deleted file mode 100644 index e820af3..0000000 --- a/cmd/account/account.go +++ /dev/null @@ -1,164 +0,0 @@ -package account - -import ( - "context" - "fmt" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/config" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "account", - Short: "Manage MailerLite accounts", - Long: "List and switch between MailerLite accounts (requires OAuth authentication).", -} - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List accounts you have access to", - RunE: runList, -} - -var switchCmd = &cobra.Command{ - Use: "switch [account-id]", - Short: "Switch active account", - Long: "Switch to a different MailerLite account. If no account ID is provided, shows an interactive picker.", - Args: cobra.MaximumNArgs(1), - RunE: runSwitch, -} - -func init() { - Cmd.AddCommand(listCmd, switchCmd) -} - -type accountEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` -} - -type accountsResponse struct { - Data []accountEntry `json:"data"` -} - -func fetchAccounts(cmd *cobra.Command) ([]accountEntry, error) { - httpClient, token, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return nil, err - } - - var resp accountsResponse - _, err = sdkclient.DoRaw(context.Background(), httpClient, token, "GET", "/accounts", nil, &resp) - if err != nil { - return nil, fmt.Errorf("failed to fetch accounts: %w", err) - } - return resp.Data, nil -} - -func runList(cmd *cobra.Command, args []string) error { - accounts, err := fetchAccounts(cmd) - if err != nil { - return err - } - - activeAccountID := config.GetAccountID(cmdutil.ProfileFlag(cmd)) - - jsonFlag := cmdutil.JSONFlag(cmd) - if jsonFlag { - return output.JSON(accounts) - } - - var rows [][]string - for _, a := range accounts { - name := a.Name - if a.ID == activeAccountID { - name += " (active)" - } - rows = append(rows, []string{a.ID, name, a.Status}) - } - - output.Table([]string{"ID", "Name", "Status"}, rows) - return nil -} - -func runSwitch(cmd *cobra.Command, args []string) error { - var accountID string - - accounts, err := fetchAccounts(cmd) - if err != nil { - return err - } - - if len(args) > 0 { - accountID = args[0] - valid := false - for _, a := range accounts { - if a.ID == accountID { - valid = true - break - } - } - if !valid { - return fmt.Errorf("account %q not found; use 'account list' to see available accounts", accountID) - } - } else { - if len(accounts) == 0 { - return fmt.Errorf("no accounts found") - } - if len(accounts) == 1 { - accountID = accounts[0].ID - output.Success(fmt.Sprintf("Only one account available: %s (%s)", accounts[0].Name, accounts[0].ID)) - } else { - if !prompt.IsInteractive() { - return fmt.Errorf("account ID argument is required in non-interactive mode") - } - labels := make([]string, len(accounts)) - values := make([]string, len(accounts)) - activeAccountID := config.GetAccountID(cmdutil.ProfileFlag(cmd)) - for i, a := range accounts { - label := fmt.Sprintf("%s (%s)", a.Name, a.ID) - if a.ID == activeAccountID { - label += " [active]" - } - labels[i] = label - values[i] = a.ID - } - accountID, err = prompt.SelectLabeled("Select account", labels, values) - if err != nil { - return err - } - } - } - - profName := cmdutil.ProfileFlag(cmd) - cfg, err := config.Load() - if err != nil { - return err - } - - if profName == "" { - profName, _, err = config.ActiveProfile(cfg) - if err != nil { - return err - } - } - - prof, ok := cfg.Profiles[profName] - if !ok { - return fmt.Errorf("profile %q not found", profName) - } - - prof.AccountID = accountID - cfg.Profiles[profName] = prof - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Switched to account: %s", accountID)) - return nil -} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go deleted file mode 100644 index f53efe1..0000000 --- a/cmd/auth/auth.go +++ /dev/null @@ -1,471 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "os/exec" - "runtime" - "strings" - "time" - - "github.com/mailerlite/mailerlite-cli/internal/config" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/spf13/cobra" -) - -const ( - oauthClientID = "1118" - oauthAuthorizeURL = "https://dashboard.mailerlite.com/oauth/authorize" - oauthTokenURL = "https://dashboard.mailerlite.com/oauth/token" - oauthScopes = "manager-app" -) - -var Cmd = &cobra.Command{ - Use: "auth", - Short: "Authenticate with MailerLite", -} - -var loginCmd = &cobra.Command{ - Use: "login", - Short: "Log in to MailerLite", - Long: "Authenticate via API token or OAuth browser flow.", - RunE: runLogin, -} - -var logoutCmd = &cobra.Command{ - Use: "logout", - Short: "Log out and remove stored credentials", - RunE: runLogout, -} - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show current authentication status", - RunE: runStatus, -} - -func init() { - loginCmd.Flags().String("method", "", "auth method: token or oauth") - loginCmd.Flags().String("token", "", "API token (for token method)") - loginCmd.Flags().String("profile", "", "profile name to save credentials to (default: uses active profile or 'default')") - Cmd.AddCommand(loginCmd, logoutCmd, statusCmd) -} - -func runLogin(cmd *cobra.Command, args []string) error { - method, _ := cmd.Flags().GetString("method") - token, _ := cmd.Flags().GetString("token") - profName, _ := cmd.Flags().GetString("profile") - - if method == "" && prompt.IsInteractive() { - var err error - method, err = prompt.SelectLabeled("Authentication method", []string{"OAuth (Recommended)", "API Token (less secure)"}, []string{"oauth", "token"}) - if err != nil { - return err - } - } - if method == "" { - method = "oauth" - } - - if profName == "" { - profName = "default" - } - - cfg, err := config.Load() - if err != nil { - return err - } - - switch method { - case "token": - if token == "" { - if !prompt.IsInteractive() { - return fmt.Errorf("--token is required in non-interactive mode") - } - token, err = prompt.Input("API Token", "") - if err != nil { - return err - } - } - if token == "" { - return fmt.Errorf("token cannot be empty") - } - cfg.Profiles[profName] = config.Profile{APIToken: token} - - case "oauth": - prof, err := oauthBrowserFlow() - if err != nil { - return fmt.Errorf("OAuth login failed: %w", err) - } - cfg.Profiles[profName] = prof - - default: - return fmt.Errorf("unknown auth method: %s (use 'token' or 'oauth')", method) - } - - cfg.ActiveProfile = profName - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Logged in successfully. Profile: %s", profName)) - - // After OAuth login, prompt to select an account. - if method == "oauth" { - if err := selectAccount(cfg, profName); err != nil { - fmt.Printf("Warning: could not set account: %v\n", err) - } - } - - return nil -} - -func runLogout(cmd *cobra.Command, args []string) error { - profFlag, _ := cmd.Root().PersistentFlags().GetString("profile") - - cfg, err := config.Load() - if err != nil { - return err - } - - name := profFlag - if name == "" { - name = cfg.ActiveProfile - } - if name == "" { - name = "default" - } - - if _, ok := cfg.Profiles[name]; !ok { - return fmt.Errorf("profile %q not found", name) - } - - delete(cfg.Profiles, name) - if cfg.ActiveProfile == name { - cfg.ActiveProfile = "" - for n := range cfg.Profiles { - cfg.ActiveProfile = n - break - } - } - - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Logged out from profile: %s", name)) - return nil -} - -func runStatus(cmd *cobra.Command, args []string) error { - profFlag, _ := cmd.Root().PersistentFlags().GetString("profile") - - cfg, err := config.Load() - if err != nil { - return err - } - - name := profFlag - if name == "" { - name, _, err = config.ActiveProfile(cfg) - if err != nil { - output.Error("Not logged in. Run 'mailerlite auth login' to authenticate.") - return nil - } - } - - prof, ok := cfg.Profiles[name] - if !ok { - output.Error(fmt.Sprintf("Profile %q not found.", name)) - return nil - } - - jsonFlag, _ := cmd.Root().PersistentFlags().GetBool("json") - if jsonFlag { - return output.JSON(map[string]interface{}{ - "profile": name, - "has_token": prof.APIToken != "", - "has_oauth": prof.OAuthToken != "", - "expires_at": prof.OAuthExpiresAt, - "account_id": prof.AccountID, - }) - } - - method := "API Token" - if prof.OAuthToken != "" { - method = "OAuth" - } - maskedToken := "none" - if prof.APIToken != "" { - t := prof.APIToken - if len(t) > 10 { - maskedToken = t[:7] + "..." + t[len(t)-4:] - } else { - maskedToken = "***" - } - } - - rows := [][]string{ - {"Profile", name}, - {"Method", method}, - {"Token", maskedToken}, - {"Active", "Yes"}, - } - - if prof.OAuthExpiresAt != "" { - rows = append(rows, []string{"Expires", prof.OAuthExpiresAt}) - } - if prof.AccountID != "" { - rows = append(rows, []string{"Account ID", prof.AccountID}) - } - - output.Table( - []string{"Field", "Value"}, - rows, - ) - return nil -} - -// oauthBrowserFlow performs the full OAuth 2.0 Authorization Code flow with PKCE. -// It starts a local HTTP server, opens the browser to the authorize URL, -// captures the authorization code, and exchanges it for access/refresh tokens. -func oauthBrowserFlow() (config.Profile, error) { - state, err := randomHex(16) - if err != nil { - return config.Profile{}, err - } - - verifier, challenge, err := generatePKCE() - if err != nil { - return config.Profile{}, err - } - - const callbackPort = "19821" - callbackURL := "http://127.0.0.1:" + callbackPort + "/callback" - - listener, err := net.Listen("tcp", "127.0.0.1:"+callbackPort) - if err != nil { - return config.Profile{}, fmt.Errorf("failed to start local server on port %s: %w", callbackPort, err) - } - defer listener.Close() //nolint:errcheck - - codeCh := make(chan string, 1) - errCh := make(chan error, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - errCh <- fmt.Errorf("state mismatch") - http.Error(w, "State mismatch", http.StatusBadRequest) - return - } - code := r.URL.Query().Get("code") - if code != "" { - codeCh <- code - fmt.Fprintf(w, "

Authentication successful!

You can close this window.

") //nolint:errcheck - } else { - errMsg := r.URL.Query().Get("error") - errCh <- fmt.Errorf("OAuth error: %s", errMsg) - fmt.Fprintf(w, "

Authentication failed

%s

", errMsg) //nolint:errcheck - } - }) - - server := &http.Server{Handler: mux} - go func() { - if err := server.Serve(listener); err != http.ErrServerClosed { - errCh <- err - } - }() - defer server.Shutdown(context.Background()) //nolint:errcheck // best-effort shutdown - - authURL := fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&code_challenge=%s&code_challenge_method=S256", - oauthAuthorizeURL, - oauthClientID, - url.QueryEscape(callbackURL), - url.QueryEscape(oauthScopes), - state, - challenge, - ) - - fmt.Printf("Opening browser for authentication...\n") - fmt.Printf("If the browser doesn't open, visit:\n%s\n\n", authURL) - openBrowser(authURL) - - var code string - select { - case code = <-codeCh: - case err := <-errCh: - return config.Profile{}, err - case <-time.After(5 * time.Minute): - return config.Profile{}, fmt.Errorf("authentication timed out after 5 minutes") - } - - // Exchange authorization code for tokens. - return exchangeCodeForTokens(code, callbackURL, verifier) -} - -// tokenResponse represents the JSON response from the OAuth token endpoint. -type tokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - TokenType string `json:"token_type"` -} - -// exchangeCodeForTokens POSTs to the token endpoint with the -// authorization code and PKCE verifier to obtain access and refresh tokens. -func exchangeCodeForTokens(code, redirectURI, codeVerifier string) (config.Profile, error) { - data := url.Values{ - "grant_type": {"authorization_code"}, - "client_id": {oauthClientID}, - "redirect_uri": {redirectURI}, - "code": {code}, - "code_verifier": {codeVerifier}, - } - - resp, err := http.Post(oauthTokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) //nolint:gosec,noctx - if err != nil { - return config.Profile{}, fmt.Errorf("token exchange request failed: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - var body map[string]interface{} - _ = json.NewDecoder(resp.Body).Decode(&body) - return config.Profile{}, fmt.Errorf("token exchange failed (HTTP %d): %v", resp.StatusCode, body) - } - - var tok tokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { - return config.Profile{}, fmt.Errorf("failed to parse token response: %w", err) - } - - if tok.AccessToken == "" { - return config.Profile{}, fmt.Errorf("server returned empty access token") - } - - expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Format(time.RFC3339) - - return config.Profile{ - OAuthToken: tok.AccessToken, - OAuthRefreshToken: tok.RefreshToken, - OAuthExpiresAt: expiresAt, - }, nil -} - -// generatePKCE creates a PKCE code verifier and its S256 challenge. -func generatePKCE() (verifier, challenge string, err error) { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", "", err - } - verifier = base64.RawURLEncoding.EncodeToString(buf) - - h := sha256.Sum256([]byte(verifier)) - challenge = base64.RawURLEncoding.EncodeToString(h[:]) - return verifier, challenge, nil -} - -func openBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - } - if cmd != nil { - _ = cmd.Start() - } -} - -func randomHex(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -type accountEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` -} - -type accountsResponse struct { - Data []accountEntry `json:"data"` -} - -// selectAccount fetches the user's accounts and prompts to select one. -// If there's only one account, it's selected automatically. -func selectAccount(cfg *config.Config, profName string) error { - prof := cfg.Profiles[profName] - token := prof.OAuthToken - if token == "" { - return nil - } - - req, err := http.NewRequest("GET", "https://connect.mailerlite.com/api/accounts", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch accounts: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch accounts (HTTP %d)", resp.StatusCode) - } - - var accounts accountsResponse - if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { - return fmt.Errorf("failed to parse accounts: %w", err) - } - - if len(accounts.Data) == 0 { - return nil - } - - var accountID string - if len(accounts.Data) == 1 { - accountID = accounts.Data[0].ID - output.Success(fmt.Sprintf("Account selected: %s (%s)", accounts.Data[0].Name, accountID)) - } else { - if !prompt.IsInteractive() { - // Non-interactive: pick the first account. - accountID = accounts.Data[0].ID - } else { - labels := make([]string, len(accounts.Data)) - values := make([]string, len(accounts.Data)) - for i, a := range accounts.Data { - labels[i] = fmt.Sprintf("%s (%s)", a.Name, a.ID) - values[i] = a.ID - } - accountID, err = prompt.SelectLabeled("Select account", labels, values) - if err != nil { - return err - } - } - } - - prof.AccountID = accountID - cfg.Profiles[profName] = prof - return config.Save(cfg) -} diff --git a/cmd/automation/automation.go b/cmd/automation/automation.go deleted file mode 100644 index da42e09..0000000 --- a/cmd/automation/automation.go +++ /dev/null @@ -1,216 +0,0 @@ -package automation - -import ( - "context" - "fmt" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "automation", - Short: "Manage automations", - Long: "List and view automations.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(subscribersCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of automations to return (0 = all)") - listCmd.Flags().String("enabled", "", "filter by enabled status (true, false)") - - // subscribers flags - subscribersCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List automations", - RunE: runList, -} - -func runList(c *cobra.Command, _ []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - enabled, _ := c.Flags().GetString("enabled") - - ctx := context.Background() - - automations, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Automation, bool, error) { - var filters []mailerlite.Filter - if enabled != "" { - filters = append(filters, mailerlite.Filter{Name: "enabled", Value: enabled}) - } - - opts := &mailerlite.ListAutomationOptions{ - Page: page, - Limit: perPage, - } - if len(filters) > 0 { - opts.Filters = &filters - } - - root, _, err := ml.Automation.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(automations) - } - - headers := []string{"ID", "NAME", "ENABLED", "EMAILS", "COMPLETED", "IN QUEUE"} - var rows [][]string - - for _, a := range automations { - enabledStr := "No" - if a.Enabled { - enabledStr = "Yes" - } - rows = append(rows, []string{ - a.ID, - output.Truncate(a.Name, 40), - enabledStr, - strconv.Itoa(a.EmailsCount), - strconv.Itoa(a.Stats.CompletedSubscribersCount), - strconv.Itoa(a.Stats.SubscribersInQueueCount), - }) - } - - output.Table(headers, rows) - return nil -} - -// --- get --- - -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get automation details", - Args: cobra.ExactArgs(1), - RunE: runGet, -} - -func runGet(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Automation.Get(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - d := result.Data - - enabledStr := "No" - if d.Enabled { - enabledStr = "Yes" - } - - fmt.Printf("ID: %s\n", d.ID) - fmt.Printf("Name: %s\n", d.Name) - fmt.Printf("Enabled: %s\n", enabledStr) - fmt.Printf("Emails: %d\n", d.EmailsCount) - fmt.Printf("Created At: %s\n", d.CreatedAt) - - fmt.Println() - fmt.Println("Stats:") - fmt.Printf(" Completed: %d\n", d.Stats.CompletedSubscribersCount) - fmt.Printf(" In Queue: %d\n", d.Stats.SubscribersInQueueCount) - fmt.Printf(" Sent: %d\n", d.Stats.Sent) - fmt.Printf(" Opens: %d\n", d.Stats.OpensCount) - fmt.Printf(" Clicks: %d\n", d.Stats.ClicksCount) - - if len(d.Steps) > 0 { - fmt.Println() - fmt.Println("Steps:") - for _, s := range d.Steps { - fmt.Printf(" - %s (%s)\n", s.Description, s.Type) - } - } - - return nil -} - -// --- subscribers --- - -var subscribersCmd = &cobra.Command{ - Use: "subscribers ", - Short: "List automation subscriber activity", - Args: cobra.ExactArgs(1), - RunE: runSubscribers, -} - -func runSubscribers(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - - ctx := context.Background() - - subscribers, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.AutomationSubscriber, bool, error) { - opts := &mailerlite.ListAutomationSubscriberOptions{ - AutomationID: args[0], - Page: page, - Limit: perPage, - } - - root, _, err := ml.Automation.Subscribers(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(subscribers) - } - - headers := []string{"ID", "EMAIL", "STATUS", "DATE"} - var rows [][]string - - for _, s := range subscribers { - rows = append(rows, []string{ - s.ID, - s.Subscriber.Email, - s.Status, - s.Date, - }) - } - - output.Table(headers, rows) - return nil -} diff --git a/cmd/campaign/campaign.go b/cmd/campaign/campaign.go deleted file mode 100644 index 12f6809..0000000 --- a/cmd/campaign/campaign.go +++ /dev/null @@ -1,556 +0,0 @@ -package campaign - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "campaign", - Short: "Manage campaigns", - Long: "List, view, create, update, schedule, cancel, and delete campaigns.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(scheduleCmd) - Cmd.AddCommand(cancelCmd) - Cmd.AddCommand(subscribersCmd) - Cmd.AddCommand(languagesCmd) - Cmd.AddCommand(deleteCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of campaigns to return (0 = all)") - listCmd.Flags().String("status", "", "filter by status (sent, draft, ready)") - listCmd.Flags().String("type", "", "filter by type (regular, ab, resend)") - - // create flags - createCmd.Flags().String("name", "", "campaign name (required)") - createCmd.Flags().String("type", "regular", "campaign type (regular, ab, resend)") - createCmd.Flags().String("subject", "", "email subject (required)") - createCmd.Flags().String("from", "", "sender email address (required)") - createCmd.Flags().String("from-name", "", "sender name (required)") - createCmd.Flags().String("content", "", "email HTML content") - createCmd.Flags().StringSlice("groups", nil, "group IDs") - createCmd.Flags().StringSlice("segments", nil, "segment IDs") - - // update flags - updateCmd.Flags().String("name", "", "campaign name") - updateCmd.Flags().String("type", "", "campaign type (regular, ab, resend)") - updateCmd.Flags().String("subject", "", "email subject") - updateCmd.Flags().String("from", "", "sender email address") - updateCmd.Flags().String("from-name", "", "sender name") - updateCmd.Flags().String("content", "", "email HTML content") - updateCmd.Flags().StringSlice("groups", nil, "group IDs") - updateCmd.Flags().StringSlice("segments", nil, "segment IDs") - - // schedule flags - scheduleCmd.Flags().String("delivery", "instant", "delivery type (instant, scheduled)") - scheduleCmd.Flags().String("date", "", "schedule date (YYYY-MM-DD)") - scheduleCmd.Flags().String("hours", "", "schedule hours (00-23)") - scheduleCmd.Flags().String("minutes", "", "schedule minutes (00-59)") - scheduleCmd.Flags().Int("timezone-id", 0, "timezone ID") - - // subscribers flags - subscribersCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List campaigns", - RunE: runList, -} - -func runList(c *cobra.Command, _ []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - status, _ := c.Flags().GetString("status") - campaignType, _ := c.Flags().GetString("type") - - ctx := context.Background() - - campaigns, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Campaign, bool, error) { - var filters []mailerlite.Filter - if status != "" { - filters = append(filters, mailerlite.Filter{Name: "status", Value: status}) - } - if campaignType != "" { - filters = append(filters, mailerlite.Filter{Name: "type", Value: campaignType}) - } - - opts := &mailerlite.ListCampaignOptions{ - Page: page, - Limit: perPage, - } - if len(filters) > 0 { - opts.Filters = &filters - } - - root, _, err := ml.Campaign.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(campaigns) - } - - headers := []string{"ID", "NAME", "TYPE", "STATUS", "SENT", "OPENS", "CLICKS"} - var rows [][]string - - for _, camp := range campaigns { - rows = append(rows, []string{ - camp.ID, - output.Truncate(camp.Name, 40), - camp.Type, - camp.Status, - strconv.Itoa(camp.Stats.Sent), - strconv.Itoa(camp.Stats.OpensCount), - strconv.Itoa(camp.Stats.ClicksCount), - }) - } - - output.Table(headers, rows) - return nil -} - -// --- get --- - -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get campaign details", - Args: cobra.ExactArgs(1), - RunE: runGet, -} - -func runGet(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Campaign.Get(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - d := result.Data - - fmt.Printf("ID: %s\n", d.ID) - fmt.Printf("Name: %s\n", d.Name) - fmt.Printf("Type: %s\n", d.Type) - fmt.Printf("Status: %s\n", d.Status) - fmt.Printf("Created At: %s\n", d.CreatedAt) - fmt.Printf("Updated At: %s\n", d.UpdatedAt) - if d.ScheduledFor != "" { - fmt.Printf("Scheduled: %s\n", d.ScheduledFor) - } - - fmt.Println() - fmt.Println("Stats:") - fmt.Printf(" Sent: %d\n", d.Stats.Sent) - fmt.Printf(" Opens: %d\n", d.Stats.OpensCount) - fmt.Printf(" Clicks: %d\n", d.Stats.ClicksCount) - - if len(d.Emails) > 0 { - fmt.Println() - fmt.Println("Emails:") - for _, e := range d.Emails { - fmt.Printf(" - %s (from: %s <%s>)\n", e.Subject, e.FromName, e.From) - } - } - - return nil -} - -// --- create --- - -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a campaign", - RunE: runCreate, -} - -func runCreate(c *cobra.Command, _ []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Campaign name") - if err != nil { - return err - } - - campaignType, _ := c.Flags().GetString("type") - subject, _ := c.Flags().GetString("subject") - subject, err = prompt.RequireArg(subject, "subject", "Email subject") - if err != nil { - return err - } - - from, _ := c.Flags().GetString("from") - from, err = prompt.RequireArg(from, "from", "Sender email address") - if err != nil { - return err - } - - fromName, _ := c.Flags().GetString("from-name") - fromName, err = prompt.RequireArg(fromName, "from-name", "Sender name") - if err != nil { - return err - } - - content, _ := c.Flags().GetString("content") - groups, _ := c.Flags().GetStringSlice("groups") - segments, _ := c.Flags().GetStringSlice("segments") - - ctx := context.Background() - opts := &mailerlite.CreateCampaign{ - Name: name, - Type: campaignType, - Emails: []mailerlite.Emails{ - { - Subject: subject, - From: from, - FromName: fromName, - Content: content, - }, - }, - Groups: groups, - Segments: segments, - } - - result, _, err := ml.Campaign.Create(ctx, opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Campaign created successfully. ID: " + result.Data.ID) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a campaign", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - // First get the existing campaign to preserve unchanged fields. - ctx := context.Background() - existing, _, err := ml.Campaign.Get(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - name := existing.Data.Name - if c.Flags().Changed("name") { - name, _ = c.Flags().GetString("name") - } - - campaignType := existing.Data.Type - if c.Flags().Changed("type") { - campaignType, _ = c.Flags().GetString("type") - } - - // Build email from existing or flags. - var existingEmail mailerlite.Emails - if len(existing.Data.Emails) > 0 { - e := existing.Data.Emails[0] - existingEmail = mailerlite.Emails{ - Subject: e.Subject, - From: e.From, - FromName: e.FromName, - } - } - - if c.Flags().Changed("subject") { - existingEmail.Subject, _ = c.Flags().GetString("subject") - } - if c.Flags().Changed("from") { - existingEmail.From, _ = c.Flags().GetString("from") - } - if c.Flags().Changed("from-name") { - existingEmail.FromName, _ = c.Flags().GetString("from-name") - } - if c.Flags().Changed("content") { - existingEmail.Content, _ = c.Flags().GetString("content") - } - - opts := &mailerlite.UpdateCampaign{ - Name: name, - Type: campaignType, - Emails: []mailerlite.Emails{existingEmail}, - } - - if c.Flags().Changed("groups") { - opts.Groups, _ = c.Flags().GetStringSlice("groups") - } - if c.Flags().Changed("segments") { - opts.Segments, _ = c.Flags().GetStringSlice("segments") - } - - result, _, err := ml.Campaign.Update(ctx, args[0], opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Campaign " + args[0] + " updated successfully.") - return nil -} - -// --- schedule --- - -var scheduleCmd = &cobra.Command{ - Use: "schedule ", - Short: "Schedule a campaign", - Args: cobra.ExactArgs(1), - RunE: runSchedule, -} - -func runSchedule(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - delivery, _ := c.Flags().GetString("delivery") - - opts := &mailerlite.ScheduleCampaign{ - Delivery: delivery, - } - - if strings.EqualFold(delivery, "scheduled") { - date, _ := c.Flags().GetString("date") - hours, _ := c.Flags().GetString("hours") - minutes, _ := c.Flags().GetString("minutes") - timezoneID, _ := c.Flags().GetInt("timezone-id") - - opts.Schedule = &mailerlite.Schedule{ - Date: date, - Hours: hours, - Minutes: minutes, - TimezoneID: timezoneID, - } - } - - ctx := context.Background() - result, _, err := ml.Campaign.Schedule(ctx, args[0], opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Campaign " + args[0] + " scheduled successfully.") - return nil -} - -// --- cancel --- - -var cancelCmd = &cobra.Command{ - Use: "cancel ", - Short: "Cancel a campaign", - Args: cobra.ExactArgs(1), - RunE: runCancel, -} - -func runCancel(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Campaign.Cancel(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Campaign " + args[0] + " cancelled successfully.") - return nil -} - -// --- subscribers --- - -var subscribersCmd = &cobra.Command{ - Use: "subscribers ", - Short: "List campaign subscriber activity", - Args: cobra.ExactArgs(1), - RunE: runSubscribers, -} - -func runSubscribers(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - - ctx := context.Background() - - subscribers, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.CampaignSubscriber, bool, error) { - opts := &mailerlite.ListCampaignSubscriberOptions{ - CampaignID: args[0], - Page: page, - Limit: perPage, - } - - root, _, err := ml.Campaign.Subscribers(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(subscribers) - } - - headers := []string{"ID", "EMAIL", "OPENS", "CLICKS"} - var rows [][]string - - for _, s := range subscribers { - rows = append(rows, []string{ - s.ID, - s.Subscriber.Email, - strconv.Itoa(s.OpensCount), - strconv.Itoa(s.ClicksCount), - }) - } - - output.Table(headers, rows) - return nil -} - -// --- languages --- - -var languagesCmd = &cobra.Command{ - Use: "languages", - Short: "List campaign languages", - RunE: runLanguages, -} - -func runLanguages(c *cobra.Command, _ []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Campaign.Languages(ctx) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result.Data) - } - - headers := []string{"ID", "NAME", "SHORTCODE"} - var rows [][]string - - for _, lang := range result.Data { - rows = append(rows, []string{ - lang.Id, - lang.Name, - lang.Shortcode, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a campaign", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm("Are you sure you want to delete campaign " + args[0] + "?") - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Campaign.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Campaign " + args[0] + " deleted successfully.") - return nil -} diff --git a/cmd/cart/cart.go b/cmd/cart/cart.go deleted file mode 100644 index 2cec860..0000000 --- a/cmd/cart/cart.go +++ /dev/null @@ -1,216 +0,0 @@ -package cart - -import ( - "context" - "fmt" - "net/http" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "cart", - Short: "Manage e-commerce carts", - Long: "List, view, and update carts within a shop.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of carts to return (0 = all)") - - // update flags - updateCmd.Flags().String("customer", "", "customer ID") - updateCmd.Flags().String("currency", "", "cart currency") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List carts", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - carts, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Cart, bool, error) { - path := fmt.Sprintf("/ecommerce/shops/%s/carts?page=%d&limit=%d", shopID, page, perPage) - var result ecommerce.RootCarts - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(carts) - } - - headers := []string{"ID", "CUSTOMER", "CURRENCY", "TOTAL", "CREATED"} - var rows [][]string - for _, c := range carts { - rows = append(rows, []string{ - c.ID, - c.CustomerID, - c.Currency, - strconv.FormatFloat(c.Total, 'f', 2, 64), - c.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get cart details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/carts/%s", shopID, args[0]) - var result ecommerce.RootCart - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - c := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", c.ID}, - {"Customer ID", c.CustomerID}, - {"Currency", c.Currency}, - {"Total", strconv.FormatFloat(c.Total, 'f', 2, 64)}, - {"Created", c.CreatedAt}, - {"Updated", c.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a cart", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]string) - if cmd.Flags().Changed("customer") { - v, _ := cmd.Flags().GetString("customer") - body["customer_id"] = v - } - if cmd.Flags().Changed("currency") { - v, _ := cmd.Flags().GetString("currency") - body["currency"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/carts/%s", shopID, args[0]) - var result ecommerce.RootCart - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Cart updated: %s", result.Data.ID)) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total cart count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/carts?limit=0", shopID) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total carts: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/cartitem/cartitem.go b/cmd/cartitem/cartitem.go deleted file mode 100644 index d4c09cf..0000000 --- a/cmd/cartitem/cartitem.go +++ /dev/null @@ -1,331 +0,0 @@ -package cartitem - -import ( - "context" - "fmt" - "net/http" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "cart-item", - Short: "Manage cart items", - Long: "List, create, update, and delete items within a cart.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - Cmd.PersistentFlags().String("cart", "", "cart ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of items to return (0 = all)") - - // create flags - createCmd.Flags().String("product", "", "product ID (required)") - createCmd.Flags().Int("quantity", 1, "item quantity") - createCmd.Flags().Float64("price", 0, "item price") - - // update flags - updateCmd.Flags().Int("quantity", 0, "item quantity") - updateCmd.Flags().Float64("price", 0, "item price") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -func cartFlag(cmd *cobra.Command) (string, error) { - cart, _ := cmd.Flags().GetString("cart") - return prompt.RequireArg(cart, "cart", "Cart ID") -} - -func basePath(shopID, cartID string) string { - return fmt.Sprintf("/ecommerce/shops/%s/carts/%s/items", shopID, cartID) -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List cart items", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - items, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.CartItem, bool, error) { - path := fmt.Sprintf("%s?page=%d&limit=%d", basePath(shopID, cartID), page, perPage) - var result ecommerce.RootCartItems - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(items) - } - - headers := []string{"ID", "PRODUCT", "QUANTITY", "PRICE", "CREATED"} - var rows [][]string - for _, i := range items { - rows = append(rows, []string{ - i.ID, - i.ProductID, - strconv.Itoa(i.Quantity), - strconv.FormatFloat(i.Price, 'f', 2, 64), - i.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get cart item details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("%s/%s", basePath(shopID, cartID), args[0]) - var result ecommerce.RootCartItem - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - i := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", i.ID}, - {"Product ID", i.ProductID}, - {"Quantity", strconv.Itoa(i.Quantity)}, - {"Price", strconv.FormatFloat(i.Price, 'f', 2, 64)}, - {"Created", i.CreatedAt}, - {"Updated", i.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Add an item to a cart", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - productID, _ := cmd.Flags().GetString("product") - productID, err = prompt.RequireArg(productID, "product", "Product ID") - if err != nil { - return err - } - - quantity, _ := cmd.Flags().GetInt("quantity") - price, _ := cmd.Flags().GetFloat64("price") - - body := map[string]interface{}{ - "product_id": productID, - "quantity": quantity, - "price": price, - } - - ctx := context.Background() - var result ecommerce.RootCartItem - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, basePath(shopID, cartID), body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Cart item created: %s (ID: %s)", result.Data.ProductID, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a cart item", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]interface{}) - if cmd.Flags().Changed("quantity") { - v, _ := cmd.Flags().GetInt("quantity") - body["quantity"] = v - } - if cmd.Flags().Changed("price") { - v, _ := cmd.Flags().GetFloat64("price") - body["price"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("%s/%s", basePath(shopID, cartID), args[0]) - var result ecommerce.RootCartItem - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Cart item updated: %s", result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a cart item", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("%s/%s", basePath(shopID, cartID), args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Cart item %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total cart item count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - cartID, err := cartFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("%s?limit=0", basePath(shopID, cartID)) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total cart items: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/category/category.go b/cmd/category/category.go deleted file mode 100644 index 4676ef7..0000000 --- a/cmd/category/category.go +++ /dev/null @@ -1,398 +0,0 @@ -package category - -import ( - "context" - "fmt" - "net/http" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "category", - Short: "Manage e-commerce categories", - Long: "List, create, update, and delete categories within a shop.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - Cmd.AddCommand(productsCmd) - Cmd.AddCommand(assignProductCmd) - Cmd.AddCommand(unassignProductCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of categories to return (0 = all)") - - // create flags - createCmd.Flags().String("name", "", "category name (required)") - - // update flags - updateCmd.Flags().String("name", "", "category name") - - // assign-product flags - assignProductCmd.Flags().String("product", "", "product ID (required)") - - // unassign-product flags - unassignProductCmd.Flags().String("product", "", "product ID (required)") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List categories", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - categories, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Category, bool, error) { - path := fmt.Sprintf("/ecommerce/shops/%s/categories?page=%d&limit=%d", shopID, page, perPage) - var result ecommerce.RootCategories - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(categories) - } - - headers := []string{"ID", "NAME", "CREATED"} - var rows [][]string - for _, c := range categories { - rows = append(rows, []string{c.ID, c.Name, c.CreatedAt}) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get category details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s", shopID, args[0]) - var result ecommerce.RootCategory - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - c := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", c.ID}, - {"Name", c.Name}, - {"Created", c.CreatedAt}, - {"Updated", c.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new category", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - name, _ := cmd.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Category name") - if err != nil { - return err - } - - body := map[string]string{"name": name} - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories", shopID) - var result ecommerce.RootCategory - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Category created: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a category", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]string) - if cmd.Flags().Changed("name") { - v, _ := cmd.Flags().GetString("name") - body["name"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s", shopID, args[0]) - var result ecommerce.RootCategory - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Category updated: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a category", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s", shopID, args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Category %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total category count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories?limit=0", shopID) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total categories: %d\n", result.Total) - return nil - }, -} - -// products - list products in a category -var productsCmd = &cobra.Command{ - Use: "products ", - Short: "List products in a category", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s/products", shopID, args[0]) - var result ecommerce.RootProducts - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - headers := []string{"ID", "NAME", "PRICE", "CREATED"} - var rows [][]string - for _, p := range result.Data { - rows = append(rows, []string{ - p.ID, - p.Name, - fmt.Sprintf("%.2f", p.Price), - p.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil - }, -} - -// assign-product -var assignProductCmd = &cobra.Command{ - Use: "assign-product ", - Short: "Assign a product to a category", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - productID, _ := cmd.Flags().GetString("product") - productID, err = prompt.RequireArg(productID, "product", "Product ID") - if err != nil { - return err - } - - body := map[string]string{"product_id": productID} - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s/products", shopID, args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, body, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Product %s assigned to category %s.", productID, args[0])) - return nil - }, -} - -// unassign-product -var unassignProductCmd = &cobra.Command{ - Use: "unassign-product ", - Short: "Remove a product from a category", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - productID, _ := cmd.Flags().GetString("product") - productID, err = prompt.RequireArg(productID, "product", "Product ID") - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/categories/%s/products/%s", shopID, args[0], productID) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Product %s removed from category %s.", productID, args[0])) - return nil - }, -} diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go deleted file mode 100644 index 3f67d95..0000000 --- a/cmd/completion/completion.go +++ /dev/null @@ -1,48 +0,0 @@ -package completion - -import ( - "os" - - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", - Long: `Generate shell completion scripts for mailerlite. - -To load completions: - -Bash: - $ source <(mailerlite completion bash) - # Or for permanent: - $ mailerlite completion bash > /etc/bash_completion.d/mailerlite - -Zsh: - $ mailerlite completion zsh > "${fpath[1]}/_mailerlite" - -Fish: - $ mailerlite completion fish | source - # Or for permanent: - $ mailerlite completion fish > ~/.config/fish/completions/mailerlite.fish - -PowerShell: - PS> mailerlite completion powershell | Out-String | Invoke-Expression -`, - DisableFlagsInUseLine: true, - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - RunE: func(cmd *cobra.Command, args []string) error { - switch args[0] { - case "bash": - return cmd.Root().GenBashCompletion(os.Stdout) - case "zsh": - return cmd.Root().GenZshCompletion(os.Stdout) - case "fish": - return cmd.Root().GenFishCompletion(os.Stdout, true) - case "powershell": - return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) - } - return nil - }, -} diff --git a/cmd/customer/customer.go b/cmd/customer/customer.go deleted file mode 100644 index 196f64a..0000000 --- a/cmd/customer/customer.go +++ /dev/null @@ -1,295 +0,0 @@ -package customer - -import ( - "context" - "fmt" - "net/http" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "customer", - Short: "Manage e-commerce customers", - Long: "List, create, update, and delete customers within a shop.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of customers to return (0 = all)") - - // create flags - createCmd.Flags().String("email", "", "customer email (required)") - createCmd.Flags().String("first-name", "", "customer first name") - createCmd.Flags().String("last-name", "", "customer last name") - - // update flags - updateCmd.Flags().String("email", "", "customer email") - updateCmd.Flags().String("first-name", "", "customer first name") - updateCmd.Flags().String("last-name", "", "customer last name") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List customers", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - customers, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Customer, bool, error) { - path := fmt.Sprintf("/ecommerce/shops/%s/customers?page=%d&limit=%d", shopID, page, perPage) - var result ecommerce.RootCustomers - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(customers) - } - - headers := []string{"ID", "EMAIL", "FIRST NAME", "LAST NAME", "CREATED"} - var rows [][]string - for _, c := range customers { - rows = append(rows, []string{c.ID, c.Email, c.FirstName, c.LastName, c.CreatedAt}) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get customer details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/customers/%s", shopID, args[0]) - var result ecommerce.RootCustomer - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - c := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", c.ID}, - {"Email", c.Email}, - {"First Name", c.FirstName}, - {"Last Name", c.LastName}, - {"Created", c.CreatedAt}, - {"Updated", c.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new customer", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - email, _ := cmd.Flags().GetString("email") - email, err = prompt.RequireArg(email, "email", "Customer email") - if err != nil { - return err - } - - body := map[string]string{"email": email} - if v, _ := cmd.Flags().GetString("first-name"); v != "" { - body["first_name"] = v - } - if v, _ := cmd.Flags().GetString("last-name"); v != "" { - body["last_name"] = v - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/customers", shopID) - var result ecommerce.RootCustomer - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Customer created: %s (ID: %s)", result.Data.Email, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a customer", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]string) - if cmd.Flags().Changed("email") { - v, _ := cmd.Flags().GetString("email") - body["email"] = v - } - if cmd.Flags().Changed("first-name") { - v, _ := cmd.Flags().GetString("first-name") - body["first_name"] = v - } - if cmd.Flags().Changed("last-name") { - v, _ := cmd.Flags().GetString("last-name") - body["last_name"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/customers/%s", shopID, args[0]) - var result ecommerce.RootCustomer - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Customer updated: %s (ID: %s)", result.Data.Email, result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a customer", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/customers/%s", shopID, args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Customer %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total customer count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/customers?limit=0", shopID) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total customers: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/dashboard/dashboard.go b/cmd/dashboard/dashboard.go deleted file mode 100644 index f954561..0000000 --- a/cmd/dashboard/dashboard.go +++ /dev/null @@ -1,41 +0,0 @@ -package dashboard - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/tui" - "github.com/spf13/cobra" -) - -// Cmd is the dashboard command. -var Cmd = &cobra.Command{ - Use: "dashboard", - Short: "Interactive dashboard for managing MailerLite", - Long: `Launch a terminal UI for browsing subscribers, campaigns, automations, and more. - -The dashboard provides a lazygit-style interface with: - - Sidebar navigation between views - - Vim-style keybindings (j/k to navigate, Enter to select) - - Real-time data from your MailerLite account - -Press ? for help or q to quit.`, - RunE: runDashboard, -} - -func runDashboard(cmd *cobra.Command, _ []string) error { - client, err := cmdutil.NewSDKClient(cmd) - if err != nil { - return err - } - - profile := cmdutil.ProfileFlag(cmd) - if profile == "" { - profile = "default" - } - - app := tui.NewApp(client, profile) - - p := tea.NewProgram(app, tea.WithAltScreen()) - _, err = p.Run() - return err -} diff --git a/cmd/field/field.go b/cmd/field/field.go deleted file mode 100644 index fba2d34..0000000 --- a/cmd/field/field.go +++ /dev/null @@ -1,202 +0,0 @@ -package field - -import ( - "context" - "fmt" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "field", - Short: "Manage fields", - Long: "List, create, update, and delete subscriber fields.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of fields to return (0 = all)") - listCmd.Flags().String("sort", "", "sort order (e.g. name)") - - // create flags - createCmd.Flags().String("name", "", "field name (required)") - createCmd.Flags().String("type", "", "field type: text, number, or date (required)") - - // update flags - updateCmd.Flags().String("name", "", "new field name (required)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List fields", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - sort, _ := c.Flags().GetString("sort") - - ctx := context.Background() - - allFields, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Field, bool, error) { - opts := &mailerlite.ListFieldOptions{ - Page: page, - Limit: perPage, - Sort: sort, - } - root, _, err := ml.Field.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(allFields) - } - - headers := []string{"ID", "NAME", "KEY", "TYPE"} - var rows [][]string - - for _, f := range allFields { - rows = append(rows, []string{ - f.Id, - f.Name, - f.Key, - f.Type, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- create --- - -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a field", - RunE: runCreate, -} - -func runCreate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Field name") - if err != nil { - return err - } - - fieldType, _ := c.Flags().GetString("type") - fieldType, err = prompt.RequireArg(fieldType, "type", "Field type (text, number, date)") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Field.Create(ctx, name, fieldType) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Field created successfully. ID: " + result.Data.Id) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a field", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "New field name") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Field.Update(ctx, args[0], name) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Field " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a field", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm(fmt.Sprintf("Delete field %s?", args[0])) - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Field.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Field " + args[0] + " deleted successfully.") - return nil -} diff --git a/cmd/form/form.go b/cmd/form/form.go deleted file mode 100644 index 26f7823..0000000 --- a/cmd/form/form.go +++ /dev/null @@ -1,274 +0,0 @@ -package form - -import ( - "context" - "fmt" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "form", - Short: "Manage forms", - Long: "List, view, update, and delete forms.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(subscribersCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of forms to return (0 = all)") - listCmd.Flags().String("type", "popup", "form type (popup, embedded, promotion)") - listCmd.Flags().String("sort", "", "sort field") - - // update flags - updateCmd.Flags().String("name", "", "form name (required)") - - // subscribers flags - subscribersCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List forms", - RunE: runList, -} - -func runList(c *cobra.Command, _ []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - formType, _ := c.Flags().GetString("type") - sort, _ := c.Flags().GetString("sort") - - ctx := context.Background() - - forms, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Form, bool, error) { - opts := &mailerlite.ListFormOptions{ - Type: formType, - Page: page, - Limit: perPage, - Sort: sort, - } - - root, _, err := ml.Form.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(forms) - } - - headers := []string{"ID", "NAME", "TYPE", "ACTIVE", "CONVERSIONS", "OPENS"} - var rows [][]string - - for _, f := range forms { - active := "No" - if f.Active { - active = "Yes" - } - rows = append(rows, []string{ - f.Id, - output.Truncate(f.Name, 40), - f.Type, - active, - strconv.Itoa(f.ConversionsCount), - strconv.Itoa(f.OpensCount), - }) - } - - output.Table(headers, rows) - return nil -} - -// --- get --- - -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get form details", - Args: cobra.ExactArgs(1), - RunE: runGet, -} - -func runGet(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Form.Get(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - d := result.Data - - active := "No" - if d.Active { - active = "Yes" - } - - fmt.Printf("ID: %s\n", d.Id) - fmt.Printf("Name: %s\n", d.Name) - fmt.Printf("Type: %s\n", d.Type) - fmt.Printf("Active: %s\n", active) - fmt.Printf("Conversions: %d\n", d.ConversionsCount) - fmt.Printf("Opens: %d\n", d.OpensCount) - fmt.Printf("Created At: %s\n", d.CreatedAt) - - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a form", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Form name") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Form.Update(ctx, args[0], name) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Form " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a form", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm("Are you sure you want to delete form " + args[0] + "?") - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Form.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Form " + args[0] + " deleted successfully.") - return nil -} - -// --- subscribers --- - -var subscribersCmd = &cobra.Command{ - Use: "subscribers ", - Short: "List form subscribers", - Args: cobra.ExactArgs(1), - RunE: runSubscribers, -} - -func runSubscribers(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - - ctx := context.Background() - - subscribers, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Subscriber, bool, error) { - opts := &mailerlite.ListFormSubscriberOptions{ - FormID: args[0], - Page: page, - Limit: perPage, - } - - root, _, err := ml.Form.Subscribers(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(subscribers) - } - - headers := []string{"ID", "EMAIL", "STATUS", "CREATED AT"} - var rows [][]string - - for _, s := range subscribers { - rows = append(rows, []string{ - s.ID, - s.Email, - s.Status, - s.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil -} diff --git a/cmd/group/group.go b/cmd/group/group.go deleted file mode 100644 index e79adea..0000000 --- a/cmd/group/group.go +++ /dev/null @@ -1,319 +0,0 @@ -package group - -import ( - "context" - "fmt" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "group", - Short: "Manage groups", - Long: "List, create, update, and delete groups. Manage group subscriber assignments.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(subscribersCmd) - Cmd.AddCommand(assignCmd) - Cmd.AddCommand(unassignCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of groups to return (0 = all)") - listCmd.Flags().String("sort", "", "sort field (e.g. name, created_at)") - - // create flags - createCmd.Flags().String("name", "", "group name (required)") - - // update flags - updateCmd.Flags().String("name", "", "group name (required)") - - // subscribers flags - subscribersCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List groups", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - sort, _ := c.Flags().GetString("sort") - - ctx := context.Background() - - groups, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Group, bool, error) { - opts := &mailerlite.ListGroupOptions{ - Page: page, - Limit: perPage, - Sort: sort, - } - - root, _, err := ml.Group.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - hasNext := !root.Links.IsLastPage() - return root.Data, hasNext, nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(groups) - } - - headers := []string{"ID", "NAME", "ACTIVE", "SENT", "OPENS", "CLICK RATE", "CREATED AT"} - var rows [][]string - - for _, g := range groups { - rows = append(rows, []string{ - g.ID, - output.Truncate(g.Name, 40), - strconv.Itoa(g.ActiveCount), - strconv.Itoa(g.SentCount), - strconv.Itoa(g.OpensCount), - g.ClickRate.String, - g.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- create --- - -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a group", - RunE: runCreate, -} - -func runCreate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Group name") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Group.Create(ctx, name) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Group created successfully. ID: " + result.Data.ID) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a group", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Group name") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Group.Update(ctx, args[0], name) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Group " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a group", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm("Delete group " + args[0] + "?") - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Group.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Group " + args[0] + " deleted successfully.") - return nil -} - -// --- subscribers --- - -var subscribersCmd = &cobra.Command{ - Use: "subscribers ", - Short: "List subscribers in a group", - Args: cobra.ExactArgs(1), - RunE: runSubscribers, -} - -func runSubscribers(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - groupID := args[0] - - ctx := context.Background() - - subscribers, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Subscriber, bool, error) { - opts := &mailerlite.ListGroupSubscriberOptions{ - GroupID: groupID, - Page: page, - Limit: perPage, - } - - root, _, err := ml.Group.Subscribers(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - - hasNext := !root.Links.IsLastPage() - return root.Data, hasNext, nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(subscribers) - } - - headers := []string{"EMAIL", "STATUS", "SOURCE", "OPENS", "CLICKS", "SUBSCRIBED AT"} - var rows [][]string - - for _, s := range subscribers { - rows = append(rows, []string{ - output.Truncate(s.Email, 40), - s.Status, - s.Source, - strconv.Itoa(s.OpensCount), - strconv.Itoa(s.ClicksCount), - s.SubscribedAt, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- assign --- - -var assignCmd = &cobra.Command{ - Use: "assign ", - Short: "Assign a subscriber to a group", - Args: cobra.ExactArgs(2), - RunE: runAssign, -} - -func runAssign(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - _, _, err = ml.Group.Assign(ctx, args[0], args[1]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success(fmt.Sprintf("Subscriber %s assigned to group %s successfully.", args[1], args[0])) - return nil -} - -// --- unassign --- - -var unassignCmd = &cobra.Command{ - Use: "unassign ", - Short: "Unassign a subscriber from a group", - Args: cobra.ExactArgs(2), - RunE: runUnassign, -} - -func runUnassign(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - _, err = ml.Group.UnAssign(ctx, args[0], args[1]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success(fmt.Sprintf("Subscriber %s unassigned from group %s successfully.", args[1], args[0])) - return nil -} diff --git a/cmd/import/import.go b/cmd/import/import.go deleted file mode 100644 index 581104b..0000000 --- a/cmd/import/import.go +++ /dev/null @@ -1,120 +0,0 @@ -package importcmd - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "import", - Short: "Bulk import e-commerce data", - Long: "Import categories, products, or orders in bulk from a JSON file.", -} - -func init() { - Cmd.AddCommand(categoriesCmd) - Cmd.AddCommand(productsCmd) - Cmd.AddCommand(ordersCmd) - - // categories flags - categoriesCmd.Flags().String("shop", "", "shop ID (required)") - categoriesCmd.Flags().String("file", "", "path to JSON file (required)") - - // products flags - productsCmd.Flags().String("shop", "", "shop ID (required)") - productsCmd.Flags().String("file", "", "path to JSON file (required)") - - // orders flags - ordersCmd.Flags().String("shop", "", "shop ID (required)") - ordersCmd.Flags().String("file", "", "path to JSON file (required)") -} - -func readJSONFile(path string) (json.RawMessage, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", path, err) - } - - if !json.Valid(data) { - return nil, fmt.Errorf("file %s does not contain valid JSON", path) - } - - return json.RawMessage(data), nil -} - -func doImport(cmd *cobra.Command, resource string) error { - shopID, _ := cmd.Flags().GetString("shop") - var err error - shopID, err = prompt.RequireArg(shopID, "shop", "Shop ID") - if err != nil { - return err - } - - filePath, _ := cmd.Flags().GetString("file") - filePath, err = prompt.RequireArg(filePath, "file", "Path to JSON file") - if err != nil { - return err - } - - data, err := readJSONFile(filePath) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/%s/import", shopID, resource) - - var result json.RawMessage - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, data, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - output.Success(fmt.Sprintf("Import of %s completed successfully.", resource)) - return nil -} - -// categories -var categoriesCmd = &cobra.Command{ - Use: "categories", - Short: "Bulk import categories", - RunE: func(cmd *cobra.Command, args []string) error { - return doImport(cmd, "categories") - }, -} - -// products -var productsCmd = &cobra.Command{ - Use: "products", - Short: "Bulk import products", - RunE: func(cmd *cobra.Command, args []string) error { - return doImport(cmd, "products") - }, -} - -// orders -var ordersCmd = &cobra.Command{ - Use: "orders", - Short: "Bulk import orders", - RunE: func(cmd *cobra.Command, args []string) error { - return doImport(cmd, "orders") - }, -} diff --git a/cmd/order/order.go b/cmd/order/order.go deleted file mode 100644 index 84e7ff0..0000000 --- a/cmd/order/order.go +++ /dev/null @@ -1,338 +0,0 @@ -package order - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "order", - Short: "Manage e-commerce orders", - Long: "List, create, update, and delete orders within a shop.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of orders to return (0 = all)") - - // create flags - createCmd.Flags().String("customer", "", "customer ID (required)") - createCmd.Flags().String("status", "", "order status (required)") - createCmd.Flags().Float64("total", 0, "order total (required)") - createCmd.Flags().String("currency", "USD", "order currency") - createCmd.Flags().String("items", "", "order items as JSON array") - - // update flags - updateCmd.Flags().String("customer", "", "customer ID") - updateCmd.Flags().String("status", "", "order status") - updateCmd.Flags().Float64("total", 0, "order total") - updateCmd.Flags().String("currency", "", "order currency") - updateCmd.Flags().String("items", "", "order items as JSON array") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List orders", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - orders, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Order, bool, error) { - path := fmt.Sprintf("/ecommerce/shops/%s/orders?page=%d&limit=%d", shopID, page, perPage) - var result ecommerce.RootOrders - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(orders) - } - - headers := []string{"ID", "CUSTOMER", "STATUS", "TOTAL", "CURRENCY", "CREATED"} - var rows [][]string - for _, o := range orders { - rows = append(rows, []string{ - o.ID, - o.CustomerID, - o.Status, - strconv.FormatFloat(o.Total, 'f', 2, 64), - o.Currency, - o.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get order details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/orders/%s", shopID, args[0]) - var result ecommerce.RootOrder - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - o := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", o.ID}, - {"Customer ID", o.CustomerID}, - {"Status", o.Status}, - {"Total", strconv.FormatFloat(o.Total, 'f', 2, 64)}, - {"Currency", o.Currency}, - {"Items", strconv.Itoa(len(o.Items))}, - {"Created", o.CreatedAt}, - {"Updated", o.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new order", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - customerID, _ := cmd.Flags().GetString("customer") - customerID, err = prompt.RequireArg(customerID, "customer", "Customer ID") - if err != nil { - return err - } - - status, _ := cmd.Flags().GetString("status") - status, err = prompt.RequireArg(status, "status", "Order status") - if err != nil { - return err - } - - total, _ := cmd.Flags().GetFloat64("total") - currency, _ := cmd.Flags().GetString("currency") - - body := map[string]interface{}{ - "customer_id": customerID, - "status": status, - "total": total, - "currency": currency, - } - - if itemsStr, _ := cmd.Flags().GetString("items"); itemsStr != "" { - var items []ecommerce.OrderItem - if err := json.Unmarshal([]byte(itemsStr), &items); err != nil { - return fmt.Errorf("invalid --items JSON: %w", err) - } - body["items"] = items - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/orders", shopID) - var result ecommerce.RootOrder - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Order created: %s (ID: %s)", result.Data.Status, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update an order", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]interface{}) - if cmd.Flags().Changed("customer") { - v, _ := cmd.Flags().GetString("customer") - body["customer_id"] = v - } - if cmd.Flags().Changed("status") { - v, _ := cmd.Flags().GetString("status") - body["status"] = v - } - if cmd.Flags().Changed("total") { - v, _ := cmd.Flags().GetFloat64("total") - body["total"] = v - } - if cmd.Flags().Changed("currency") { - v, _ := cmd.Flags().GetString("currency") - body["currency"] = v - } - if cmd.Flags().Changed("items") { - itemsStr, _ := cmd.Flags().GetString("items") - var items []ecommerce.OrderItem - if err := json.Unmarshal([]byte(itemsStr), &items); err != nil { - return fmt.Errorf("invalid --items JSON: %w", err) - } - body["items"] = items - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/orders/%s", shopID, args[0]) - var result ecommerce.RootOrder - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Order updated: %s (ID: %s)", result.Data.Status, result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an order", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/orders/%s", shopID, args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Order %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total order count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/orders?limit=0", shopID) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total orders: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/product/product.go b/cmd/product/product.go deleted file mode 100644 index 4e03d06..0000000 --- a/cmd/product/product.go +++ /dev/null @@ -1,332 +0,0 @@ -package product - -import ( - "context" - "fmt" - "net/http" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "product", - Short: "Manage e-commerce products", - Long: "List, create, update, and delete products within a shop.", -} - -func init() { - Cmd.PersistentFlags().String("shop", "", "shop ID (required)") - - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of products to return (0 = all)") - - // create flags - createCmd.Flags().String("name", "", "product name (required)") - createCmd.Flags().Float64("price", 0, "product price (required)") - createCmd.Flags().String("url", "", "product URL") - createCmd.Flags().String("image-url", "", "product image URL") - createCmd.Flags().String("description", "", "product description") - createCmd.Flags().Int("quantity", 0, "product quantity") - - // update flags - updateCmd.Flags().String("name", "", "product name") - updateCmd.Flags().Float64("price", 0, "product price") - updateCmd.Flags().String("url", "", "product URL") - updateCmd.Flags().String("image-url", "", "product image URL") - updateCmd.Flags().String("description", "", "product description") - updateCmd.Flags().Int("quantity", 0, "product quantity") -} - -func shopFlag(cmd *cobra.Command) (string, error) { - shop, _ := cmd.Flags().GetString("shop") - return prompt.RequireArg(shop, "shop", "Shop ID") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List products", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - products, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Product, bool, error) { - path := fmt.Sprintf("/ecommerce/shops/%s/products?page=%d&limit=%d", shopID, page, perPage) - var result ecommerce.RootProducts - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(products) - } - - headers := []string{"ID", "NAME", "PRICE", "QUANTITY", "CREATED"} - var rows [][]string - for _, p := range products { - rows = append(rows, []string{ - p.ID, - p.Name, - strconv.FormatFloat(p.Price, 'f', 2, 64), - strconv.Itoa(p.Quantity), - p.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get product details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/products/%s", shopID, args[0]) - var result ecommerce.RootProduct - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - p := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", p.ID}, - {"Name", p.Name}, - {"Price", strconv.FormatFloat(p.Price, 'f', 2, 64)}, - {"URL", p.URL}, - {"Image URL", p.ImageURL}, - {"Description", output.Truncate(p.Description, 60)}, - {"Quantity", strconv.Itoa(p.Quantity)}, - {"Created", p.CreatedAt}, - {"Updated", p.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new product", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - name, _ := cmd.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Product name") - if err != nil { - return err - } - - price, _ := cmd.Flags().GetFloat64("price") - - body := map[string]interface{}{"name": name, "price": price} - if v, _ := cmd.Flags().GetString("url"); v != "" { - body["url"] = v - } - if v, _ := cmd.Flags().GetString("image-url"); v != "" { - body["image_url"] = v - } - if v, _ := cmd.Flags().GetString("description"); v != "" { - body["description"] = v - } - if cmd.Flags().Changed("quantity") { - v, _ := cmd.Flags().GetInt("quantity") - body["quantity"] = v - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/products", shopID) - var result ecommerce.RootProduct - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Product created: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a product", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]interface{}) - if cmd.Flags().Changed("name") { - v, _ := cmd.Flags().GetString("name") - body["name"] = v - } - if cmd.Flags().Changed("price") { - v, _ := cmd.Flags().GetFloat64("price") - body["price"] = v - } - if cmd.Flags().Changed("url") { - v, _ := cmd.Flags().GetString("url") - body["url"] = v - } - if cmd.Flags().Changed("image-url") { - v, _ := cmd.Flags().GetString("image-url") - body["image_url"] = v - } - if cmd.Flags().Changed("description") { - v, _ := cmd.Flags().GetString("description") - body["description"] = v - } - if cmd.Flags().Changed("quantity") { - v, _ := cmd.Flags().GetInt("quantity") - body["quantity"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/products/%s", shopID, args[0]) - var result ecommerce.RootProduct - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Product updated: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a product", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/products/%s", shopID, args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Product %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total product count", - RunE: func(cmd *cobra.Command, args []string) error { - shopID, err := shopFlag(cmd) - if err != nil { - return err - } - - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s/products?limit=0", shopID) - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total products: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go deleted file mode 100644 index d92ff46..0000000 --- a/cmd/profile/profile.go +++ /dev/null @@ -1,197 +0,0 @@ -package profile - -import ( - "fmt" - "sort" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/config" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "profile", - Short: "Manage authentication profiles", -} - -var addCmd = &cobra.Command{ - Use: "add ", - Short: "Add a new profile", - Args: cobra.ExactArgs(1), - RunE: runAdd, -} - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all profiles", - RunE: runList, -} - -var switchCmd = &cobra.Command{ - Use: "switch ", - Short: "Switch active profile", - Args: cobra.ExactArgs(1), - RunE: runSwitch, -} - -var removeCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a profile", - Args: cobra.ExactArgs(1), - RunE: runRemove, -} - -func init() { - addCmd.Flags().String("token", "", "API token for this profile") - Cmd.AddCommand(addCmd, listCmd, switchCmd, removeCmd) -} - -func runAdd(cmd *cobra.Command, args []string) error { - name := args[0] - token, _ := cmd.Flags().GetString("token") - - if token == "" && prompt.IsInteractive() { - var err error - token, err = prompt.Input("API Token", "") - if err != nil { - return err - } - } - if token == "" { - return fmt.Errorf("--token is required") - } - - cfg, err := config.Load() - if err != nil { - return err - } - - if _, exists := cfg.Profiles[name]; exists { - if !cmdutil.YesFlag(cmd) && prompt.IsInteractive() { - ok, err := prompt.Confirm(fmt.Sprintf("Profile %q already exists. Overwrite?", name)) - if err != nil { - return err - } - if !ok { - return nil - } - } - } - - cfg.Profiles[name] = config.Profile{APIToken: token} - if cfg.ActiveProfile == "" { - cfg.ActiveProfile = name - } - - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Profile %q added.", name)) - return nil -} - -func runList(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() - if err != nil { - return err - } - - jsonFlag, _ := cmd.Root().PersistentFlags().GetBool("json") - if jsonFlag { - profiles := make([]map[string]interface{}, 0, len(cfg.Profiles)) - for name, p := range cfg.Profiles { - profiles = append(profiles, map[string]interface{}{ - "name": name, - "active": name == cfg.ActiveProfile, - "has_token": p.APIToken != "", - }) - } - return output.JSON(profiles) - } - - if len(cfg.Profiles) == 0 { - fmt.Println("No profiles configured. Run 'mailerlite profile add ' to create one.") - return nil - } - - names := make([]string, 0, len(cfg.Profiles)) - for n := range cfg.Profiles { - names = append(names, n) - } - sort.Strings(names) - - var rows [][]string - for _, name := range names { - active := "" - if name == cfg.ActiveProfile { - active = "*" - } - rows = append(rows, []string{active, name, "token"}) - } - - output.Table([]string{"", "NAME", "METHOD"}, rows) - return nil -} - -func runSwitch(cmd *cobra.Command, args []string) error { - name := args[0] - - cfg, err := config.Load() - if err != nil { - return err - } - - if _, ok := cfg.Profiles[name]; !ok { - return fmt.Errorf("profile %q not found", name) - } - - cfg.ActiveProfile = name - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Switched to profile: %s", name)) - return nil -} - -func runRemove(cmd *cobra.Command, args []string) error { - name := args[0] - - cfg, err := config.Load() - if err != nil { - return err - } - - if _, ok := cfg.Profiles[name]; !ok { - return fmt.Errorf("profile %q not found", name) - } - - if !cmdutil.YesFlag(cmd) && prompt.IsInteractive() { - ok, err := prompt.Confirm(fmt.Sprintf("Remove profile %q?", name)) - if err != nil { - return err - } - if !ok { - return nil - } - } - - delete(cfg.Profiles, name) - if cfg.ActiveProfile == name { - cfg.ActiveProfile = "" - for n := range cfg.Profiles { - cfg.ActiveProfile = n - break - } - } - - if err := config.Save(cfg); err != nil { - return err - } - - output.Success(fmt.Sprintf("Profile %q removed.", name)) - return nil -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index bec1147..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,77 +0,0 @@ -package cmd - -import ( - "github.com/mailerlite/mailerlite-cli/cmd/account" - "github.com/mailerlite/mailerlite-cli/cmd/auth" - "github.com/mailerlite/mailerlite-cli/cmd/automation" - "github.com/mailerlite/mailerlite-cli/cmd/campaign" - "github.com/mailerlite/mailerlite-cli/cmd/cart" - "github.com/mailerlite/mailerlite-cli/cmd/cartitem" - "github.com/mailerlite/mailerlite-cli/cmd/category" - "github.com/mailerlite/mailerlite-cli/cmd/completion" - "github.com/mailerlite/mailerlite-cli/cmd/customer" - "github.com/mailerlite/mailerlite-cli/cmd/dashboard" - "github.com/mailerlite/mailerlite-cli/cmd/field" - "github.com/mailerlite/mailerlite-cli/cmd/form" - "github.com/mailerlite/mailerlite-cli/cmd/group" - importcmd "github.com/mailerlite/mailerlite-cli/cmd/import" - "github.com/mailerlite/mailerlite-cli/cmd/order" - "github.com/mailerlite/mailerlite-cli/cmd/product" - "github.com/mailerlite/mailerlite-cli/cmd/profile" - "github.com/mailerlite/mailerlite-cli/cmd/segment" - "github.com/mailerlite/mailerlite-cli/cmd/shop" - "github.com/mailerlite/mailerlite-cli/cmd/subscriber" - "github.com/mailerlite/mailerlite-cli/cmd/timezone" - "github.com/mailerlite/mailerlite-cli/cmd/webhook" - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/spf13/cobra" -) - -var rootCmd = &cobra.Command{ - Use: "mailerlite", - Short: "MailerLite CLI — manage your email marketing from the terminal", - Long: "A command-line interface for the MailerLite API. Manage subscribers, campaigns, automations, groups, forms, and more.", - SilenceUsage: true, - SilenceErrors: true, -} - -func init() { - rootCmd.Version = version - cmdutil.SetVersion(version) - rootCmd.PersistentFlags().String("profile", "", "config profile to use") - rootCmd.PersistentFlags().BoolP("verbose", "v", false, "show HTTP request/response details") - rootCmd.PersistentFlags().Bool("json", false, "output as JSON") - rootCmd.PersistentFlags().BoolP("yes", "y", false, "skip confirmation prompts") - - rootCmd.AddCommand(dashboard.Cmd) - rootCmd.AddCommand(subscriber.Cmd) - rootCmd.AddCommand(group.Cmd) - rootCmd.AddCommand(campaign.Cmd) - rootCmd.AddCommand(automation.Cmd) - rootCmd.AddCommand(form.Cmd) - rootCmd.AddCommand(field.Cmd) - rootCmd.AddCommand(segment.Cmd) - rootCmd.AddCommand(webhook.Cmd) - rootCmd.AddCommand(timezone.Cmd) - rootCmd.AddCommand(shop.Cmd) - rootCmd.AddCommand(product.Cmd) - rootCmd.AddCommand(category.Cmd) - rootCmd.AddCommand(customer.Cmd) - rootCmd.AddCommand(order.Cmd) - rootCmd.AddCommand(cart.Cmd) - rootCmd.AddCommand(cartitem.Cmd) - rootCmd.AddCommand(importcmd.Cmd) - rootCmd.AddCommand(account.Cmd) - rootCmd.AddCommand(auth.Cmd) - rootCmd.AddCommand(profile.Cmd) - rootCmd.AddCommand(completion.Cmd) - rootCmd.AddCommand(versionCmd) -} - -func Execute() error { - return rootCmd.Execute() -} - -func IsJSON() bool { - return cmdutil.JSONFlag(rootCmd) -} diff --git a/cmd/segment/segment.go b/cmd/segment/segment.go deleted file mode 100644 index e012211..0000000 --- a/cmd/segment/segment.go +++ /dev/null @@ -1,222 +0,0 @@ -package segment - -import ( - "context" - "fmt" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "segment", - Short: "Manage segments", - Long: "List, update, delete segments and view segment subscribers.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(subscribersCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of segments to return (0 = all)") - - // update flags - updateCmd.Flags().String("name", "", "new segment name (required)") - - // subscribers flags - subscribersCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List segments", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - - ctx := context.Background() - - allSegments, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Segment, bool, error) { - opts := &mailerlite.ListSegmentOptions{ - Page: page, - Limit: perPage, - } - root, _, err := ml.Segment.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(allSegments) - } - - headers := []string{"ID", "NAME", "TOTAL", "OPEN RATE", "CLICK RATE", "CREATED AT"} - var rows [][]string - - for _, s := range allSegments { - rows = append(rows, []string{ - s.ID, - s.Name, - strconv.Itoa(s.Total), - s.OpenRate.String, - s.ClickRate.String, - s.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a segment", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "New segment name") - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Segment.Update(ctx, args[0], name) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Segment " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a segment", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm(fmt.Sprintf("Delete segment %s?", args[0])) - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Segment.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Segment " + args[0] + " deleted successfully.") - return nil -} - -// --- subscribers --- - -var subscribersCmd = &cobra.Command{ - Use: "subscribers ", - Short: "List subscribers in a segment", - Args: cobra.ExactArgs(1), - RunE: runSubscribers, -} - -func runSubscribers(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - segmentID := args[0] - - ctx := context.Background() - - allSubscribers, err := sdkclient.FetchAllCursor(ctx, func(ctx context.Context, after, perPage int) ([]mailerlite.Subscriber, int, error) { - opts := &mailerlite.ListSegmentSubscriberOptions{ - SegmentID: segmentID, - Limit: perPage, - After: after, - } - root, _, err := ml.Segment.Subscribers(ctx, opts) - if err != nil { - return nil, 0, sdkclient.WrapError(err) - } - nextAfter := 0 - if root.Meta.Last > 0 { - nextAfter = root.Meta.Last - } - return root.Data, nextAfter, nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(allSubscribers) - } - - headers := []string{"ID", "EMAIL", "STATUS", "SUBSCRIBED AT", "CREATED AT"} - var rows [][]string - - for _, s := range allSubscribers { - rows = append(rows, []string{ - s.ID, - s.Email, - s.Status, - s.SubscribedAt, - s.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil -} diff --git a/cmd/shop/shop.go b/cmd/shop/shop.go deleted file mode 100644 index 0d75f43..0000000 --- a/cmd/shop/shop.go +++ /dev/null @@ -1,250 +0,0 @@ -package shop - -import ( - "context" - "fmt" - "net/http" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/ecommerce" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "shop", - Short: "Manage e-commerce shops", - Long: "List, create, update, and delete e-commerce shops.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(countCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of shops to return (0 = all)") - - // create flags - createCmd.Flags().String("name", "", "shop name (required)") - createCmd.Flags().String("url", "", "shop URL (required)") - - // update flags - updateCmd.Flags().String("name", "", "shop name") - updateCmd.Flags().String("url", "", "shop URL") -} - -// list -var listCmd = &cobra.Command{ - Use: "list", - Short: "List shops", - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - limit, _ := cmd.Flags().GetInt("limit") - ctx := context.Background() - - shops, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]ecommerce.Shop, bool, error) { - path := "/ecommerce/shops?page=" + strconv.Itoa(page) + "&limit=" + strconv.Itoa(perPage) - var result ecommerce.RootShops - _, err := sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return nil, false, err - } - return result.Data, !result.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(shops) - } - - headers := []string{"ID", "NAME", "URL", "CREATED"} - var rows [][]string - for _, s := range shops { - rows = append(rows, []string{s.ID, s.Name, s.URL, s.CreatedAt}) - } - - output.Table(headers, rows) - return nil - }, -} - -// get -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get shop details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s", args[0]) - var result ecommerce.RootShop - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, path, nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - s := result.Data - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"ID", s.ID}, - {"Name", s.Name}, - {"URL", s.URL}, - {"Created", s.CreatedAt}, - {"Updated", s.UpdatedAt}, - } - - output.Table(headers, rows) - return nil - }, -} - -// create -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new shop", - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - name, _ := cmd.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Shop name") - if err != nil { - return err - } - - shopURL, _ := cmd.Flags().GetString("url") - shopURL, err = prompt.RequireArg(shopURL, "url", "Shop URL") - if err != nil { - return err - } - - body := map[string]string{"name": name, "url": shopURL} - - ctx := context.Background() - var result ecommerce.RootShop - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPost, "/ecommerce/shops", body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Shop created: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// update -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a shop", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - body := make(map[string]string) - if cmd.Flags().Changed("name") { - v, _ := cmd.Flags().GetString("name") - body["name"] = v - } - if cmd.Flags().Changed("url") { - v, _ := cmd.Flags().GetString("url") - body["url"] = v - } - if len(body) == 0 { - return fmt.Errorf("no flags provided; use --help to see available options") - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s", args[0]) - var result ecommerce.RootShop - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodPut, path, body, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result.Data) - } - - output.Success(fmt.Sprintf("Shop updated: %s (ID: %s)", result.Data.Name, result.Data.ID)) - return nil - }, -} - -// delete -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a shop", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - path := fmt.Sprintf("/ecommerce/shops/%s", args[0]) - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodDelete, path, nil, nil) - if err != nil { - return err - } - - output.Success(fmt.Sprintf("Shop %s deleted.", args[0])) - return nil - }, -} - -// count -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total shop count", - RunE: func(cmd *cobra.Command, args []string) error { - httpClient, apiKey, err := cmdutil.RawHTTPClient(cmd) - if err != nil { - return err - } - - ctx := context.Background() - var result ecommerce.RootCount - _, err = sdkclient.DoRaw(ctx, httpClient, apiKey, http.MethodGet, "/ecommerce/shops?limit=0", nil, &result) - if err != nil { - return err - } - - if cmdutil.JSONFlag(cmd) { - return output.JSON(result) - } - - fmt.Printf("Total shops: %d\n", result.Total) - return nil - }, -} diff --git a/cmd/subscriber/subscriber.go b/cmd/subscriber/subscriber.go deleted file mode 100644 index 74a4c34..0000000 --- a/cmd/subscriber/subscriber.go +++ /dev/null @@ -1,398 +0,0 @@ -package subscriber - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "subscriber", - Short: "Manage subscribers", - Long: "List, view, create, update, and delete subscribers.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(countCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(upsertCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - Cmd.AddCommand(forgetCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of subscribers to return (0 = all)") - listCmd.Flags().String("status", "", "filter by status (active, unsubscribed, unconfirmed, bounced, junk)") - listCmd.Flags().String("email", "", "filter by email address") - - // upsert flags - upsertCmd.Flags().String("email", "", "subscriber email (required)") - upsertCmd.Flags().String("status", "", "subscriber status") - upsertCmd.Flags().StringSlice("groups", nil, "group IDs to assign") - upsertCmd.Flags().StringSlice("fields", nil, "custom fields as key=value pairs") - - // update flags - updateCmd.Flags().String("email", "", "subscriber email") - updateCmd.Flags().String("status", "", "subscriber status") - updateCmd.Flags().StringSlice("fields", nil, "custom fields as key=value pairs") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List subscribers", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - status, _ := c.Flags().GetString("status") - email, _ := c.Flags().GetString("email") - - ctx := context.Background() - - var filters []mailerlite.Filter - if status != "" { - filters = append(filters, *mailerlite.NewFilter("status", status)) - } - if email != "" { - filters = append(filters, *mailerlite.NewFilter("email", email)) - } - - subscribers, err := sdkclient.FetchAllStringCursor(ctx, func(ctx context.Context, cursor string, perPage int) ([]mailerlite.Subscriber, string, error) { - opts := &mailerlite.ListSubscriberOptions{ - Cursor: cursor, - Limit: perPage, - } - if len(filters) > 0 { - opts.Filters = &filters - } - - root, _, err := ml.Subscriber.List(ctx, opts) - if err != nil { - return nil, "", sdkclient.WrapError(err) - } - - return root.Data, root.Meta.NextCursor, nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(subscribers) - } - - headers := []string{"EMAIL", "STATUS", "SOURCE", "OPENS", "CLICKS", "SUBSCRIBED AT"} - var rows [][]string - - for _, s := range subscribers { - rows = append(rows, []string{ - output.Truncate(s.Email, 40), - s.Status, - s.Source, - strconv.Itoa(s.OpensCount), - strconv.Itoa(s.ClicksCount), - s.SubscribedAt, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- count --- - -var countCmd = &cobra.Command{ - Use: "count", - Short: "Get total subscriber count", - RunE: runCount, -} - -func runCount(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Subscriber.Count(ctx) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - fmt.Printf("Total subscribers: %d\n", result.Total) - return nil -} - -// --- get --- - -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get subscriber details", - Args: cobra.ExactArgs(1), - RunE: runGet, -} - -func runGet(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - opts := &mailerlite.GetSubscriberOptions{} - if strings.Contains(args[0], "@") { - opts.Email = args[0] - } else { - opts.SubscriberID = args[0] - } - - ctx := context.Background() - result, _, err := ml.Subscriber.Get(ctx, opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - s := result.Data - fmt.Printf("ID: %s\n", s.ID) - fmt.Printf("Email: %s\n", s.Email) - fmt.Printf("Status: %s\n", s.Status) - fmt.Printf("Source: %s\n", s.Source) - fmt.Printf("Opens: %d\n", s.OpensCount) - fmt.Printf("Clicks: %d\n", s.ClicksCount) - fmt.Printf("Open Rate: %.2f%%\n", s.OpenRate) - fmt.Printf("Click Rate: %.2f%%\n", s.ClickRate) - fmt.Printf("Subscribed At: %s\n", s.SubscribedAt) - fmt.Printf("Created At: %s\n", s.CreatedAt) - fmt.Printf("Updated At: %s\n", s.UpdatedAt) - - if len(s.Fields) > 0 { - fmt.Println() - fmt.Println("Fields:") - for k, v := range s.Fields { - fmt.Printf(" %s: %v\n", k, v) - } - } - - if len(s.Groups) > 0 { - fmt.Println() - fmt.Println("Groups:") - for _, g := range s.Groups { - fmt.Printf(" - %s (%s)\n", g.Name, g.ID) - } - } - - return nil -} - -// --- upsert --- - -var upsertCmd = &cobra.Command{ - Use: "upsert", - Short: "Create or update a subscriber", - RunE: runUpsert, -} - -func runUpsert(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - email, _ := c.Flags().GetString("email") - email, err = prompt.RequireArg(email, "email", "Subscriber email") - if err != nil { - return err - } - - subscriber := &mailerlite.UpsertSubscriber{ - Email: email, - } - - if c.Flags().Changed("status") { - status, _ := c.Flags().GetString("status") - subscriber.Status = status - } - if c.Flags().Changed("groups") { - groups, _ := c.Flags().GetStringSlice("groups") - subscriber.Groups = groups - } - if c.Flags().Changed("fields") { - fieldPairs, _ := c.Flags().GetStringSlice("fields") - fields, err := parseFields(fieldPairs) - if err != nil { - return err - } - subscriber.Fields = fields - } - - ctx := context.Background() - result, _, err := ml.Subscriber.Upsert(ctx, subscriber) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Subscriber upserted successfully. ID: " + result.Data.ID) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a subscriber", - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - subscriber := &mailerlite.UpdateSubscriber{ - ID: args[0], - } - - if c.Flags().Changed("email") { - email, _ := c.Flags().GetString("email") - subscriber.Email = email - } - if c.Flags().Changed("status") { - status, _ := c.Flags().GetString("status") - subscriber.Status = status - } - if c.Flags().Changed("fields") { - fieldPairs, _ := c.Flags().GetStringSlice("fields") - fields, err := parseFields(fieldPairs) - if err != nil { - return err - } - subscriber.Fields = fields - } - - ctx := context.Background() - result, _, err := ml.Subscriber.Update(ctx, subscriber) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Subscriber " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a subscriber", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm("Delete subscriber " + args[0] + "?") - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Subscriber.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Subscriber " + args[0] + " deleted successfully.") - return nil -} - -// --- forget --- - -var forgetCmd = &cobra.Command{ - Use: "forget ", - Short: "Forget a subscriber (GDPR)", - Long: "Permanently forget a subscriber and all their data. This action cannot be undone.", - Args: cobra.ExactArgs(1), - RunE: runForget, -} - -func runForget(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm("Permanently forget subscriber " + args[0] + "? This cannot be undone.") - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, _, err = ml.Subscriber.Forget(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Subscriber " + args[0] + " forgotten successfully.") - return nil -} - -// parseFields converts "key=value" pairs into a map for subscriber fields. -func parseFields(pairs []string) (map[string]interface{}, error) { - fields := make(map[string]interface{}, len(pairs)) - for _, pair := range pairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid field format %q: expected key=value", pair) - } - fields[parts[0]] = parts[1] - } - return fields, nil -} diff --git a/cmd/timezone/timezone.go b/cmd/timezone/timezone.go deleted file mode 100644 index be9cc93..0000000 --- a/cmd/timezone/timezone.go +++ /dev/null @@ -1,60 +0,0 @@ -package timezone - -import ( - "context" - "strconv" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "timezone", - Short: "Manage timezones", - Long: "List available timezones.", -} - -func init() { - Cmd.AddCommand(listCmd) -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List timezones", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Timezone.List(ctx) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result.Data) - } - - headers := []string{"ID", "NAME", "OFFSET"} - var rows [][]string - - for _, tz := range result.Data { - rows = append(rows, []string{ - tz.Id, - tz.Name, - strconv.Itoa(tz.Offset), - }) - } - - output.Table(headers, rows) - return nil -} diff --git a/cmd/version.go b/cmd/version.go deleted file mode 100644 index 6c0f744..0000000 --- a/cmd/version.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var ( - version = "dev" - commit = "none" - date = "unknown" -) - -// Version returns the version string for use by other packages (e.g. user-agent). -func Version() string { - return version -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version of mailerlite", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("mailerlite v%s (%s) built %s\n", version, commit, date) - }, -} diff --git a/cmd/webhook/webhook.go b/cmd/webhook/webhook.go deleted file mode 100644 index 3580477..0000000 --- a/cmd/webhook/webhook.go +++ /dev/null @@ -1,308 +0,0 @@ -package webhook - -import ( - "context" - "fmt" - "strings" - - "github.com/mailerlite/mailerlite-cli/internal/cmdutil" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/prompt" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -var webhookEvents = []string{ - "subscriber.created", - "subscriber.updated", - "subscriber.unsubscribed", - "subscriber.added_to_group", - "subscriber.removed_from_group", - "subscriber.bounced", - "subscriber.automation_triggered", - "subscriber.automation_completed", - "campaign.sent", - "campaign.draft_created", -} - -var Cmd = &cobra.Command{ - Use: "webhook", - Short: "Manage webhooks", - Long: "List, view, create, update, and delete webhooks.", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(getCmd) - Cmd.AddCommand(createCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(deleteCmd) - - // list flags - listCmd.Flags().Int("limit", 25, "maximum number of webhooks to return (0 = all)") - listCmd.Flags().String("sort", "", "sort order") - - // create flags - createCmd.Flags().String("name", "", "webhook name (required)") - createCmd.Flags().String("url", "", "webhook URL (required)") - createCmd.Flags().StringSlice("events", nil, "webhook events (required)") - createCmd.Flags().Bool("enabled", true, "whether the webhook is enabled") - - // update flags - updateCmd.Flags().String("name", "", "webhook name") - updateCmd.Flags().String("url", "", "webhook URL") - updateCmd.Flags().StringSlice("events", nil, "webhook events") - updateCmd.Flags().Bool("enabled", true, "whether the webhook is enabled") -} - -// --- list --- - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List webhooks", - RunE: runList, -} - -func runList(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - limit, _ := c.Flags().GetInt("limit") - sort, _ := c.Flags().GetString("sort") - - ctx := context.Background() - - allWebhooks, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Webhook, bool, error) { - opts := &mailerlite.ListWebhookOptions{ - Page: page, - Limit: perPage, - Sort: sort, - } - root, _, err := ml.Webhook.List(ctx, opts) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, !root.Links.IsLastPage(), nil - }, limit) - if err != nil { - return err - } - - if cmdutil.JSONFlag(c) { - return output.JSON(allWebhooks) - } - - headers := []string{"ID", "NAME", "URL", "ENABLED", "CREATED AT"} - var rows [][]string - - for _, w := range allWebhooks { - enabled := "No" - if w.Enabled { - enabled = "Yes" - } - rows = append(rows, []string{ - w.Id, - output.Truncate(w.Name, 40), - output.Truncate(w.Url, 50), - enabled, - w.CreatedAt, - }) - } - - output.Table(headers, rows) - return nil -} - -// --- get --- - -var getCmd = &cobra.Command{ - Use: "get ", - Short: "Get webhook details", - Args: cobra.ExactArgs(1), - RunE: runGet, -} - -func runGet(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - ctx := context.Background() - result, _, err := ml.Webhook.Get(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - d := result.Data - - enabled := "No" - if d.Enabled { - enabled = "Yes" - } - - fmt.Printf("ID: %s\n", d.Id) - fmt.Printf("Name: %s\n", d.Name) - fmt.Printf("URL: %s\n", d.Url) - fmt.Printf("Enabled: %s\n", enabled) - fmt.Printf("Created At: %s\n", d.CreatedAt) - fmt.Printf("Updated At: %s\n", d.UpdatedAt) - - fmt.Println() - fmt.Println("Events:") - for _, e := range d.Events { - fmt.Printf(" - %s\n", e) - } - - return nil -} - -// --- create --- - -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a webhook", - Long: "Create a new webhook.\n\nValid events: " + strings.Join(webhookEvents, ", "), - RunE: runCreate, -} - -func runCreate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - name, _ := c.Flags().GetString("name") - name, err = prompt.RequireArg(name, "name", "Webhook name") - if err != nil { - return err - } - - url, _ := c.Flags().GetString("url") - url, err = prompt.RequireArg(url, "url", "Webhook URL") - if err != nil { - return err - } - - events, _ := c.Flags().GetStringSlice("events") - events, err = prompt.RequireSliceArg(events, "events", "Webhook events") - if err != nil { - return err - } - - ctx := context.Background() - opts := &mailerlite.CreateWebhookOptions{ - Name: name, - Url: url, - Events: events, - } - - result, _, err := ml.Webhook.Create(ctx, opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Webhook created successfully. ID: " + result.Data.Id) - return nil -} - -// --- update --- - -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a webhook", - Long: "Update an existing webhook.\n\nValid events: " + strings.Join(webhookEvents, ", "), - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - -func runUpdate(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - opts := &mailerlite.UpdateWebhookOptions{ - WebhookID: args[0], - } - - if c.Flags().Changed("name") { - name, _ := c.Flags().GetString("name") - opts.Name = name - } - if c.Flags().Changed("url") { - url, _ := c.Flags().GetString("url") - opts.Url = url - } - if c.Flags().Changed("events") { - events, _ := c.Flags().GetStringSlice("events") - opts.Events = events - } - if c.Flags().Changed("enabled") { - enabled, _ := c.Flags().GetBool("enabled") - if enabled { - opts.Enabled = "true" - } else { - opts.Enabled = "false" - } - } - - ctx := context.Background() - result, _, err := ml.Webhook.Update(ctx, opts) - if err != nil { - return sdkclient.WrapError(err) - } - - if cmdutil.JSONFlag(c) { - return output.JSON(result) - } - - output.Success("Webhook " + args[0] + " updated successfully.") - return nil -} - -// --- delete --- - -var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a webhook", - Args: cobra.ExactArgs(1), - RunE: runDelete, -} - -func runDelete(c *cobra.Command, args []string) error { - ml, err := cmdutil.NewSDKClient(c) - if err != nil { - return err - } - - if !cmdutil.YesFlag(c) && prompt.IsInteractive() { - ok, err := prompt.Confirm(fmt.Sprintf("Delete webhook %s?", args[0])) - if err != nil { - return err - } - if !ok { - return nil - } - } - - ctx := context.Background() - _, err = ml.Webhook.Delete(ctx, args[0]) - if err != nil { - return sdkclient.WrapError(err) - } - - output.Success("Webhook " + args[0] + " deleted successfully.") - return nil -} diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..862e7dd --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.32.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "homebrew"] +# A GitHub repo to push Homebrew formulas to +tap = "mailerlite/homebrew-tap" +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".zip" +# Publish jobs to run in CI +publish-jobs = ["homebrew"] +# Post-announce jobs to run in CI +post-announce-jobs = ["./update-flake"] +# Whether to install an updater program +install-updater = false +# Path that installers should place binaries in +install-path = "CARGO_HOME" diff --git a/flake.nix b/flake.nix index 1c456ab..441af20 100644 --- a/flake.nix +++ b/flake.nix @@ -8,39 +8,40 @@ outputs = { self, nixpkgs, flake-utils }: let - version = "1.0.2"; + version = "2.0.0"; - # Map nix system to goreleaser naming + # Map nix system to Rust target triples (cargo-dist artifact naming) systemMap = { - "x86_64-linux" = { os = "linux"; arch = "amd64"; }; - "aarch64-linux" = { os = "linux"; arch = "arm64"; }; - "x86_64-darwin" = { os = "darwin"; arch = "amd64"; }; - "aarch64-darwin" = { os = "darwin"; arch = "arm64"; }; + "x86_64-linux" = "x86_64-unknown-linux-gnu"; + "aarch64-linux" = "aarch64-unknown-linux-gnu"; + "x86_64-darwin" = "x86_64-apple-darwin"; + "aarch64-darwin" = "aarch64-apple-darwin"; }; # SHA256 hashes for each platform (updated by CI on release) hashes = { - "x86_64-linux" = "sha256-ZIMckB4Yu0A0Xrfe7MDgSToztoXN/ePP4MeSBzJWud8="; - "aarch64-linux" = "sha256-Wm5XFVh2l9voM8d8mSfYxTq4yBm00ihorsVkbio3njA="; - "x86_64-darwin" = "sha256-oMrmRzlJwUOMH0Dx3l44Vudr8gLr4EaMa5dpRlM82ao="; - "aarch64-darwin" = "sha256-elIKPI9os7J+33dXqBjhyJ3tRcKTXxCedZucz7wbuGc="; + "x86_64-linux" = "sha256-0000000000000000000000000000000000000000000="; + "aarch64-linux" = "sha256-0000000000000000000000000000000000000000000="; + "x86_64-darwin" = "sha256-0000000000000000000000000000000000000000000="; + "aarch64-darwin" = "sha256-0000000000000000000000000000000000000000000="; }; in flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; - platformInfo = systemMap.${system} or (throw "Unsupported system: ${system}"); + target = systemMap.${system} or (throw "Unsupported system: ${system}"); mailerlite = pkgs.stdenv.mkDerivation { pname = "mailerlite"; inherit version; src = pkgs.fetchurl { - url = "https://github.com/mailerlite/mailerlite-cli/releases/download/v${version}/mailerlite-cli_${version}_${platformInfo.os}_${platformInfo.arch}.tar.gz"; + url = "https://github.com/mailerlite/mailerlite-cli/releases/download/v${version}/mailerlite-${target}.tar.gz"; sha256 = hashes.${system}; }; - sourceRoot = "."; + # cargo-dist tarballs unpack to a directory named after the archive + sourceRoot = "mailerlite-${target}"; installPhase = '' install -Dm755 mailerlite $out/bin/mailerlite @@ -63,8 +64,10 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ - go - golangci-lint + cargo + rustc + rustfmt + clippy lefthook ]; }; diff --git a/go.mod b/go.mod deleted file mode 100644 index 695df8f..0000000 --- a/go.mod +++ /dev/null @@ -1,41 +0,0 @@ -module github.com/mailerlite/mailerlite-cli - -go 1.25.5 - -require ( - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/mailerlite/mailerlite-go v1.1.2 - github.com/spf13/cobra v1.10.2 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/google/go-querystring v1.2.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // 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.16 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.23.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 9d6deb9..0000000 --- a/go.sum +++ /dev/null @@ -1,99 +0,0 @@ -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailerlite/mailerlite-go v1.1.2 h1:GijU8cMYkkdpTBxMjs/FYfisS0f0uoMpKXTCF/a1gLQ= -github.com/mailerlite/mailerlite-go v1.1.2/go.mod h1:nsL6Rr1J0Vw43Jzp08s2aF617ZVOtJbPw2YNyUjUPNo= -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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -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.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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -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= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go deleted file mode 100644 index 927e63b..0000000 --- a/internal/cmdutil/cmdutil.go +++ /dev/null @@ -1,139 +0,0 @@ -package cmdutil - -import ( - "fmt" - "net/http" - "os" - "strconv" - "time" - - "github.com/mailerlite/mailerlite-cli/internal/config" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-go" - "github.com/spf13/cobra" -) - -// ProfileFlag returns the --profile persistent flag value. -func ProfileFlag(cmd *cobra.Command) string { - v, _ := cmd.Root().PersistentFlags().GetString("profile") - return v -} - -// VerboseFlag returns the --verbose persistent flag value. -func VerboseFlag(cmd *cobra.Command) bool { - v, _ := cmd.Root().PersistentFlags().GetBool("verbose") - return v -} - -// JSONFlag returns the --json persistent flag value. -func JSONFlag(cmd *cobra.Command) bool { - v, _ := cmd.Root().PersistentFlags().GetBool("json") - return v -} - -// YesFlag returns the --yes persistent flag value. -func YesFlag(cmd *cobra.Command) bool { - v, _ := cmd.Root().PersistentFlags().GetBool("yes") - return v -} - -// SetVersion configures the SDK client user-agent with the CLI version. -func SetVersion(v string) { - sdkclient.SetUserAgent("mailerlite-cli/" + v) -} - -// NewSDKClient creates a mailerlite-go SDK client with CLI-specific behavior -// injected via a custom HTTP transport (retry, verbose, user-agent, base URL). -// Returns both the SDK client and the transport (needed for error body access). -func NewSDKClient(cmd *cobra.Command) (*mailerlite.Client, error) { - token, err := config.GetToken(ProfileFlag(cmd)) - if err != nil { - return nil, err - } - - transport := &sdkclient.CLITransport{ - Base: http.DefaultTransport, - Verbose: VerboseFlag(cmd), - AccountID: config.GetAccountID(ProfileFlag(cmd)), - } - - if base := os.Getenv("MAILERLITE_API_BASE_URL"); base != "" { - transport.BaseURL = base - } - - ml := mailerlite.NewClient(token) - ml.SetHttpClient(&http.Client{ - Timeout: 30 * time.Second, - Transport: transport, - }) - - return ml, nil -} - -// RawHTTPClient creates an *http.Client and API key for raw HTTP e-commerce calls. -func RawHTTPClient(cmd *cobra.Command) (*http.Client, string, error) { - token, err := config.GetToken(ProfileFlag(cmd)) - if err != nil { - return nil, "", err - } - - transport := &sdkclient.CLITransport{ - Base: http.DefaultTransport, - Verbose: VerboseFlag(cmd), - AccountID: config.GetAccountID(ProfileFlag(cmd)), - } - - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: transport, - } - - return httpClient, token, nil -} - -// ParseDate accepts a date string in YYYY-MM-DD format or a raw unix -// timestamp and returns the corresponding unix timestamp as int64. -func ParseDate(value string) (int64, error) { - t, err := time.Parse("2006-01-02", value) - if err == nil { - return t.Unix(), nil - } - ts, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid date %q: use YYYY-MM-DD or a unix timestamp", value) - } - return ts, nil -} - -// DefaultDateRange returns parsed dateFrom/dateTo timestamps. If either value -// is empty, it defaults to the last 7 days (dateTo = now, dateFrom = now - 7d). -func DefaultDateRange(dateFromStr, dateToStr string, now time.Time) (int64, int64, error) { - var dateFrom, dateTo int64 - var err error - - if dateFromStr == "" && dateToStr == "" { - dateTo = now.Unix() - dateFrom = now.AddDate(0, 0, -7).Unix() - return dateFrom, dateTo, nil - } - - if dateFromStr != "" { - dateFrom, err = ParseDate(dateFromStr) - if err != nil { - return 0, 0, err - } - } else { - dateFrom = now.AddDate(0, 0, -7).Unix() - } - - if dateToStr != "" { - dateTo, err = ParseDate(dateToStr) - if err != nil { - return 0, 0, err - } - } else { - dateTo = now.Unix() - } - - return dateFrom, dateTo, nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index f5f4303..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,233 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -const ( - oauthClientID = "1118" - oauthTokenURL = "https://dashboard.mailerlite.com/oauth/token" -) - -type Profile struct { - APIToken string `yaml:"api_token,omitempty"` - OAuthToken string `yaml:"oauth_token,omitempty"` - OAuthRefreshToken string `yaml:"oauth_refresh_token,omitempty"` - OAuthExpiresAt string `yaml:"oauth_expires_at,omitempty"` - AccountID string `yaml:"account_id,omitempty"` -} - -type Config struct { - ActiveProfile string `yaml:"active_profile"` - Profiles map[string]Profile `yaml:"profiles"` -} - -func Dir() (string, error) { - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "mailerlite"), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("could not determine home directory: %w", err) - } - return filepath.Join(home, ".config", "mailerlite"), nil -} - -func Path() (string, error) { - dir, err := Dir() - if err != nil { - return "", err - } - return filepath.Join(dir, "config.yaml"), nil -} - -func Load() (*Config, error) { - p, err := Path() - if err != nil { - return nil, err - } - - data, err := os.ReadFile(p) - if err != nil { - if os.IsNotExist(err) { - return &Config{Profiles: make(map[string]Profile)}, nil - } - return nil, fmt.Errorf("failed to read config: %w", err) - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) - } - if cfg.Profiles == nil { - cfg.Profiles = make(map[string]Profile) - } - return &cfg, nil -} - -func Save(cfg *Config) error { - p, err := Path() - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - return os.WriteFile(p, data, 0600) -} - -func ActiveProfile(cfg *Config) (string, Profile, error) { - name := cfg.ActiveProfile - if name == "" { - if len(cfg.Profiles) == 0 { - return "", Profile{}, fmt.Errorf("no profiles configured — run 'mailerlite auth login' or 'mailerlite profile add '") - } - for n := range cfg.Profiles { - name = n - break - } - } - - p, ok := cfg.Profiles[name] - if !ok { - return "", Profile{}, fmt.Errorf("profile %q not found", name) - } - return name, p, nil -} - -func GetToken(profileOverride string) (string, error) { - // Environment variable takes highest precedence. - if token := os.Getenv("MAILERLITE_API_TOKEN"); token != "" { - return token, nil - } - - cfg, err := Load() - if err != nil { - return "", err - } - - var profName string - var prof Profile - if profileOverride != "" { - p, ok := cfg.Profiles[profileOverride] - if !ok { - return "", fmt.Errorf("profile %q not found", profileOverride) - } - profName = profileOverride - prof = p - } else { - n, p, err := ActiveProfile(cfg) - if err != nil { - return "", err - } - profName = n - prof = p - } - - if prof.APIToken != "" { - return prof.APIToken, nil - } - if prof.OAuthToken != "" { - // Check if token is expired and refresh if needed. - if prof.OAuthExpiresAt != "" && prof.OAuthRefreshToken != "" { - expiresAt, err := time.Parse(time.RFC3339, prof.OAuthExpiresAt) - if err == nil && time.Now().After(expiresAt.Add(-5*time.Minute)) { - refreshed, refreshErr := refreshOAuthToken(prof.OAuthRefreshToken) - if refreshErr == nil { - refreshed.AccountID = prof.AccountID - cfg.Profiles[profName] = refreshed - _ = Save(cfg) - return refreshed.OAuthToken, nil - } - // If refresh fails but token isn't actually expired yet, use it anyway. - if time.Now().Before(expiresAt) { - return prof.OAuthToken, nil - } - return "", fmt.Errorf("OAuth token expired and refresh failed: %w", refreshErr) - } - } - return prof.OAuthToken, nil - } - return "", fmt.Errorf("no token found — run 'mailerlite auth login' or set MAILERLITE_API_TOKEN") -} - -// GetAccountID returns the stored account ID for the active (or overridden) profile. -func GetAccountID(profileOverride string) string { - cfg, err := Load() - if err != nil { - return "" - } - - var prof Profile - if profileOverride != "" { - p, ok := cfg.Profiles[profileOverride] - if !ok { - return "" - } - prof = p - } else { - _, p, err := ActiveProfile(cfg) - if err != nil { - return "" - } - prof = p - } - - return prof.AccountID -} - -// refreshOAuthToken exchanges a refresh token for a new access token. -func refreshOAuthToken(refreshToken string) (Profile, error) { - data := url.Values{ - "grant_type": {"refresh_token"}, - "client_id": {oauthClientID}, - "refresh_token": {refreshToken}, - } - - resp, err := http.Post(oauthTokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) //nolint:gosec,noctx - if err != nil { - return Profile{}, fmt.Errorf("refresh request failed: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - return Profile{}, fmt.Errorf("refresh failed (HTTP %d)", resp.StatusCode) - } - - var tok struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - } - if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { - return Profile{}, fmt.Errorf("failed to parse refresh response: %w", err) - } - - if tok.AccessToken == "" { - return Profile{}, fmt.Errorf("server returned empty access token on refresh") - } - - expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Format(time.RFC3339) - - return Profile{ - OAuthToken: tok.AccessToken, - OAuthRefreshToken: tok.RefreshToken, - OAuthExpiresAt: expiresAt, - }, nil -} diff --git a/internal/ecommerce/types.go b/internal/ecommerce/types.go deleted file mode 100644 index f4076f4..0000000 --- a/internal/ecommerce/types.go +++ /dev/null @@ -1,183 +0,0 @@ -package ecommerce - -// Links represents pagination links in API responses. -type Links struct { - First string `json:"first"` - Last string `json:"last"` - Prev string `json:"prev"` - Next string `json:"next"` -} - -// IsLastPage returns true when there is no next page. -func (l *Links) IsLastPage() bool { return l.Next == "" } - -// Meta represents pagination metadata in API responses. -type Meta struct { - CurrentPage int `json:"current_page"` - From int `json:"from"` - LastPage int `json:"last_page"` - PerPage int `json:"per_page"` - To int `json:"to"` - Total int `json:"total"` -} - -// --- Shop --- - -type Shop struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootShops struct { - Data []Shop `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootShop struct { - Data Shop `json:"data"` -} - -// --- Product --- - -type Product struct { - ID string `json:"id"` - Name string `json:"name"` - Price float64 `json:"price"` - URL string `json:"url"` - ImageURL string `json:"image_url"` - Description string `json:"description"` - Quantity int `json:"quantity"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootProducts struct { - Data []Product `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootProduct struct { - Data Product `json:"data"` -} - -// --- Category --- - -type Category struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootCategories struct { - Data []Category `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootCategory struct { - Data Category `json:"data"` -} - -// --- Customer --- - -type Customer struct { - ID string `json:"id"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootCustomers struct { - Data []Customer `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootCustomer struct { - Data Customer `json:"data"` -} - -// --- Order --- - -type OrderItem struct { - ProductID string `json:"product_id"` - Quantity int `json:"quantity"` - Price float64 `json:"price"` -} - -type Order struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - Status string `json:"status"` - Total float64 `json:"total"` - Currency string `json:"currency"` - Items []OrderItem `json:"items"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootOrders struct { - Data []Order `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootOrder struct { - Data Order `json:"data"` -} - -// --- Cart --- - -type Cart struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - Currency string `json:"currency"` - Total float64 `json:"total"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootCarts struct { - Data []Cart `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootCart struct { - Data Cart `json:"data"` -} - -// --- CartItem --- - -type CartItem struct { - ID string `json:"id"` - ProductID string `json:"product_id"` - Quantity int `json:"quantity"` - Price float64 `json:"price"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type RootCartItems struct { - Data []CartItem `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` -} - -type RootCartItem struct { - Data CartItem `json:"data"` -} - -// --- Count (shared response) --- - -type RootCount struct { - Total int `json:"total"` -} diff --git a/internal/output/output.go b/internal/output/output.go deleted file mode 100644 index f2c59b9..0000000 --- a/internal/output/output.go +++ /dev/null @@ -1,114 +0,0 @@ -package output - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" -) - -var ( - noColor = os.Getenv("NO_COLOR") != "" - - HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) - SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) -) - -func style(s lipgloss.Style, text string) string { - if noColor { - return text - } - return s.Render(text) -} - -func Success(msg string) { - fmt.Println(style(SuccessStyle, msg)) -} - -func Error(msg string) { - fmt.Fprintln(os.Stderr, style(ErrorStyle, msg)) -} - -func Errorf(format string, args ...interface{}) { - Error(fmt.Sprintf(format, args...)) -} - -func JSON(v interface{}) error { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(v) -} - -func Table(headers []string, rows [][]string) { - if len(rows) == 0 { - fmt.Println(style(DimStyle, "No results found.")) - return - } - - if noColor { - printPlainTable(headers, rows) - return - } - - t := table.New(). - Border(lipgloss.NormalBorder()). - BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("8"))). - Headers(headers...). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Padding(0, 1) - } - return lipgloss.NewStyle().Padding(0, 1) - }) - - for _, row := range rows { - t.Row(row...) - } - - fmt.Println(t) -} - -func printPlainTable(headers []string, rows [][]string) { - widths := make([]int, len(headers)) - for i, h := range headers { - widths[i] = len(h) - } - for _, row := range rows { - for i, cell := range row { - if i < len(widths) && len(cell) > widths[i] { - widths[i] = len(cell) - } - } - } - - // Header - for i, h := range headers { - fmt.Printf("%-*s", widths[i]+2, strings.ToUpper(h)) - } - fmt.Println() - - // Rows - for _, row := range rows { - for i, cell := range row { - if i < len(widths) { - fmt.Printf("%-*s", widths[i]+2, cell) - } - } - fmt.Println() - } -} - -func Truncate(s string, max int) string { - if len(s) <= max { - return s - } - if max <= 3 { - return s[:max] - } - return s[:max-3] + "..." -} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go deleted file mode 100644 index 8837fb2..0000000 --- a/internal/prompt/prompt.go +++ /dev/null @@ -1,98 +0,0 @@ -package prompt - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/huh" -) - -func IsInteractive() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} - -func Input(label, placeholder string) (string, error) { - var value string - err := huh.NewInput(). - Title(label). - Placeholder(placeholder). - Value(&value). - Run() - return strings.TrimSpace(value), err -} - -func Confirm(label string) (bool, error) { - var value bool - err := huh.NewConfirm(). - Title(label). - Value(&value). - Run() - return value, err -} - -func Select(label string, options []string) (string, error) { - var value string - opts := make([]huh.Option[string], len(options)) - for i, o := range options { - opts[i] = huh.NewOption(o, o) - } - err := huh.NewSelect[string](). - Title(label). - Options(opts...). - Value(&value). - Run() - return value, err -} - -func SelectLabeled(label string, labels, values []string) (string, error) { - var value string - opts := make([]huh.Option[string], len(labels)) - for i := range labels { - opts[i] = huh.NewOption(labels[i], values[i]) - } - err := huh.NewSelect[string](). - Title(label). - Options(opts...). - Value(&value). - Run() - return value, err -} - -func RequireArg(value, flag, label string) (string, error) { - if value != "" { - return value, nil - } - if !IsInteractive() { - return "", fmt.Errorf("--%s is required", flag) - } - return Input(label, "") -} - -func RequireSliceArg(values []string, flag, label string) ([]string, error) { - if len(values) > 0 { - return values, nil - } - if !IsInteractive() { - return nil, fmt.Errorf("--%s is required", flag) - } - raw, err := Input(label+" (comma-separated)", "") - if err != nil { - return nil, err - } - var result []string - for _, s := range strings.Split(raw, ",") { - s = strings.TrimSpace(s) - if s != "" { - result = append(result, s) - } - } - if len(result) == 0 { - return nil, fmt.Errorf("--%s is required", flag) - } - return result, nil -} diff --git a/internal/sdkclient/errors.go b/internal/sdkclient/errors.go deleted file mode 100644 index 4f0466b..0000000 --- a/internal/sdkclient/errors.go +++ /dev/null @@ -1,114 +0,0 @@ -package sdkclient - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - - "github.com/mailerlite/mailerlite-go" -) - -// CLIError represents an API error with full field-level validation details. -type CLIError struct { - StatusCode int - Message string `json:"message"` - Errors map[string][]string `json:"errors,omitempty"` - RawBody json.RawMessage `json:"-"` -} - -func (e *CLIError) Error() string { - if len(e.Errors) > 0 { - maxLen := 0 - for field := range e.Errors { - if len(field) > maxLen { - maxLen = len(field) - } - } - - var b strings.Builder - fmt.Fprintf(&b, "API error %d: %s\n", e.StatusCode, e.Message) - for field, msgs := range e.Errors { - for _, msg := range msgs { - fmt.Fprintf(&b, "\n %-*s %s", maxLen, field, msg) - } - } - return b.String() - } - return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message) -} - -// WrapError converts SDK errors into CLIError with full field-level details. -// It uses the response body captured by CLITransport to extract validation -// errors that the SDK discards. -func WrapError(err error) error { - if err == nil { - return nil - } - - cliErr := &CLIError{} - - // Try to extract status code and message from SDK error types. - var errResp *mailerlite.ErrorResponse - var authErr *mailerlite.AuthError - var rateLimitErr *mailerlite.RateLimitError - var httpResp *http.Response - - switch { - case errors.As(err, &authErr): - if authErr.Response != nil { - httpResp = authErr.Response - cliErr.StatusCode = authErr.Response.StatusCode - } - cliErr.Message = authErr.Message - case errors.As(err, &rateLimitErr): - if rateLimitErr.Response != nil { - httpResp = rateLimitErr.Response - cliErr.StatusCode = rateLimitErr.Response.StatusCode - } - cliErr.Message = rateLimitErr.Message - case errors.As(err, &errResp): - if errResp.Response != nil { - httpResp = errResp.Response - cliErr.StatusCode = errResp.Response.StatusCode - } - cliErr.Message = errResp.Message - if len(errResp.Errors) > 0 { - cliErr.Errors = errResp.Errors - } - default: - // Not an SDK API error — return as-is. - return err - } - - // Try to parse the captured response body for field-level errors. - if httpResp != nil && httpResp.Header != nil { - if encodedBody := httpResp.Header.Get("X-CLI-Error-Body"); encodedBody != "" { - if rawBody, decErr := base64.StdEncoding.DecodeString(encodedBody); decErr == nil && len(rawBody) > 0 { - cliErr.RawBody = json.RawMessage(rawBody) - - var parsed struct { - Message string `json:"message"` - Errors map[string][]string `json:"errors"` - } - if json.Unmarshal(rawBody, &parsed) == nil { - if parsed.Message != "" { - cliErr.Message = parsed.Message - } - // Only overwrite errors if errResp.Errors wasn't set or was empty - if len(parsed.Errors) > 0 && len(cliErr.Errors) == 0 { - cliErr.Errors = parsed.Errors - } - } - } - } - } - - if cliErr.Message == "" { - cliErr.Message = err.Error() - } - - return cliErr -} diff --git a/internal/sdkclient/paginate.go b/internal/sdkclient/paginate.go deleted file mode 100644 index 6431256..0000000 --- a/internal/sdkclient/paginate.go +++ /dev/null @@ -1,114 +0,0 @@ -package sdkclient - -import "context" - -// PageFetcher fetches a single page of results. Returns the items, whether -// there is a next page, and any error. -type PageFetcher[T any] func(ctx context.Context, page, perPage int) ([]T, bool, error) - -// FetchAll fetches all pages up to limit using the given PageFetcher. -// If limit is 0, all pages are fetched. -func FetchAll[T any](ctx context.Context, fetch PageFetcher[T], limit int) ([]T, error) { - perPage := 25 - if limit > 0 && limit < perPage { - perPage = limit - } - - var allItems []T - page := 1 - - for { - items, hasNext, err := fetch(ctx, page, perPage) - if err != nil { - return nil, err - } - - allItems = append(allItems, items...) - - if limit > 0 && len(allItems) >= limit { - allItems = allItems[:limit] - break - } - - if !hasNext { - break - } - page++ - } - - return allItems, nil -} - -// StringCursorFetcher fetches a single page using string cursor-based pagination. -// Returns the items, the next cursor (empty if no more pages), and any error. -type StringCursorFetcher[T any] func(ctx context.Context, cursor string, perPage int) ([]T, string, error) - -// FetchAllStringCursor fetches all pages using string cursor-based pagination. -// Used for subscriber pagination in MailerLite. -func FetchAllStringCursor[T any](ctx context.Context, fetch StringCursorFetcher[T], limit int) ([]T, error) { - perPage := 25 - if limit > 0 && limit < perPage { - perPage = limit - } - - var allItems []T - cursor := "" - - for { - items, nextCursor, err := fetch(ctx, cursor, perPage) - if err != nil { - return nil, err - } - - allItems = append(allItems, items...) - - if limit > 0 && len(allItems) >= limit { - allItems = allItems[:limit] - break - } - - if nextCursor == "" || len(items) == 0 { - break - } - cursor = nextCursor - } - - return allItems, nil -} - -// CursorFetcher fetches a single page of results using cursor-based pagination. -// Returns the items, the next cursor value (0 if no more pages), and any error. -type CursorFetcher[T any] func(ctx context.Context, after, perPage int) ([]T, int, error) - -// FetchAllCursor fetches all pages using cursor-based pagination (After int). -// Used for segment subscriber pagination in MailerLite. -func FetchAllCursor[T any](ctx context.Context, fetch CursorFetcher[T], limit int) ([]T, error) { - perPage := 25 - if limit > 0 && limit < perPage { - perPage = limit - } - - var allItems []T - after := 0 - - for { - items, nextAfter, err := fetch(ctx, after, perPage) - if err != nil { - return nil, err - } - - allItems = append(allItems, items...) - - if limit > 0 && len(allItems) >= limit { - allItems = allItems[:limit] - break - } - - if nextAfter == 0 || len(items) == 0 { - break - } - after = nextAfter - } - - return allItems, nil -} diff --git a/internal/sdkclient/rawhttp.go b/internal/sdkclient/rawhttp.go deleted file mode 100644 index 1f899ad..0000000 --- a/internal/sdkclient/rawhttp.go +++ /dev/null @@ -1,83 +0,0 @@ -package sdkclient - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -const rawDefaultBaseURL = "https://connect.mailerlite.com/api" - -// DoRaw performs a raw HTTP request for endpoints not covered by the SDK -// (e.g., e-commerce). It uses the provided httpClient (which should have -// the CLITransport configured) for retry/verbose behavior. -func DoRaw(ctx context.Context, httpClient *http.Client, apiKey, method, path string, body, result interface{}) (*http.Response, error) { - url := rawDefaultBaseURL + path - - var bodyReader io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(b) - } - - req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - cliErr := &CLIError{StatusCode: resp.StatusCode} - if len(respBody) > 0 { - cliErr.RawBody = respBody - var parsed struct { - Message string `json:"message"` - Errors map[string][]string `json:"errors"` - } - if json.Unmarshal(respBody, &parsed) == nil { - cliErr.Message = parsed.Message - cliErr.Errors = parsed.Errors - } - } - if cliErr.Message == "" { - cliErr.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) - } - return resp, cliErr - } - - if result != nil && resp.StatusCode != http.StatusNoContent { - if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return resp, fmt.Errorf("failed to decode response: %w", err) - } - } - - return resp, nil -} - -// DoRawQuery is like DoRaw but accepts query parameters as a map. -func DoRawQuery(ctx context.Context, httpClient *http.Client, apiKey, method, path string, query map[string]string, body, result interface{}) (*http.Response, error) { - if len(query) > 0 { - sep := "?" - for k, v := range query { - path += sep + k + "=" + v - sep = "&" - } - } - return DoRaw(ctx, httpClient, apiKey, method, path, body, result) -} diff --git a/internal/sdkclient/transport.go b/internal/sdkclient/transport.go deleted file mode 100644 index b9f39a0..0000000 --- a/internal/sdkclient/transport.go +++ /dev/null @@ -1,168 +0,0 @@ -package sdkclient - -import ( - "bytes" - "encoding/base64" - "fmt" - "io" - "math" - "net/http" - "strconv" - "strings" - "time" -) - -const ( - defaultBaseURL = "https://connect.mailerlite.com/api" - maxRetries = 3 -) - -var userAgent = "mailerlite-cli/dev" - -// SetUserAgent sets the User-Agent string used for all API requests. -func SetUserAgent(ua string) { - userAgent = ua -} - -// CLITransport wraps an http.RoundTripper with CLI-specific behavior: -// retry logic, verbose logging, user-agent override, base URL rewrite, -// and error body capture for the error bridge. -type CLITransport struct { - Base http.RoundTripper - Verbose bool - BaseURL string // if set, replaces the SDK's hardcoded base URL - AccountID string // if set, sends X-Acc-Id header on all requests -} - -func (t *CLITransport) base() http.RoundTripper { - if t.Base != nil { - return t.Base - } - return http.DefaultTransport -} - -func (t *CLITransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Rewrite base URL if configured. - if t.BaseURL != "" { - urlStr := req.URL.String() - if strings.HasPrefix(urlStr, defaultBaseURL) { - newURL := t.BaseURL + strings.TrimPrefix(urlStr, defaultBaseURL) - parsed, err := req.URL.Parse(newURL) - if err == nil { - req.URL = parsed - } - } - } - - // Override User-Agent. - req.Header.Set("User-Agent", userAgent) - - // Set account context header if configured. - if t.AccountID != "" { - req.Header.Set("X-Acc-Id", t.AccountID) - } - - // Capture request body for retries. - var bodyBytes []byte - if req.Body != nil { - var err error - bodyBytes, err = io.ReadAll(req.Body) - if err != nil { - return nil, fmt.Errorf("failed to read request body: %w", err) - } - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - - if t.Verbose { - fmt.Printf("--> %s %s\n", req.Method, req.URL) - if t.AccountID != "" { - fmt.Printf("--> X-Acc-Id: %s\n", t.AccountID) - } - if len(bodyBytes) > 0 { - fmt.Printf("--> body: %s\n", string(bodyBytes)) - } - } - - var resp *http.Response - var lastErr error - - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - // Reset body for retry. - if len(bodyBytes) > 0 { - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - } - - resp, lastErr = t.base().RoundTrip(req) - if lastErr != nil { - if t.Verbose { - fmt.Printf("<-- error: %v\n", lastErr) - } - if attempt == maxRetries { - break - } - backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second - time.Sleep(backoff) - continue - } - - if t.Verbose { - fmt.Printf("<-- %d %s\n", resp.StatusCode, resp.Status) - } - - // Capture error response body before the SDK can consume it. - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() //nolint:errcheck - - if t.Verbose && len(respBody) > 0 { - fmt.Printf("<-- body: %s\n", string(respBody)) - } - - // Store for WrapError. - if resp.Header == nil { - resp.Header = make(http.Header) - } - resp.Header.Set("X-CLI-Error-Body", base64.StdEncoding.EncodeToString(respBody)) - - // For retryable errors, retry. - if resp.StatusCode == 429 || resp.StatusCode >= 500 { - wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second - if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { - if secs, err := strconv.Atoi(retryAfter); err == nil { - wait = time.Duration(secs) * time.Second - } - } - if attempt < maxRetries { - if t.Verbose { - fmt.Printf(" retrying in %s...\n", wait) - } - time.Sleep(wait) - continue - } - } - - // Replace the body so the SDK can still read it. - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - return resp, nil - } - - // Success — capture body for verbose logging, then re-wrap. - if t.Verbose { - respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() //nolint:errcheck - if len(respBody) > 0 { - fmt.Printf("<-- body: %s\n", string(respBody)) - } - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - } - - return resp, nil - } - - if lastErr != nil { - return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr) - } - return resp, nil -} diff --git a/internal/tui/app.go b/internal/tui/app.go deleted file mode 100644 index 9443248..0000000 --- a/internal/tui/app.go +++ /dev/null @@ -1,420 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-cli/internal/tui/views" - "github.com/mailerlite/mailerlite-go" -) - -var ( - headerStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - Padding(0, 1) - - headerBarStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(theme.Muted) - - contentStyle = lipgloss.NewStyle(). - Padding(0, 1) - - errorStyle = lipgloss.NewStyle(). - Foreground(theme.Error) -) - -// FocusArea represents which area of the UI is focused. -type FocusArea int - -const ( - FocusSidebar FocusArea = iota - FocusContent -) - -// App is the main TUI application model. -type App struct { - // SDK - client *mailerlite.Client - - profile string - - // Components - sidebar components.Sidebar - statusbar components.StatusBar - spinner components.Spinner - help components.Help - keys KeyMap - - // Views - subscribers views.SubscribersView - campaigns views.CampaignsView - automations views.AutomationsView - groups views.GroupsView - forms views.FormsView - - // State - activeView types.ViewType - focus FocusArea - width int - height int - showHelp bool - err error - initialized bool -} - -// NewApp creates a new TUI application. -func NewApp(client *mailerlite.Client, profile string) *App { - keys := DefaultKeyMap() - - app := &App{ - client: client, - - profile: profile, - keys: keys, - sidebar: components.NewSidebar(), - statusbar: components.NewStatusBar(), - spinner: components.NewSpinner("Loading..."), - help: components.NewHelp(keys.HelpBindings()), - focus: FocusContent, - } - - // Initialize views - app.subscribers = views.NewSubscribersView(client) - app.campaigns = views.NewCampaignsView(client) - app.automations = views.NewAutomationsView(client) - app.groups = views.NewGroupsView(client) - app.forms = views.NewFormsView(client) - - // Set initial focus - app.sidebar.SetFocused(false) - app.subscribers.SetFocused(true) - - return app -} - -// Init implements tea.Model. -func (a *App) Init() tea.Cmd { - return tea.Batch( - a.spinner.Init(), - a.fetchCurrentView(), - ) -} - -// Update implements tea.Model. -func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - a.width = msg.Width - a.height = msg.Height - a.updateLayout() - if !a.initialized { - a.initialized = true - } - - case tea.KeyMsg: - // Handle help overlay first - if a.showHelp { - if key.Matches(msg, a.keys.Help) || key.Matches(msg, a.keys.Back) { - a.showHelp = false - return a, nil - } - return a, nil - } - - // Global keys - switch { - case key.Matches(msg, a.keys.Quit): - return a, tea.Quit - case key.Matches(msg, a.keys.Help): - a.showHelp = true - return a, nil - case key.Matches(msg, a.keys.Tab): - a.toggleFocus() - return a, nil - case key.Matches(msg, a.keys.View1): - return a, a.switchView(types.ViewSubscribers) - case key.Matches(msg, a.keys.View2): - return a, a.switchView(types.ViewCampaigns) - case key.Matches(msg, a.keys.View3): - return a, a.switchView(types.ViewAutomations) - case key.Matches(msg, a.keys.View4): - return a, a.switchView(types.ViewGroups) - case key.Matches(msg, a.keys.View5): - return a, a.switchView(types.ViewForms) - } - - // Focus-specific keys - if a.focus == FocusSidebar { - cmd := a.handleSidebarKey(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - cmd := a.handleContentKey(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - // Handle data loaded messages - case types.SubscribersLoadedMsg: - a.subscribers, _ = a.subscribers.Update(msg) - a.updateStatusBar() - case types.CampaignsLoadedMsg: - a.campaigns, _ = a.campaigns.Update(msg) - a.updateStatusBar() - case types.AutomationsLoadedMsg: - a.automations, _ = a.automations.Update(msg) - a.updateStatusBar() - case types.GroupsLoadedMsg: - a.groups, _ = a.groups.Update(msg) - a.updateStatusBar() - case types.FormsLoadedMsg: - a.forms, _ = a.forms.Update(msg) - a.updateStatusBar() - - case types.ErrorMsg: - a.err = msg.Err - } - - // Update spinner - var spinnerCmd tea.Cmd - a.spinner, spinnerCmd = a.spinner.Update(msg) - if spinnerCmd != nil { - cmds = append(cmds, spinnerCmd) - } - - return a, tea.Batch(cmds...) -} - -func (a *App) toggleFocus() { - if a.focus == FocusSidebar { - a.focus = FocusContent - a.sidebar.SetFocused(false) - a.setCurrentViewFocused(true) - } else { - a.focus = FocusSidebar - a.sidebar.SetFocused(true) - a.setCurrentViewFocused(false) - } -} - -func (a *App) handleSidebarKey(msg tea.KeyMsg) tea.Cmd { - switch { - case key.Matches(msg, a.keys.Down): - a.sidebar.Next() - return a.switchView(a.sidebar.Active()) - case key.Matches(msg, a.keys.Up): - a.sidebar.Prev() - return a.switchView(a.sidebar.Active()) - case key.Matches(msg, a.keys.Enter), key.Matches(msg, a.keys.Right): - a.focus = FocusContent - a.sidebar.SetFocused(false) - a.setCurrentViewFocused(true) - } - return nil -} - -func (a *App) handleContentKey(msg tea.KeyMsg) tea.Cmd { - switch a.activeView { - case types.ViewSubscribers: - return a.subscribers.HandleKey(msg) - case types.ViewCampaigns: - return a.campaigns.HandleKey(msg) - case types.ViewAutomations: - return a.automations.HandleKey(msg) - case types.ViewGroups: - return a.groups.HandleKey(msg) - case types.ViewForms: - return a.forms.HandleKey(msg) - } - return nil -} - -func (a *App) switchView(v types.ViewType) tea.Cmd { - if a.activeView == v { - return nil - } - - a.setCurrentViewFocused(false) - a.activeView = v - a.sidebar.SetActive(v) - a.setCurrentViewFocused(a.focus == FocusContent) - a.updateStatusBar() - - return a.fetchCurrentView() -} - -func (a *App) setCurrentViewFocused(focused bool) { - switch a.activeView { - case types.ViewSubscribers: - a.subscribers.SetFocused(focused) - case types.ViewCampaigns: - a.campaigns.SetFocused(focused) - case types.ViewAutomations: - a.automations.SetFocused(focused) - case types.ViewGroups: - a.groups.SetFocused(focused) - case types.ViewForms: - a.forms.SetFocused(focused) - } -} - -func (a *App) fetchCurrentView() tea.Cmd { - a.spinner.Start() - a.spinner.SetLabel("Loading " + a.activeView.String() + "...") - - switch a.activeView { - case types.ViewSubscribers: - return a.subscribers.Fetch() - case types.ViewCampaigns: - return a.campaigns.Fetch() - case types.ViewAutomations: - return a.automations.Fetch() - case types.ViewGroups: - return a.groups.Fetch() - case types.ViewForms: - return a.forms.Fetch() - } - return nil -} - -func (a *App) updateLayout() { - // Header takes 2 lines, status bar takes 2 lines - contentHeight := a.height - 4 - - a.sidebar.SetHeight(contentHeight) - a.statusbar.SetWidth(a.width) - a.help.SetSize(a.width, a.height) - - // Content width is total minus sidebar - contentWidth := a.width - a.sidebar.Width() - 2 - - a.subscribers.SetSize(contentWidth, contentHeight) - a.campaigns.SetSize(contentWidth, contentHeight) - a.automations.SetSize(contentWidth, contentHeight) - a.groups.SetSize(contentWidth, contentHeight) - a.forms.SetSize(contentWidth, contentHeight) - - a.updateStatusBar() -} - -func (a *App) updateStatusBar() { - a.statusbar.SetProfile(a.profile) - - // Get current view info - viewName := a.activeView.String() - itemCount := 0 - loading := false - - switch a.activeView { - case types.ViewSubscribers: - itemCount = a.subscribers.ItemCount() - loading = a.subscribers.Loading() - case types.ViewCampaigns: - itemCount = a.campaigns.ItemCount() - loading = a.campaigns.Loading() - case types.ViewAutomations: - itemCount = a.automations.ItemCount() - loading = a.automations.Loading() - case types.ViewGroups: - itemCount = a.groups.ItemCount() - loading = a.groups.Loading() - case types.ViewForms: - itemCount = a.forms.ItemCount() - loading = a.forms.Loading() - } - - if loading { - a.statusbar.SetLeft(viewName) - a.statusbar.SetLoading(true, "Loading...") - a.spinner.Start() - } else { - a.statusbar.SetLeft(fmt.Sprintf("%s (%d)", viewName, itemCount)) - a.statusbar.SetLoading(false, "") - a.spinner.Stop() - } -} - -// View implements tea.Model. -func (a *App) View() string { - if a.width == 0 || a.height == 0 { - return "Initializing..." - } - - var b strings.Builder - - // Header - header := a.renderHeader() - b.WriteString(header) - b.WriteString("\n") - - // Main content area - mainContent := a.renderMainContent() - b.WriteString(mainContent) - - // Status bar - b.WriteString("\n") - b.WriteString(a.statusbar.View()) - - // Help overlay (rendered on top) - if a.showHelp { - // Clear and render help overlay - return a.help.View() - } - - return b.String() -} - -func (a *App) renderHeader() string { - title := headerStyle.Render("MailerLite Dashboard") - profile := lipgloss.NewStyle().Foreground(theme.Muted).Render("profile: " + a.profile) - - // Calculate spacing - gap := a.width - lipgloss.Width(title) - lipgloss.Width(profile) - 4 - if gap < 1 { - gap = 1 - } - - content := title + strings.Repeat(" ", gap) + profile - return headerBarStyle.Width(a.width).Render(content) -} - -func (a *App) renderMainContent() string { - sidebar := a.sidebar.View() - - // Render active view - var content string - switch a.activeView { - case types.ViewSubscribers: - content = a.subscribers.View() - case types.ViewCampaigns: - content = a.campaigns.View() - case types.ViewAutomations: - content = a.automations.View() - case types.ViewGroups: - content = a.groups.View() - case types.ViewForms: - content = a.forms.View() - } - - // Add error display if present - if a.err != nil { - content = errorStyle.Render("Error: "+a.err.Error()) + "\n\n" + content - } - - contentWidth := a.width - a.sidebar.Width() - 4 - styledContent := contentStyle.Width(contentWidth).Render(content) - - return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, styledContent) -} diff --git a/internal/tui/components/detail.go b/internal/tui/components/detail.go deleted file mode 100644 index e5b3332..0000000 --- a/internal/tui/components/detail.go +++ /dev/null @@ -1,93 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" -) - -var ( - detailTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - MarginBottom(1) - - detailLabelStyle = lipgloss.NewStyle(). - Foreground(theme.Muted). - Width(20) - - detailValueStyle = lipgloss.NewStyle(). - Foreground(theme.Text) - - detailHintStyle = lipgloss.NewStyle(). - Foreground(theme.Muted). - Italic(true). - MarginTop(1) -) - -// DetailRow represents a key-value pair for display. -type DetailRow struct { - Label string - Value string -} - -// DetailPanel displays detailed information about an item. -type DetailPanel struct { - title string - rows []DetailRow - width int - height int -} - -// NewDetailPanel creates a new detail panel. -func NewDetailPanel() DetailPanel { - return DetailPanel{} -} - -// SetTitle sets the panel title. -func (d *DetailPanel) SetTitle(title string) { - d.title = title -} - -// SetRows sets the detail rows. -func (d *DetailPanel) SetRows(rows []DetailRow) { - d.rows = rows -} - -// SetSize sets the panel dimensions. -func (d *DetailPanel) SetSize(width, height int) { - d.width = width - d.height = height -} - -// View renders the detail panel. -func (d DetailPanel) View() string { - var b strings.Builder - - // Title - b.WriteString(detailTitleStyle.Render(d.title)) - b.WriteString("\n\n") - - // Rows - for _, row := range d.rows { - if row.Label == "" && row.Value == "" { - b.WriteString("\n") - continue - } - if row.Label == "" { - value := detailValueStyle.Render(row.Value) - b.WriteString(detailLabelStyle.Render("") + " " + value + "\n") - continue - } - label := detailLabelStyle.Render(row.Label + ":") - value := detailValueStyle.Render(row.Value) - b.WriteString(label + " " + value + "\n") - } - - // Hint - b.WriteString("\n") - b.WriteString(detailHintStyle.Render("Press Esc or Backspace to go back")) - - return b.String() -} diff --git a/internal/tui/components/help.go b/internal/tui/components/help.go deleted file mode 100644 index ced8070..0000000 --- a/internal/tui/components/help.go +++ /dev/null @@ -1,141 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" -) - -var ( - helpOverlayStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.Primary). - Padding(1, 2). - Background(theme.BgOverlay) - - helpTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - MarginBottom(1) - - helpKeyStyle = lipgloss.NewStyle(). - Foreground(theme.Key). - Width(14) - - helpDescStyle = lipgloss.NewStyle(). - Foreground(theme.TextSub) - - helpSectionStyle = lipgloss.NewStyle(). - MarginTop(1). - MarginBottom(0) -) - -// Help is the help overlay component. -type Help struct { - bindings [][]key.Binding - visible bool - width int - height int -} - -// NewHelp creates a new help overlay. -func NewHelp(bindings [][]key.Binding) Help { - return Help{ - bindings: bindings, - } -} - -// SetVisible sets whether the help overlay is shown. -func (h *Help) SetVisible(visible bool) { - h.visible = visible -} - -// Toggle toggles the help overlay visibility. -func (h *Help) Toggle() { - h.visible = !h.visible -} - -// Visible returns whether the help is showing. -func (h Help) Visible() bool { - return h.visible -} - -// SetSize sets the overlay dimensions. -func (h *Help) SetSize(width, height int) { - h.width = width - h.height = height -} - -// View renders the help overlay. -func (h Help) View() string { - if !h.visible { - return "" - } - - var b strings.Builder - - b.WriteString(helpTitleStyle.Render("Keyboard Shortcuts")) - b.WriteString("\n\n") - - sections := []struct { - title string - bindings []key.Binding - }{ - {"Navigation", h.bindings[0]}, - {"Actions", h.bindings[1]}, - {"Views", h.bindings[2]}, - {"General", h.bindings[3]}, - } - - for i, section := range sections { - if i > 0 { - b.WriteString("\n") - } - b.WriteString(helpSectionStyle.Render(lipgloss.NewStyle().Bold(true).Render(section.title))) - b.WriteString("\n") - - for _, binding := range section.bindings { - help := binding.Help() - line := helpKeyStyle.Render(help.Key) + helpDescStyle.Render(help.Desc) - b.WriteString(line) - b.WriteString("\n") - } - } - - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render("Press ? or Esc to close")) - - content := b.String() - - // Calculate overlay size - overlayWidth := 40 - overlayHeight := strings.Count(content, "\n") + 4 - - // Center the overlay - x := (h.width - overlayWidth) / 2 - y := (h.height - overlayHeight) / 2 - if x < 0 { - x = 0 - } - if y < 0 { - y = 0 - } - - styled := helpOverlayStyle.Width(overlayWidth).Render(content) - - // Add positioning - var result strings.Builder - for i := 0; i < y; i++ { - result.WriteString("\n") - } - lines := strings.Split(styled, "\n") - for _, line := range lines { - result.WriteString(strings.Repeat(" ", x)) - result.WriteString(line) - result.WriteString("\n") - } - - return result.String() -} diff --git a/internal/tui/components/sidebar.go b/internal/tui/components/sidebar.go deleted file mode 100644 index 9030a2f..0000000 --- a/internal/tui/components/sidebar.go +++ /dev/null @@ -1,139 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" -) - -const SidebarWidth = 20 - -var ( - sidebarStyle = lipgloss.NewStyle(). - Width(SidebarWidth). - Border(lipgloss.NormalBorder(), false, true, false, false). - BorderForeground(theme.Muted). - Padding(1, 1) - - itemStyle = lipgloss.NewStyle(). - Padding(0, 1) - - activeItemStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - Background(theme.BgSelected). - Padding(0, 1) - - focusedItemStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Text). - Background(theme.Accent). - Padding(0, 1) -) - -// Sidebar is the navigation sidebar component. -type Sidebar struct { - views []types.ViewInfo - active types.ViewType - focused bool - height int -} - -// NewSidebar creates a new sidebar. -func NewSidebar() Sidebar { - return Sidebar{ - views: types.AllViews(), - active: types.ViewSubscribers, - } -} - -// SetActive sets the active view. -func (s *Sidebar) SetActive(v types.ViewType) { - s.active = v -} - -// Active returns the currently active view. -func (s Sidebar) Active() types.ViewType { - return s.active -} - -// SetFocused sets whether the sidebar is focused. -func (s *Sidebar) SetFocused(focused bool) { - s.focused = focused -} - -// Focused returns whether the sidebar is focused. -func (s Sidebar) Focused() bool { - return s.focused -} - -// SetHeight sets the sidebar height. -func (s *Sidebar) SetHeight(h int) { - s.height = h -} - -// Next moves to the next view. -func (s *Sidebar) Next() { - idx := int(s.active) - idx = (idx + 1) % len(s.views) - s.active = s.views[idx].Type -} - -// Prev moves to the previous view. -func (s *Sidebar) Prev() { - idx := int(s.active) - idx-- - if idx < 0 { - idx = len(s.views) - 1 - } - s.active = s.views[idx].Type -} - -// SetView sets the view directly. -func (s *Sidebar) SetView(v types.ViewType) { - if int(v) >= 0 && int(v) < len(s.views) { - s.active = v - } -} - -// Width returns the sidebar width. -func (s Sidebar) Width() int { - return SidebarWidth + 2 // account for border -} - -// View renders the sidebar. -func (s Sidebar) View() string { - var b strings.Builder - - for _, view := range s.views { - style := itemStyle - prefix := " " - - if view.Type == s.active { - if s.focused { - style = focusedItemStyle - } else { - style = activeItemStyle - } - prefix = "▸ " - } - - line := prefix + view.Icon + " " + view.Label - // Pad to full width - line = padRight(line, SidebarWidth-4) - b.WriteString(style.Render(line)) - b.WriteString("\n") - } - - content := strings.TrimRight(b.String(), "\n") - return sidebarStyle.Height(s.height - 2).Render(content) -} - -func padRight(s string, width int) string { - if len(s) >= width { - return s - } - return s + strings.Repeat(" ", width-len(s)) -} diff --git a/internal/tui/components/spinner.go b/internal/tui/components/spinner.go deleted file mode 100644 index e9d3610..0000000 --- a/internal/tui/components/spinner.go +++ /dev/null @@ -1,75 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" -) - -// Spinner wraps the bubbles spinner with consistent styling. -type Spinner struct { - spinner spinner.Model - label string - visible bool -} - -// NewSpinner creates a new spinner with the given label. -func NewSpinner(label string) Spinner { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(theme.Primary) - return Spinner{ - spinner: s, - label: label, - visible: false, - } -} - -// Start makes the spinner visible. -func (s *Spinner) Start() { - s.visible = true -} - -// Stop hides the spinner. -func (s *Spinner) Stop() { - s.visible = false -} - -// SetLabel updates the spinner label. -func (s *Spinner) SetLabel(label string) { - s.label = label -} - -// Visible returns whether the spinner is showing. -func (s Spinner) Visible() bool { - return s.visible -} - -// Init initializes the spinner. -func (s Spinner) Init() tea.Cmd { - return s.spinner.Tick -} - -// Update handles spinner tick messages. -func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { - if !s.visible { - return s, nil - } - var cmd tea.Cmd - s.spinner, cmd = s.spinner.Update(msg) - return s, cmd -} - -// View renders the spinner. -func (s Spinner) View() string { - if !s.visible { - return "" - } - return s.spinner.View() + " " + s.label -} - -// Tick returns the spinner tick command. -func (s Spinner) Tick() tea.Cmd { - return s.spinner.Tick -} diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go deleted file mode 100644 index c051e16..0000000 --- a/internal/tui/components/statusbar.go +++ /dev/null @@ -1,127 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" -) - -var ( - statusStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), true, false, false, false). - BorderForeground(theme.Muted) - - statusTextStyle = lipgloss.NewStyle(). - Foreground(theme.Muted) - - statusKeyStyle = lipgloss.NewStyle(). - Foreground(theme.Key) - - statusValueStyle = lipgloss.NewStyle(). - Foreground(theme.TextSub) -) - -// StatusBar represents the bottom status bar. -type StatusBar struct { - width int - left string - center string - right string - profile string - loading bool - loadText string -} - -// NewStatusBar creates a new status bar. -func NewStatusBar() StatusBar { - return StatusBar{} -} - -// SetWidth sets the status bar width. -func (s *StatusBar) SetWidth(w int) { - s.width = w -} - -// SetLeft sets the left section text. -func (s *StatusBar) SetLeft(text string) { - s.left = text -} - -// SetCenter sets the center section text. -func (s *StatusBar) SetCenter(text string) { - s.center = text -} - -// SetRight sets the right section text. -func (s *StatusBar) SetRight(text string) { - s.right = text -} - -// SetProfile sets the current profile name. -func (s *StatusBar) SetProfile(name string) { - s.profile = name -} - -// SetLoading sets the loading state and text. -func (s *StatusBar) SetLoading(loading bool, text string) { - s.loading = loading - s.loadText = text -} - -// View renders the status bar. -func (s StatusBar) View() string { - if s.width == 0 { - return "" - } - - // Build left section: view name + count - left := statusTextStyle.Render(s.left) - - // Build center section: loading or custom text - center := "" - if s.loading { - center = statusTextStyle.Render(s.loadText) - } else if s.center != "" { - center = statusTextStyle.Render(s.center) - } - - // Build right section: key hints + profile - hints := []string{ - fmt.Sprintf("%s %s", statusKeyStyle.Render("↑↓"), statusValueStyle.Render("navigate")), - fmt.Sprintf("%s %s", statusKeyStyle.Render("enter"), statusValueStyle.Render("select")), - fmt.Sprintf("%s %s", statusKeyStyle.Render("?"), statusValueStyle.Render("help")), - } - if s.profile != "" { - hints = append(hints, fmt.Sprintf("%s %s", statusKeyStyle.Render("profile:"), statusValueStyle.Render(s.profile))) - } - right := strings.Join(hints, " ") - - // Calculate spacing - leftLen := lipgloss.Width(left) - centerLen := lipgloss.Width(center) - rightLen := lipgloss.Width(right) - - // Available space for padding - contentWidth := s.width - 4 // account for padding - totalContentLen := leftLen + centerLen + rightLen - - if totalContentLen >= contentWidth { - // Not enough space, just show left and right - gap := contentWidth - leftLen - rightLen - if gap < 1 { - gap = 1 - } - return statusStyle.Width(s.width).Render(left + strings.Repeat(" ", gap) + right) - } - - // Calculate gaps - remainingSpace := contentWidth - totalContentLen - leftGap := remainingSpace / 2 - rightGap := remainingSpace - leftGap - - content := left + strings.Repeat(" ", leftGap) + center + strings.Repeat(" ", rightGap) + right - - return statusStyle.Width(s.width).Render(content) -} diff --git a/internal/tui/components/table.go b/internal/tui/components/table.go deleted file mode 100644 index e718b9f..0000000 --- a/internal/tui/components/table.go +++ /dev/null @@ -1,250 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" -) - -var ( - tableHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(theme.Muted) - - tableRowStyle = lipgloss.NewStyle() - - tableSelectedStyle = lipgloss.NewStyle(). - Background(theme.BgSelected). - Foreground(theme.Text) - - tableFocusedSelectedStyle = lipgloss.NewStyle(). - Background(theme.Accent). - Foreground(theme.Text) - - emptyStyle = lipgloss.NewStyle(). - Foreground(theme.Muted). - Italic(true) -) - -// Column defines a table column. -type Column struct { - Title string - Width int -} - -// Table is an interactive table component. -type Table struct { - columns []Column - rows [][]string - cursor int - offset int - width int - height int - focused bool - loading bool - emptyMsg string -} - -// NewTable creates a new table with the given columns. -func NewTable(columns []Column) Table { - return Table{ - columns: columns, - emptyMsg: "No data", - } -} - -// SetColumns updates the table columns. -func (t *Table) SetColumns(columns []Column) { - t.columns = columns -} - -// SetRows sets the table data. -func (t *Table) SetRows(rows [][]string) { - t.rows = rows - t.cursor = 0 - t.offset = 0 -} - -// SetSize sets the table dimensions. -func (t *Table) SetSize(width, height int) { - t.width = width - t.height = height -} - -// SetFocused sets whether the table is focused. -func (t *Table) SetFocused(focused bool) { - t.focused = focused -} - -// SetLoading sets the loading state. -func (t *Table) SetLoading(loading bool) { - t.loading = loading -} - -// SetEmptyMessage sets the message shown when there are no rows. -func (t *Table) SetEmptyMessage(msg string) { - t.emptyMsg = msg -} - -// Cursor returns the current cursor position. -func (t Table) Cursor() int { - return t.cursor -} - -// SelectedRow returns the currently selected row data. -func (t Table) SelectedRow() []string { - if t.cursor >= 0 && t.cursor < len(t.rows) { - return t.rows[t.cursor] - } - return nil -} - -// RowCount returns the number of rows. -func (t Table) RowCount() int { - return len(t.rows) -} - -// MoveUp moves the cursor up. -func (t *Table) MoveUp() { - if t.cursor > 0 { - t.cursor-- - t.updateOffset() - } -} - -// MoveDown moves the cursor down. -func (t *Table) MoveDown() { - if t.cursor < len(t.rows)-1 { - t.cursor++ - t.updateOffset() - } -} - -// GotoTop moves to the first row. -func (t *Table) GotoTop() { - t.cursor = 0 - t.offset = 0 -} - -// GotoBottom moves to the last row. -func (t *Table) GotoBottom() { - t.cursor = len(t.rows) - 1 - t.updateOffset() -} - -func (t *Table) updateOffset() { - visibleRows := t.visibleRowCount() - if visibleRows <= 0 { - return - } - - // Scroll down if cursor is below visible area - if t.cursor >= t.offset+visibleRows { - t.offset = t.cursor - visibleRows + 1 - } - // Scroll up if cursor is above visible area - if t.cursor < t.offset { - t.offset = t.cursor - } -} - -func (t Table) visibleRowCount() int { - // Account for header and borders - return t.height - 3 -} - -// View renders the table. -func (t Table) View() string { - if t.loading { - return emptyStyle.Render("Loading...") - } - - if len(t.rows) == 0 { - return emptyStyle.Render(t.emptyMsg) - } - - var b strings.Builder - - // Render header - header := t.renderRow(t.columnTitles(), tableHeaderStyle, false) - b.WriteString(header) - b.WriteString("\n") - - // Calculate visible range - visibleRows := t.visibleRowCount() - if visibleRows < 1 { - visibleRows = 1 - } - - start := t.offset - end := start + visibleRows - if end > len(t.rows) { - end = len(t.rows) - } - - // Render visible rows - for i := start; i < end; i++ { - row := t.rows[i] - style := tableRowStyle - isSelected := i == t.cursor - - if isSelected { - if t.focused { - style = tableFocusedSelectedStyle - } else { - style = tableSelectedStyle - } - } - - b.WriteString(t.renderRow(row, style, isSelected)) - if i < end-1 { - b.WriteString("\n") - } - } - - return b.String() -} - -func (t Table) columnTitles() []string { - titles := make([]string, len(t.columns)) - for i, col := range t.columns { - titles[i] = col.Title - } - return titles -} - -func (t Table) renderRow(cells []string, style lipgloss.Style, pad bool) string { - var parts []string - - for i, col := range t.columns { - cell := "" - if i < len(cells) { - cell = cells[i] - } - - // Truncate if too long - if len(cell) > col.Width { - if col.Width > 3 { - cell = cell[:col.Width-3] + "..." - } else { - cell = cell[:col.Width] - } - } - - // Pad to column width - cell = padRight(cell, col.Width) - parts = append(parts, cell) - } - - content := strings.Join(parts, " ") - - // Apply style and pad to full width if selected - if pad && t.width > 0 { - content = padRight(content, t.width-4) - } - - return style.Render(content) -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index d8b6645..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,115 +0,0 @@ -package tui - -import "github.com/charmbracelet/bubbles/key" - -// KeyMap defines all keyboard shortcuts for the dashboard. -type KeyMap struct { - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Enter key.Binding - Back key.Binding - Tab key.Binding - ShiftTab key.Binding - Refresh key.Binding - Profile key.Binding - Help key.Binding - Quit key.Binding - Search key.Binding - - // View shortcuts - View1 key.Binding - View2 key.Binding - View3 key.Binding - View4 key.Binding - View5 key.Binding -} - -// DefaultKeyMap returns the default key bindings. -func DefaultKeyMap() KeyMap { - return KeyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "left"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "right"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), - Back: key.NewBinding( - key.WithKeys("esc", "backspace"), - key.WithHelp("esc", "back"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "next panel"), - ), - ShiftTab: key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "prev panel"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh"), - ), - Profile: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "switch profile"), - ), - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - Search: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "search"), - ), - View1: key.NewBinding( - key.WithKeys("1"), - key.WithHelp("1", "subscribers"), - ), - View2: key.NewBinding( - key.WithKeys("2"), - key.WithHelp("2", "campaigns"), - ), - View3: key.NewBinding( - key.WithKeys("3"), - key.WithHelp("3", "automations"), - ), - View4: key.NewBinding( - key.WithKeys("4"), - key.WithHelp("4", "groups"), - ), - View5: key.NewBinding( - key.WithKeys("5"), - key.WithHelp("5", "forms"), - ), - } -} - -// HelpBindings returns key bindings formatted for the help overlay. -func (k KeyMap) HelpBindings() [][]key.Binding { - return [][]key.Binding{ - {k.Up, k.Down, k.Enter, k.Back}, - {k.Tab, k.Refresh, k.Profile, k.Help}, - {k.View1, k.View2, k.View3, k.View4, k.View5}, - {k.Quit}, - } -} diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go deleted file mode 100644 index 2d059ea..0000000 --- a/internal/tui/theme/theme.go +++ /dev/null @@ -1,16 +0,0 @@ -package theme - -import "github.com/charmbracelet/lipgloss" - -var ( - Primary = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} - Accent = lipgloss.AdaptiveColor{Light: "25", Dark: "24"} // 256-palette dark blue bg, not remapped by themes - Muted = lipgloss.AdaptiveColor{Light: "245", Dark: "245"} - Text = lipgloss.AdaptiveColor{Light: "0", Dark: "231"} // 256-palette pure white, not remapped - TextSub = lipgloss.AdaptiveColor{Light: "238", Dark: "250"} - Success = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} - Error = lipgloss.AdaptiveColor{Light: "1", Dark: "9"} - Key = lipgloss.AdaptiveColor{Light: "6", Dark: "14"} - BgSelected = lipgloss.AdaptiveColor{Light: "254", Dark: "238"} - BgOverlay = lipgloss.AdaptiveColor{Light: "255", Dark: "237"} -) diff --git a/internal/tui/types/types.go b/internal/tui/types/types.go deleted file mode 100644 index b0e7f37..0000000 --- a/internal/tui/types/types.go +++ /dev/null @@ -1,103 +0,0 @@ -package types - -import "github.com/mailerlite/mailerlite-go" - -// ViewType represents the different views in the dashboard. -type ViewType int - -const ( - ViewSubscribers ViewType = iota - ViewCampaigns - ViewAutomations - ViewGroups - ViewForms -) - -func (v ViewType) String() string { - switch v { - case ViewSubscribers: - return "Subscribers" - case ViewCampaigns: - return "Campaigns" - case ViewAutomations: - return "Automations" - case ViewGroups: - return "Groups" - case ViewForms: - return "Forms" - default: - return "Unknown" - } -} - -// ViewInfo contains display information for a view. -type ViewInfo struct { - Type ViewType - Label string - Icon string -} - -// AllViews returns all available views. -func AllViews() []ViewInfo { - return []ViewInfo{ - {ViewSubscribers, "Subscribers", "◉"}, - {ViewCampaigns, "Campaigns", "◈"}, - {ViewAutomations, "Automations", "◆"}, - {ViewGroups, "Groups", "◇"}, - {ViewForms, "Forms", "◌"}, - } -} - -// Data loading messages - -// SubscribersLoadedMsg is sent when subscribers are fetched. -type SubscribersLoadedMsg struct { - Subscribers []mailerlite.Subscriber - Err error -} - -// CampaignsLoadedMsg is sent when campaigns are fetched. -type CampaignsLoadedMsg struct { - Campaigns []mailerlite.Campaign - Err error -} - -// AutomationsLoadedMsg is sent when automations are fetched. -type AutomationsLoadedMsg struct { - Automations []mailerlite.Automation - Err error -} - -// GroupsLoadedMsg is sent when groups are fetched. -type GroupsLoadedMsg struct { - Groups []mailerlite.Group - Err error -} - -// FormsLoadedMsg is sent when forms are fetched. -type FormsLoadedMsg struct { - Forms []mailerlite.Form - Err error -} - -// Control messages - -// RefreshMsg triggers a refresh of the current view. -type RefreshMsg struct{} - -// ProfileChangedMsg is sent when the profile changes. -type ProfileChangedMsg struct { - Profile string -} - -// ErrorMsg wraps an error for display. -type ErrorMsg struct { - Err error -} - -func (e ErrorMsg) Error() string { - if e.Err != nil { - return e.Err.Error() - } - return "" -} diff --git a/internal/tui/views/automations.go b/internal/tui/views/automations.go deleted file mode 100644 index 7fc9293..0000000 --- a/internal/tui/views/automations.go +++ /dev/null @@ -1,222 +0,0 @@ -package views - -import ( - "context" - "fmt" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-go" -) - -func enabledBadge(enabled bool) string { - if enabled { - return lipgloss.NewStyle().Foreground(theme.Success).Render("yes") - } - return lipgloss.NewStyle().Foreground(theme.Muted).Render("no") -} - -// AutomationsView displays the list of automations. -type AutomationsView struct { - client *mailerlite.Client - - table components.Table - detail components.DetailPanel - automations []mailerlite.Automation - loading bool - err error - width int - height int - focused bool - showingDetail bool -} - -// NewAutomationsView creates a new automations view. -func NewAutomationsView(client *mailerlite.Client) AutomationsView { - columns := []components.Column{ - {Title: "NAME", Width: 30}, - {Title: "ENABLED", Width: 9}, - {Title: "EMAILS", Width: 8}, - {Title: "COMPLETED", Width: 10}, - {Title: "IN QUEUE", Width: 10}, - } - table := components.NewTable(columns) - table.SetEmptyMessage("No automations found.") - - return AutomationsView{ - client: client, - - table: table, - loading: true, - } -} - -// SetSize sets the view dimensions. -func (v *AutomationsView) SetSize(width, height int) { - v.width = width - v.height = height - v.table.SetSize(width, height) -} - -// SetFocused sets whether this view is focused. -func (v *AutomationsView) SetFocused(focused bool) { - v.focused = focused - v.table.SetFocused(focused) -} - -// Loading returns whether the view is loading. -func (v AutomationsView) Loading() bool { - return v.loading -} - -// ItemCount returns the number of items. -func (v AutomationsView) ItemCount() int { - return len(v.automations) -} - -// SelectedAutomation returns the currently selected automation. -func (v AutomationsView) SelectedAutomation() *mailerlite.Automation { - idx := v.table.Cursor() - if idx >= 0 && idx < len(v.automations) { - return &v.automations[idx] - } - return nil -} - -// Fetch returns a command to fetch automations. -func (v AutomationsView) Fetch() tea.Cmd { - return func() tea.Msg { - if v.client == nil { - return types.AutomationsLoadedMsg{Err: nil} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - automations, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Automation, bool, error) { - root, _, err := v.client.Automation.List(ctx, &mailerlite.ListAutomationOptions{ - Page: page, - Limit: perPage, - }) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, root.Links.Next != "", nil - }, 100) - - return types.AutomationsLoadedMsg{ - Automations: automations, - Err: err, - } - } -} - -// Update handles messages for this view. -func (v AutomationsView) Update(msg tea.Msg) (AutomationsView, tea.Cmd) { - switch msg := msg.(type) { - case types.AutomationsLoadedMsg: - v.loading = false - v.err = msg.Err - if msg.Err == nil { - v.automations = msg.Automations - v.updateTable() - } - } - return v, nil -} - -// HandleKey handles key events when this view is active. -func (v *AutomationsView) HandleKey(msg tea.KeyMsg) tea.Cmd { - if v.showingDetail { - switch msg.String() { - case "esc", "backspace", "q": - v.showingDetail = false - } - return nil - } - - switch msg.String() { - case "j", "down": - v.table.MoveDown() - case "k", "up": - v.table.MoveUp() - case "g": - v.table.GotoTop() - case "G": - v.table.GotoBottom() - case "enter": - v.showDetail() - case "r": - v.loading = true - v.table.SetLoading(true) - return v.Fetch() - } - return nil -} - -func (v *AutomationsView) showDetail() { - a := v.SelectedAutomation() - if a == nil { - return - } - - v.detail.SetTitle("Automation: " + a.Name) - - enabled := "No" - if a.Enabled { - enabled = "Yes" - } - - created := a.CreatedAt - if t, err := time.Parse("2006-01-02 15:04:05", a.CreatedAt); err == nil { - created = t.Format("2006-01-02 15:04:05") - } - - v.detail.SetRows([]components.DetailRow{ - {Label: "ID", Value: a.ID}, - {Label: "Name", Value: a.Name}, - {Label: "Enabled", Value: enabled}, - {Label: "Emails", Value: fmt.Sprintf("%d", a.EmailsCount)}, - {Label: "Completed", Value: fmt.Sprintf("%d", a.Stats.CompletedSubscribersCount)}, - {Label: "In Queue", Value: fmt.Sprintf("%d", a.Stats.SubscribersInQueueCount)}, - {Label: "Sent", Value: fmt.Sprintf("%d", a.Stats.Sent)}, - {Label: "Opens", Value: fmt.Sprintf("%d", a.Stats.OpensCount)}, - {Label: "Clicks", Value: fmt.Sprintf("%d", a.Stats.ClicksCount)}, - {Label: "Created", Value: created}, - }) - v.detail.SetSize(v.width, v.height) - v.showingDetail = true -} - -func (v *AutomationsView) updateTable() { - var rows [][]string - for _, a := range v.automations { - rows = append(rows, []string{ - a.Name, - enabledBadge(a.Enabled), - fmt.Sprintf("%d", a.EmailsCount), - fmt.Sprintf("%d", a.Stats.CompletedSubscribersCount), - fmt.Sprintf("%d", a.Stats.SubscribersInQueueCount), - }) - } - v.table.SetRows(rows) - v.table.SetLoading(false) -} - -// View renders the automations view. -func (v AutomationsView) View() string { - if v.showingDetail { - return v.detail.View() - } - return v.table.View() -} - -// ShowingDetail returns whether the detail view is active. -func (v AutomationsView) ShowingDetail() bool { - return v.showingDetail -} diff --git a/internal/tui/views/campaigns.go b/internal/tui/views/campaigns.go deleted file mode 100644 index ba500ba..0000000 --- a/internal/tui/views/campaigns.go +++ /dev/null @@ -1,216 +0,0 @@ -package views - -import ( - "context" - "fmt" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-go" -) - -// CampaignsView displays the list of campaigns. -type CampaignsView struct { - client *mailerlite.Client - - table components.Table - detail components.DetailPanel - campaigns []mailerlite.Campaign - loading bool - err error - width int - height int - focused bool - showingDetail bool -} - -// NewCampaignsView creates a new campaigns view. -func NewCampaignsView(client *mailerlite.Client) CampaignsView { - columns := []components.Column{ - {Title: "NAME", Width: 28}, - {Title: "TYPE", Width: 10}, - {Title: "STATUS", Width: 12}, - {Title: "SENT", Width: 8}, - {Title: "OPENS", Width: 8}, - {Title: "CLICKS", Width: 8}, - } - table := components.NewTable(columns) - table.SetEmptyMessage("No campaigns found.") - - return CampaignsView{ - client: client, - - table: table, - loading: true, - } -} - -// SetSize sets the view dimensions. -func (v *CampaignsView) SetSize(width, height int) { - v.width = width - v.height = height - v.table.SetSize(width, height) -} - -// SetFocused sets whether this view is focused. -func (v *CampaignsView) SetFocused(focused bool) { - v.focused = focused - v.table.SetFocused(focused) -} - -// Loading returns whether the view is loading. -func (v CampaignsView) Loading() bool { - return v.loading -} - -// ItemCount returns the number of items. -func (v CampaignsView) ItemCount() int { - return len(v.campaigns) -} - -// SelectedCampaign returns the currently selected campaign. -func (v CampaignsView) SelectedCampaign() *mailerlite.Campaign { - idx := v.table.Cursor() - if idx >= 0 && idx < len(v.campaigns) { - return &v.campaigns[idx] - } - return nil -} - -// Fetch returns a command to fetch campaigns. -func (v CampaignsView) Fetch() tea.Cmd { - return func() tea.Msg { - if v.client == nil { - return types.CampaignsLoadedMsg{Err: nil} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - campaigns, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Campaign, bool, error) { - root, _, err := v.client.Campaign.List(ctx, &mailerlite.ListCampaignOptions{ - Page: page, - Limit: perPage, - }) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, root.Links.Next != "", nil - }, 100) - - return types.CampaignsLoadedMsg{ - Campaigns: campaigns, - Err: err, - } - } -} - -// Update handles messages for this view. -func (v CampaignsView) Update(msg tea.Msg) (CampaignsView, tea.Cmd) { - switch msg := msg.(type) { - case types.CampaignsLoadedMsg: - v.loading = false - v.err = msg.Err - if msg.Err == nil { - v.campaigns = msg.Campaigns - v.updateTable() - } - } - return v, nil -} - -// HandleKey handles key events when this view is active. -func (v *CampaignsView) HandleKey(msg tea.KeyMsg) tea.Cmd { - if v.showingDetail { - switch msg.String() { - case "esc", "backspace", "q": - v.showingDetail = false - } - return nil - } - - switch msg.String() { - case "j", "down": - v.table.MoveDown() - case "k", "up": - v.table.MoveUp() - case "g": - v.table.GotoTop() - case "G": - v.table.GotoBottom() - case "enter": - v.showDetail() - case "r": - v.loading = true - v.table.SetLoading(true) - return v.Fetch() - } - return nil -} - -func (v *CampaignsView) showDetail() { - c := v.SelectedCampaign() - if c == nil { - return - } - - v.detail.SetTitle("Campaign: " + c.Name) - - created := c.CreatedAt - if t, err := time.Parse("2006-01-02 15:04:05", c.CreatedAt); err == nil { - created = t.Format("2006-01-02 15:04:05") - } - - rows := []components.DetailRow{ - {Label: "ID", Value: c.ID}, - {Label: "Name", Value: c.Name}, - {Label: "Type", Value: c.TypeForHumans}, - {Label: "Status", Value: c.Status}, - {Label: "Sent", Value: fmt.Sprintf("%d", c.Stats.Sent)}, - {Label: "Opens", Value: fmt.Sprintf("%d", c.Stats.OpensCount)}, - {Label: "Clicks", Value: fmt.Sprintf("%d", c.Stats.ClicksCount)}, - {Label: "Open Rate", Value: c.Stats.OpenRate.String}, - {Label: "Click Rate", Value: c.Stats.ClickRate.String}, - {Label: "Created", Value: created}, - } - - if c.ScheduledFor != "" { - rows = append(rows, components.DetailRow{Label: "Scheduled For", Value: c.ScheduledFor}) - } - - v.detail.SetRows(rows) - v.detail.SetSize(v.width, v.height) - v.showingDetail = true -} - -func (v *CampaignsView) updateTable() { - var rows [][]string - for _, c := range v.campaigns { - rows = append(rows, []string{ - c.Name, - c.TypeForHumans, - c.Status, - fmt.Sprintf("%d", c.Stats.Sent), - fmt.Sprintf("%d", c.Stats.OpensCount), - fmt.Sprintf("%d", c.Stats.ClicksCount), - }) - } - v.table.SetRows(rows) - v.table.SetLoading(false) -} - -// View renders the campaigns view. -func (v CampaignsView) View() string { - if v.showingDetail { - return v.detail.View() - } - return v.table.View() -} - -// ShowingDetail returns whether the detail view is active. -func (v CampaignsView) ShowingDetail() bool { - return v.showingDetail -} diff --git a/internal/tui/views/forms.go b/internal/tui/views/forms.go deleted file mode 100644 index c121260..0000000 --- a/internal/tui/views/forms.go +++ /dev/null @@ -1,324 +0,0 @@ -package views - -import ( - "context" - "fmt" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-go" -) - -// FormType represents the type filter for forms. -type FormType int - -const ( - FormTypePopup FormType = iota - FormTypeEmbedded - FormTypePromotion -) - -func (f FormType) String() string { - switch f { - case FormTypePopup: - return "Popup" - case FormTypeEmbedded: - return "Embedded" - case FormTypePromotion: - return "Promotion" - default: - return "Unknown" - } -} - -func (f FormType) APIValue() string { - switch f { - case FormTypePopup: - return "popup" - case FormTypeEmbedded: - return "embedded" - case FormTypePromotion: - return "promotion" - default: - return "popup" - } -} - -var ( - formTabStyle = lipgloss.NewStyle(). - Padding(0, 2) - - activeFormTabStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Primary). - Background(theme.BgSelected). - Padding(0, 2) - - formTabBarStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(theme.Muted). - MarginBottom(1) -) - -// FormsView displays the list of forms. -type FormsView struct { - client *mailerlite.Client - - table components.Table - detail components.DetailPanel - forms []mailerlite.Form - loading bool - err error - width int - height int - focused bool - activeTab FormType - showingDetail bool -} - -// NewFormsView creates a new forms view. -func NewFormsView(client *mailerlite.Client) FormsView { - columns := []components.Column{ - {Title: "NAME", Width: 28}, - {Title: "TYPE", Width: 12}, - {Title: "ACTIVE", Width: 8}, - {Title: "CONVERSIONS", Width: 12}, - {Title: "OPENS", Width: 8}, - } - table := components.NewTable(columns) - table.SetEmptyMessage("No forms found.") - - return FormsView{ - client: client, - - table: table, - loading: true, - activeTab: FormTypePopup, - } -} - -// SetSize sets the view dimensions. -func (v *FormsView) SetSize(width, height int) { - v.width = width - v.height = height - // Reserve space for tab bar - v.table.SetSize(width, height-4) -} - -// SetFocused sets whether this view is focused. -func (v *FormsView) SetFocused(focused bool) { - v.focused = focused - v.table.SetFocused(focused) -} - -// Loading returns whether the view is loading. -func (v FormsView) Loading() bool { - return v.loading -} - -// ItemCount returns the number of items. -func (v FormsView) ItemCount() int { - return len(v.forms) -} - -// SelectedForm returns the currently selected form. -func (v FormsView) SelectedForm() *mailerlite.Form { - idx := v.table.Cursor() - if idx >= 0 && idx < len(v.forms) { - return &v.forms[idx] - } - return nil -} - -// Fetch returns a command to fetch forms. -func (v FormsView) Fetch() tea.Cmd { - return func() tea.Msg { - if v.client == nil { - return types.FormsLoadedMsg{Err: nil} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - forms, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Form, bool, error) { - root, _, err := v.client.Form.List(ctx, &mailerlite.ListFormOptions{ - Type: v.activeTab.APIValue(), - Page: page, - Limit: perPage, - }) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, root.Links.Next != "", nil - }, 100) - - return types.FormsLoadedMsg{ - Forms: forms, - Err: err, - } - } -} - -// Update handles messages for this view. -func (v FormsView) Update(msg tea.Msg) (FormsView, tea.Cmd) { - switch msg := msg.(type) { - case types.FormsLoadedMsg: - v.loading = false - v.err = msg.Err - if msg.Err == nil { - v.forms = msg.Forms - v.updateTable() - } - } - return v, nil -} - -// HandleKey handles key events when this view is active. -func (v *FormsView) HandleKey(msg tea.KeyMsg) tea.Cmd { - if v.showingDetail { - switch msg.String() { - case "esc", "backspace", "q": - v.showingDetail = false - } - return nil - } - - switch msg.String() { - case "j", "down": - v.table.MoveDown() - case "k", "up": - v.table.MoveUp() - case "g": - v.table.GotoTop() - case "G": - v.table.GotoBottom() - case "h", "left": - v.prevTab() - v.loading = true - return v.Fetch() - case "l", "right": - v.nextTab() - v.loading = true - return v.Fetch() - case "enter": - v.showDetail() - case "r": - v.loading = true - v.table.SetLoading(true) - return v.Fetch() - } - return nil -} - -func (v *FormsView) nextTab() { - v.activeTab = (v.activeTab + 1) % 3 -} - -func (v *FormsView) prevTab() { - v.activeTab-- - if v.activeTab < 0 { - v.activeTab = 2 - } -} - -func (v *FormsView) showDetail() { - f := v.SelectedForm() - if f == nil { - return - } - - v.detail.SetTitle("Form: " + f.Name) - - active := "No" - if f.Active { - active = "Yes" - } - - created := f.CreatedAt - if t, err := time.Parse("2006-01-02 15:04:05", f.CreatedAt); err == nil { - created = t.Format("2006-01-02 15:04:05") - } - - v.detail.SetRows([]components.DetailRow{ - {Label: "ID", Value: f.Id}, - {Label: "Name", Value: f.Name}, - {Label: "Type", Value: f.Type}, - {Label: "Active", Value: active}, - {Label: "Conversions", Value: fmt.Sprintf("%d", f.ConversionsCount)}, - {Label: "Conversion Rate", Value: f.ConversionsRate.String}, - {Label: "Opens", Value: fmt.Sprintf("%d", f.OpensCount)}, - {Label: "Created", Value: created}, - }) - v.detail.SetSize(v.width, v.height-4) - v.showingDetail = true -} - -func (v *FormsView) updateTable() { - var rows [][]string - for _, f := range v.forms { - active := checkStyle.Render("yes") - if !f.Active { - active = crossStyle.Render("no") - } - - rows = append(rows, []string{ - f.Name, - f.Type, - active, - fmt.Sprintf("%d", f.ConversionsCount), - fmt.Sprintf("%d", f.OpensCount), - }) - } - v.table.SetRows(rows) - v.table.SetLoading(false) -} - -// View renders the forms view. -func (v FormsView) View() string { - if v.showingDetail { - return v.detail.View() - } - - var b strings.Builder - - // Tab bar - formTypes := []FormType{ - FormTypePopup, - FormTypeEmbedded, - FormTypePromotion, - } - - var tabViews []string - for _, ft := range formTypes { - style := formTabStyle - if ft == v.activeTab { - style = activeFormTabStyle - } - tabViews = append(tabViews, style.Render(ft.String())) - } - - tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabViews...) - b.WriteString(formTabBarStyle.Render(tabBar)) - b.WriteString("\n") - - // Hint for tab navigation - hint := fmt.Sprintf("← → to switch types | %d forms", len(v.forms)) - b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(hint)) - b.WriteString("\n\n") - - // Table - b.WriteString(v.table.View()) - - return b.String() -} - -// ShowingDetail returns whether the detail view is active. -func (v FormsView) ShowingDetail() bool { - return v.showingDetail -} diff --git a/internal/tui/views/groups.go b/internal/tui/views/groups.go deleted file mode 100644 index ba333b3..0000000 --- a/internal/tui/views/groups.go +++ /dev/null @@ -1,216 +0,0 @@ -package views - -import ( - "context" - "fmt" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-go" -) - -// GroupsView displays the list of groups. -type GroupsView struct { - client *mailerlite.Client - - table components.Table - detail components.DetailPanel - groups []mailerlite.Group - loading bool - err error - width int - height int - focused bool - showingDetail bool -} - -// NewGroupsView creates a new groups view. -func NewGroupsView(client *mailerlite.Client) GroupsView { - columns := []components.Column{ - {Title: "NAME", Width: 28}, - {Title: "ACTIVE", Width: 8}, - {Title: "SENT", Width: 8}, - {Title: "OPENS", Width: 8}, - {Title: "CLICK RATE", Width: 12}, - {Title: "CREATED", Width: 12}, - } - table := components.NewTable(columns) - table.SetEmptyMessage("No groups found.") - - return GroupsView{ - client: client, - - table: table, - loading: true, - } -} - -// SetSize sets the view dimensions. -func (v *GroupsView) SetSize(width, height int) { - v.width = width - v.height = height - v.table.SetSize(width, height) -} - -// SetFocused sets whether this view is focused. -func (v *GroupsView) SetFocused(focused bool) { - v.focused = focused - v.table.SetFocused(focused) -} - -// Loading returns whether the view is loading. -func (v GroupsView) Loading() bool { - return v.loading -} - -// ItemCount returns the number of items. -func (v GroupsView) ItemCount() int { - return len(v.groups) -} - -// SelectedGroup returns the currently selected group. -func (v GroupsView) SelectedGroup() *mailerlite.Group { - idx := v.table.Cursor() - if idx >= 0 && idx < len(v.groups) { - return &v.groups[idx] - } - return nil -} - -// Fetch returns a command to fetch groups. -func (v GroupsView) Fetch() tea.Cmd { - return func() tea.Msg { - if v.client == nil { - return types.GroupsLoadedMsg{Err: nil} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - groups, err := sdkclient.FetchAll(ctx, func(ctx context.Context, page, perPage int) ([]mailerlite.Group, bool, error) { - root, _, err := v.client.Group.List(ctx, &mailerlite.ListGroupOptions{ - Page: page, - Limit: perPage, - }) - if err != nil { - return nil, false, sdkclient.WrapError(err) - } - return root.Data, root.Links.Next != "", nil - }, 100) - - return types.GroupsLoadedMsg{ - Groups: groups, - Err: err, - } - } -} - -// Update handles messages for this view. -func (v GroupsView) Update(msg tea.Msg) (GroupsView, tea.Cmd) { - switch msg := msg.(type) { - case types.GroupsLoadedMsg: - v.loading = false - v.err = msg.Err - if msg.Err == nil { - v.groups = msg.Groups - v.updateTable() - } - } - return v, nil -} - -// HandleKey handles key events when this view is active. -func (v *GroupsView) HandleKey(msg tea.KeyMsg) tea.Cmd { - if v.showingDetail { - switch msg.String() { - case "esc", "backspace", "q": - v.showingDetail = false - } - return nil - } - - switch msg.String() { - case "j", "down": - v.table.MoveDown() - case "k", "up": - v.table.MoveUp() - case "g": - v.table.GotoTop() - case "G": - v.table.GotoBottom() - case "enter": - v.showDetail() - case "r": - v.loading = true - v.table.SetLoading(true) - return v.Fetch() - } - return nil -} - -func (v *GroupsView) showDetail() { - g := v.SelectedGroup() - if g == nil { - return - } - - v.detail.SetTitle("Group: " + g.Name) - - created := g.CreatedAt - if t, err := time.Parse("2006-01-02 15:04:05", g.CreatedAt); err == nil { - created = t.Format("2006-01-02 15:04:05") - } - - v.detail.SetRows([]components.DetailRow{ - {Label: "ID", Value: g.ID}, - {Label: "Name", Value: g.Name}, - {Label: "Active", Value: fmt.Sprintf("%d", g.ActiveCount)}, - {Label: "Sent", Value: fmt.Sprintf("%d", g.SentCount)}, - {Label: "Opens", Value: fmt.Sprintf("%d", g.OpensCount)}, - {Label: "Open Rate", Value: g.OpenRate.String}, - {Label: "Clicks", Value: fmt.Sprintf("%d", g.ClicksCount)}, - {Label: "Click Rate", Value: g.ClickRate.String}, - {Label: "Unsubscribed", Value: fmt.Sprintf("%d", g.UnsubscribedCount)}, - {Label: "Bounced", Value: fmt.Sprintf("%d", g.BouncedCount)}, - {Label: "Created", Value: created}, - }) - v.detail.SetSize(v.width, v.height) - v.showingDetail = true -} - -func (v *GroupsView) updateTable() { - var rows [][]string - for _, g := range v.groups { - created := "" - if t, err := time.Parse("2006-01-02 15:04:05", g.CreatedAt); err == nil { - created = t.Format("2006-01-02") - } - - rows = append(rows, []string{ - g.Name, - fmt.Sprintf("%d", g.ActiveCount), - fmt.Sprintf("%d", g.SentCount), - fmt.Sprintf("%d", g.OpensCount), - g.ClickRate.String, - created, - }) - } - v.table.SetRows(rows) - v.table.SetLoading(false) -} - -// View renders the groups view. -func (v GroupsView) View() string { - if v.showingDetail { - return v.detail.View() - } - return v.table.View() -} - -// ShowingDetail returns whether the detail view is active. -func (v GroupsView) ShowingDetail() bool { - return v.showingDetail -} diff --git a/internal/tui/views/subscribers.go b/internal/tui/views/subscribers.go deleted file mode 100644 index 64f4691..0000000 --- a/internal/tui/views/subscribers.go +++ /dev/null @@ -1,238 +0,0 @@ -package views - -import ( - "context" - "fmt" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" - "github.com/mailerlite/mailerlite-cli/internal/tui/components" - "github.com/mailerlite/mailerlite-cli/internal/tui/theme" - "github.com/mailerlite/mailerlite-cli/internal/tui/types" - "github.com/mailerlite/mailerlite-go" -) - -var ( - checkStyle = lipgloss.NewStyle().Foreground(theme.Success) - crossStyle = lipgloss.NewStyle().Foreground(theme.Error) -) - -func statusBadge(status string) string { - switch status { - case "active": - return checkStyle.Render("active") - case "unsubscribed": - return crossStyle.Render("unsub") - case "unconfirmed": - return lipgloss.NewStyle().Foreground(theme.Muted).Render("unconf") - case "bounced": - return crossStyle.Render("bounced") - case "junk": - return crossStyle.Render("junk") - default: - return status - } -} - -// SubscribersView displays the list of subscribers. -type SubscribersView struct { - client *mailerlite.Client - - table components.Table - detail components.DetailPanel - subscribers []mailerlite.Subscriber - loading bool - err error - width int - height int - focused bool - showingDetail bool -} - -// NewSubscribersView creates a new subscribers view. -func NewSubscribersView(client *mailerlite.Client) SubscribersView { - columns := []components.Column{ - {Title: "EMAIL", Width: 30}, - {Title: "STATUS", Width: 10}, - {Title: "SOURCE", Width: 12}, - {Title: "OPENS", Width: 8}, - {Title: "CLICKS", Width: 8}, - {Title: "SUBSCRIBED", Width: 12}, - } - table := components.NewTable(columns) - table.SetEmptyMessage("No subscribers found.") - - return SubscribersView{ - client: client, - - table: table, - loading: true, - } -} - -// SetSize sets the view dimensions. -func (v *SubscribersView) SetSize(width, height int) { - v.width = width - v.height = height - v.table.SetSize(width, height) -} - -// SetFocused sets whether this view is focused. -func (v *SubscribersView) SetFocused(focused bool) { - v.focused = focused - v.table.SetFocused(focused) -} - -// Loading returns whether the view is loading. -func (v SubscribersView) Loading() bool { - return v.loading -} - -// ItemCount returns the number of items. -func (v SubscribersView) ItemCount() int { - return len(v.subscribers) -} - -// SelectedSubscriber returns the currently selected subscriber. -func (v SubscribersView) SelectedSubscriber() *mailerlite.Subscriber { - idx := v.table.Cursor() - if idx >= 0 && idx < len(v.subscribers) { - return &v.subscribers[idx] - } - return nil -} - -// Fetch returns a command to fetch subscribers. -func (v SubscribersView) Fetch() tea.Cmd { - return func() tea.Msg { - if v.client == nil { - return types.SubscribersLoadedMsg{Err: nil} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - subscribers, err := sdkclient.FetchAllStringCursor(ctx, func(ctx context.Context, cursor string, perPage int) ([]mailerlite.Subscriber, string, error) { - root, _, err := v.client.Subscriber.List(ctx, &mailerlite.ListSubscriberOptions{ - Cursor: cursor, - Limit: perPage, - }) - if err != nil { - return nil, "", sdkclient.WrapError(err) - } - return root.Data, root.Meta.NextCursor, nil - }, 100) - - return types.SubscribersLoadedMsg{ - Subscribers: subscribers, - Err: err, - } - } -} - -// Update handles messages for this view. -func (v SubscribersView) Update(msg tea.Msg) (SubscribersView, tea.Cmd) { - switch msg := msg.(type) { - case types.SubscribersLoadedMsg: - v.loading = false - v.err = msg.Err - if msg.Err == nil { - v.subscribers = msg.Subscribers - v.updateTable() - } - } - return v, nil -} - -// HandleKey handles key events when this view is active. -func (v *SubscribersView) HandleKey(msg tea.KeyMsg) tea.Cmd { - if v.showingDetail { - switch msg.String() { - case "esc", "backspace", "q": - v.showingDetail = false - } - return nil - } - - switch msg.String() { - case "j", "down": - v.table.MoveDown() - case "k", "up": - v.table.MoveUp() - case "g": - v.table.GotoTop() - case "G": - v.table.GotoBottom() - case "enter": - v.showDetail() - case "r": - v.loading = true - v.table.SetLoading(true) - return v.Fetch() - } - return nil -} - -func (v *SubscribersView) showDetail() { - sub := v.SelectedSubscriber() - if sub == nil { - return - } - - v.detail.SetTitle("Subscriber: " + sub.Email) - - subscribed := sub.SubscribedAt - if t, err := time.Parse("2006-01-02 15:04:05", sub.SubscribedAt); err == nil { - subscribed = t.Format("2006-01-02 15:04:05") - } - - v.detail.SetRows([]components.DetailRow{ - {Label: "ID", Value: sub.ID}, - {Label: "Email", Value: sub.Email}, - {Label: "Status", Value: sub.Status}, - {Label: "Source", Value: sub.Source}, - {Label: "Opens", Value: fmt.Sprintf("%d", sub.OpensCount)}, - {Label: "Clicks", Value: fmt.Sprintf("%d", sub.ClicksCount)}, - {Label: "Open Rate", Value: fmt.Sprintf("%.1f%%", sub.OpenRate)}, - {Label: "Click Rate", Value: fmt.Sprintf("%.1f%%", sub.ClickRate)}, - {Label: "Subscribed", Value: subscribed}, - }) - v.detail.SetSize(v.width, v.height) - v.showingDetail = true -} - -func (v *SubscribersView) updateTable() { - var rows [][]string - for _, s := range v.subscribers { - subscribed := "" - if t, err := time.Parse("2006-01-02 15:04:05", s.SubscribedAt); err == nil { - subscribed = t.Format("2006-01-02") - } - - rows = append(rows, []string{ - s.Email, - statusBadge(s.Status), - s.Source, - fmt.Sprintf("%d", s.OpensCount), - fmt.Sprintf("%d", s.ClicksCount), - subscribed, - }) - } - v.table.SetRows(rows) - v.table.SetLoading(false) -} - -// View renders the subscribers view. -func (v SubscribersView) View() string { - if v.showingDetail { - return v.detail.View() - } - return v.table.View() -} - -// ShowingDetail returns whether the detail view is active. -func (v SubscribersView) ShowingDetail() bool { - return v.showingDetail -} diff --git a/lefthook.yml b/lefthook.yml index 71f4869..7d514e5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,8 +1,9 @@ pre-commit: commands: + fmt: + glob: "*.rs" + run: cargo fmt + stage_fixed: true lint: - glob: "*.go" - run: golangci-lint run --fix - build: - glob: "*.go" - run: go build ./... + glob: "*.rs" + run: cargo clippy --all-targets -- -D warnings diff --git a/main.go b/main.go deleted file mode 100644 index a0a2120..0000000 --- a/main.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "errors" - "os" - - "github.com/mailerlite/mailerlite-cli/cmd" - "github.com/mailerlite/mailerlite-cli/internal/output" - "github.com/mailerlite/mailerlite-cli/internal/sdkclient" -) - -func main() { - if err := cmd.Execute(); err != nil { - var cliErr *sdkclient.CLIError - if errors.As(err, &cliErr) && cmd.IsJSON() && len(cliErr.RawBody) > 0 { - _ = output.JSON(cliErr.RawBody) - } else { - output.Error(err.Error()) - } - os.Exit(1) - } -} diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh deleted file mode 100755 index e8e736a..0000000 --- a/scripts/sign-macos.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -set -euo pipefail - -OS="$1" -BINARY="$2" -IS_SNAPSHOT="$3" - -if [ "$OS" != "darwin" ]; then - echo "skipping signing for $OS" - exit 0 -fi - -quill sign-and-notarize "$BINARY" --dry-run="$IS_SNAPSHOT" --ad-hoc="$IS_SNAPSHOT" diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..ba975ad --- /dev/null +++ b/src/api.rs @@ -0,0 +1,372 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::io::Read; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde_json::Value; + +pub const DEFAULT_BASE_URL: &str = "https://connect.mailerlite.com/api"; +const MAX_RETRIES: u32 = 3; +const PER_PAGE: u64 = 25; + +/// An API error with full field-level validation details. +#[derive(Debug, Default)] +pub struct ApiError { + pub status: u16, + pub message: String, + pub errors: BTreeMap>, + pub raw_body: Option, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "API error {}: {}", self.status, self.message)?; + for (field, msgs) in &self.errors { + for msg in msgs { + write!(f, "\n {field}: {msg}")?; + } + } + Ok(()) + } +} + +impl std::error::Error for ApiError {} + +impl ApiError { + fn from_body(status: u16, body: &[u8]) -> Self { + let mut err = ApiError { + status, + ..ApiError::default() + }; + if !body.is_empty() { + if let Ok(parsed) = serde_json::from_slice::(body) { + if let Some(msg) = parsed.get("message").and_then(Value::as_str) { + err.message = msg.to_string(); + } + if let Some(fields) = parsed.get("errors").and_then(Value::as_object) { + for (field, msgs) in fields { + let msgs: Vec = msgs + .as_array() + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + if !msgs.is_empty() { + err.errors.insert(field.clone(), msgs); + } + } + } + err.raw_body = Some(parsed); + } + } + if err.message.is_empty() { + err.message = format!("HTTP {status}"); + } + err + } +} + +/// MailerLite API client with retry, rate-limit handling, verbose logging, +/// account-context header, and base URL override. +#[derive(Clone)] +pub struct Client { + agent: ureq::Agent, + token: String, + account_id: Option, + base_url: String, + verbose: bool, + user_agent: String, +} + +impl Client { + pub fn new(token: String, account_id: Option, verbose: bool) -> Self { + let base_url = std::env::var("MAILERLITE_API_BASE_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + Client { + agent: ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(), + token, + account_id, + base_url, + verbose, + user_agent: format!("mailerlite-cli/{}", crate::cli::VERSION), + } + } + + /// Performs a request against the API. `path` starts with `/` (e.g. + /// `/subscribers`). Returns the parsed JSON body (`Value::Null` for 204 / + /// empty bodies). API errors surface as [`ApiError`] via `anyhow`. + pub fn request( + &self, + method: &str, + path: &str, + query: &[(&str, String)], + body: Option<&Value>, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let body_bytes = body + .map(serde_json::to_vec) + .transpose() + .context("failed to marshal request body")?; + + if self.verbose { + let qs = if query.is_empty() { + String::new() + } else { + let mut s = String::from("?"); + for (i, (k, v)) in query.iter().enumerate() { + if i > 0 { + s.push('&'); + } + s.push_str(k); + s.push('='); + s.push_str(v); + } + s + }; + println!("--> {method} {url}{qs}"); + if let Some(acc) = &self.account_id { + println!("--> X-Acc-Id: {acc}"); + } + if let Some(b) = &body_bytes { + println!("--> body: {}", String::from_utf8_lossy(b)); + } + } + + let mut last_transport_err: Option = None; + + for attempt in 0..=MAX_RETRIES { + let mut req = self + .agent + .request(method, &url) + .set("Authorization", &format!("Bearer {}", self.token)) + .set("User-Agent", &self.user_agent) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + if let Some(acc) = &self.account_id { + req = req.set("X-Acc-Id", acc); + } + for (k, v) in query { + req = req.query(k, v); + } + + let result = match &body_bytes { + Some(b) => req.send_bytes(b), + None => req.call(), + }; + + let (status, resp) = match result { + Ok(resp) => (resp.status(), resp), + Err(ureq::Error::Status(code, resp)) => (code, resp), + Err(ureq::Error::Transport(t)) => { + if self.verbose { + println!("<-- error: {t}"); + } + last_transport_err = Some(t); + if attempt < MAX_RETRIES { + std::thread::sleep(Duration::from_secs(1 << attempt)); + continue; + } + break; + } + }; + + if self.verbose { + println!("<-- {status} {}", resp.status_text()); + } + + let retry_after = resp + .header("Retry-After") + .and_then(|v| v.parse::().ok()); + let mut resp_body = Vec::new(); + resp.into_reader() + .take(10 << 20) + .read_to_end(&mut resp_body) + .context("failed to read response body")?; + + if self.verbose && !resp_body.is_empty() { + println!("<-- body: {}", String::from_utf8_lossy(&resp_body)); + } + + if status >= 400 { + if (status == 429 || status >= 500) && attempt < MAX_RETRIES { + let wait = retry_after.unwrap_or(1 << attempt); + if self.verbose { + println!(" retrying in {wait}s..."); + } + std::thread::sleep(Duration::from_secs(wait)); + continue; + } + return Err(ApiError::from_body(status, &resp_body).into()); + } + + if status == 204 || resp_body.is_empty() { + return Ok(Value::Null); + } + return serde_json::from_slice(&resp_body).context("failed to decode response"); + } + + Err(anyhow::anyhow!( + "request failed after {MAX_RETRIES} retries: {}", + last_transport_err.expect("transport error recorded") + )) + } + + pub fn get(&self, path: &str) -> Result { + self.request("GET", path, &[], None) + } + + pub fn get_with(&self, path: &str, query: &[(&str, String)]) -> Result { + self.request("GET", path, query, None) + } + + pub fn post(&self, path: &str, body: &Value) -> Result { + self.request("POST", path, &[], Some(body)) + } + + pub fn put(&self, path: &str, body: &Value) -> Result { + self.request("PUT", path, &[], Some(body)) + } + + pub fn delete(&self, path: &str) -> Result { + self.request("DELETE", path, &[], None) + } +} + +fn per_page(limit: u64) -> u64 { + if limit > 0 && limit < PER_PAGE { + limit + } else { + PER_PAGE + } +} + +fn data_items(body: &Value) -> Vec { + body.get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn push_items(all: &mut Vec, items: Vec, limit: u64) -> bool { + for item in items { + all.push(item); + if limit > 0 && all.len() as u64 >= limit { + return true; + } + } + false +} + +/// Fetches all pages of a page-numbered endpoint (`?page=N&limit=M`), +/// following `meta.last_page` when present. `limit == 0` fetches everything. +pub fn fetch_all_paged( + client: &Client, + path: &str, + query: &[(&str, String)], + limit: u64, +) -> Result> { + let per = per_page(limit); + let mut all = Vec::new(); + let mut page: u64 = 1; + loop { + let mut q: Vec<(&str, String)> = query.to_vec(); + q.push(("page", page.to_string())); + q.push(("limit", per.to_string())); + let body = client.get_with(path, &q)?; + let items = data_items(&body); + let got = items.len() as u64; + if push_items(&mut all, items, limit) { + return Ok(all); + } + let last_page = body + .get("meta") + .and_then(|m| m.get("last_page")) + .and_then(Value::as_u64); + let done = match last_page { + Some(last) => page >= last, + None => got < per, + }; + if done || got == 0 { + return Ok(all); + } + page += 1; + } +} + +/// Fetches all pages of a string-cursor endpoint (`?cursor=…&limit=M`), +/// following `meta.next_cursor`. Used for subscriber pagination. +pub fn fetch_all_cursor( + client: &Client, + path: &str, + query: &[(&str, String)], + limit: u64, +) -> Result> { + let per = per_page(limit); + let mut all = Vec::new(); + let mut cursor = String::new(); + loop { + let mut q: Vec<(&str, String)> = query.to_vec(); + if !cursor.is_empty() { + q.push(("cursor", cursor.clone())); + } + q.push(("limit", per.to_string())); + let body = client.get_with(path, &q)?; + let items = data_items(&body); + let got = items.len() as u64; + if push_items(&mut all, items, limit) { + return Ok(all); + } + cursor = body + .get("meta") + .and_then(|m| m.get("next_cursor")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if cursor.is_empty() || got == 0 { + return Ok(all); + } + } +} + +/// Fetches all pages of an integer-cursor endpoint (`?after=N&limit=M`), +/// following `meta.last`. Used for segment subscriber pagination. +pub fn fetch_all_after( + client: &Client, + path: &str, + query: &[(&str, String)], + limit: u64, +) -> Result> { + let per = per_page(limit); + let mut all = Vec::new(); + let mut after: u64 = 0; + loop { + let mut q: Vec<(&str, String)> = query.to_vec(); + if after > 0 { + q.push(("after", after.to_string())); + } + q.push(("limit", per.to_string())); + let body = client.get_with(path, &q)?; + let items = data_items(&body); + let got = items.len() as u64; + if push_items(&mut all, items, limit) { + return Ok(all); + } + let next = body + .get("meta") + .and_then(|m| m.get("last")) + .and_then(Value::as_u64) + .unwrap_or(0); + if next == 0 || next == after || got == 0 || got < per { + return Ok(all); + } + after = next; + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..02b2b2b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,194 @@ +use anyhow::Result; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; + +use crate::api::Client; +use crate::commands; +use crate::config; + +// cargo-dist requires the git tag to match Cargo.toml's version, so the +// crate version is authoritative. Commit and date are stamped by build.rs. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +const COMMIT: &str = match option_env!("MAILERLITE_COMMIT") { + Some(c) => c, + None => "none", +}; +const BUILD_DATE: &str = match option_env!("MAILERLITE_BUILD_DATE") { + Some(d) => d, + None => "unknown", +}; + +/// Global flags shared by every command. +pub struct Ctx { + pub profile: Option, + pub verbose: bool, + pub json: bool, + pub yes: bool, +} + +impl Ctx { + /// Builds an authenticated API client for the selected profile. + pub fn client(&self) -> Result { + let token = config::get_token(self.profile.as_deref())?; + let account_id = config::get_account_id(self.profile.as_deref()); + Ok(Client::new(token, account_id, self.verbose)) + } + + /// Confirmation gate: true when --yes was passed or stdin is not a + /// terminal (parity with the Go CLI); otherwise prompts interactively. + pub fn confirm(&self, message: &str) -> Result { + if self.yes || !crate::prompt::is_interactive() { + return Ok(true); + } + crate::prompt::confirm(message) + } +} + +#[derive(Parser)] +#[command( + name = "mailerlite", + version = VERSION, + about = "MailerLite CLI — manage your email marketing from the terminal", + long_about = "A command-line interface for the MailerLite API. Manage subscribers, campaigns, automations, groups, forms, and more." +)] +struct Root { + /// config profile to use + #[arg(long, global = true)] + profile: Option, + + /// show HTTP request/response details + #[arg(long, short = 'v', global = true)] + verbose: bool, + + /// output as JSON + #[arg(long, global = true)] + json: bool, + + /// skip confirmation prompts + #[arg(long, short = 'y', global = true)] + yes: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Launch the interactive TUI dashboard + Dashboard, + /// Manage subscribers + Subscriber(commands::subscriber::Cmd), + /// Manage groups + Group(commands::group::Cmd), + /// Manage campaigns + Campaign(commands::campaign::Cmd), + /// Manage automations + Automation(commands::automation::Cmd), + /// Manage forms + Form(commands::form::Cmd), + /// Manage subscriber fields + Field(commands::field::Cmd), + /// Manage segments + Segment(commands::segment::Cmd), + /// Manage webhooks + Webhook(commands::webhook::Cmd), + /// List available timezones + Timezone(commands::timezone::Cmd), + /// Manage e-commerce shops + Shop(commands::shop::Cmd), + /// Manage e-commerce products + Product(commands::product::Cmd), + /// Manage e-commerce categories + Category(commands::category::Cmd), + /// Manage e-commerce customers + Customer(commands::customer::Cmd), + /// Manage e-commerce orders + Order(commands::order::Cmd), + /// Manage e-commerce carts + Cart(commands::cart::Cmd), + /// Manage e-commerce cart items + #[command(name = "cart-item")] + Cartitem(commands::cartitem::Cmd), + /// Import e-commerce resources + #[command(name = "import")] + Import(commands::import_cmd::Cmd), + /// Manage accounts + Account(commands::account::Cmd), + /// Authenticate with MailerLite + Auth(commands::auth::Cmd), + /// Manage config profiles + Profile(commands::profile::Cmd), + /// Generate shell completion scripts + Completion { + /// shell to generate completions for + #[arg(value_enum)] + shell: Shell, + }, + /// Print the version of mailerlite + Version, +} + +pub fn run() -> i32 { + let root = Root::parse(); + let ctx = Ctx { + profile: root.profile, + verbose: root.verbose, + json: root.json, + yes: root.yes, + }; + + let Some(command) = root.command else { + Root::command().print_help().ok(); + return 0; + }; + + let result = match command { + Command::Dashboard => crate::tui::run(&ctx), + Command::Subscriber(cmd) => commands::subscriber::run(&ctx, cmd), + Command::Group(cmd) => commands::group::run(&ctx, cmd), + Command::Campaign(cmd) => commands::campaign::run(&ctx, cmd), + Command::Automation(cmd) => commands::automation::run(&ctx, cmd), + Command::Form(cmd) => commands::form::run(&ctx, cmd), + Command::Field(cmd) => commands::field::run(&ctx, cmd), + Command::Segment(cmd) => commands::segment::run(&ctx, cmd), + Command::Webhook(cmd) => commands::webhook::run(&ctx, cmd), + Command::Timezone(cmd) => commands::timezone::run(&ctx, cmd), + Command::Shop(cmd) => commands::shop::run(&ctx, cmd), + Command::Product(cmd) => commands::product::run(&ctx, cmd), + Command::Category(cmd) => commands::category::run(&ctx, cmd), + Command::Customer(cmd) => commands::customer::run(&ctx, cmd), + Command::Order(cmd) => commands::order::run(&ctx, cmd), + Command::Cart(cmd) => commands::cart::run(&ctx, cmd), + Command::Cartitem(cmd) => commands::cartitem::run(&ctx, cmd), + Command::Import(cmd) => commands::import_cmd::run(&ctx, cmd), + Command::Account(cmd) => commands::account::run(&ctx, cmd), + Command::Auth(cmd) => commands::auth::run(&ctx, cmd), + Command::Profile(cmd) => commands::profile::run(&ctx, cmd), + Command::Completion { shell } => { + let mut cmd = Root::command(); + clap_complete::generate(shell, &mut cmd, "mailerlite", &mut std::io::stdout()); + Ok(()) + } + Command::Version => { + println!("mailerlite v{VERSION} ({COMMIT}) built {BUILD_DATE}"); + Ok(()) + } + }; + + match result { + Ok(()) => 0, + Err(err) => { + // In --json mode, surface the raw API error body when available. + if ctx.json { + if let Some(api_err) = err.downcast_ref::() { + if let Some(raw) = &api_err.raw_body { + let _ = crate::output::json(raw); + return 1; + } + } + } + crate::output::error(&format!("{err}")); + 1 + } + } +} diff --git a/src/commands/account.rs b/src/commands/account.rs new file mode 100644 index 0000000..4f58bb7 --- /dev/null +++ b/src/commands/account.rs @@ -0,0 +1,126 @@ +use anyhow::{anyhow, bail, Result}; +use clap::{Args, Subcommand}; +use serde_json::Value; + +use crate::cli::Ctx; +use crate::config; +use crate::output; +use crate::prompt; +use crate::util::jstr; + +#[derive(Args)] +#[command( + long_about = "List and switch between MailerLite accounts (requires OAuth authentication)." +)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List accounts you have access to + List, + /// Switch active account + #[command( + long_about = "Switch to a different MailerLite account. If no account ID is provided, shows an interactive picker." + )] + Switch { + /// account ID + account_id: Option, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List => list(ctx), + Sub::Switch { account_id } => switch(ctx, account_id), + } +} + +fn fetch_accounts(ctx: &Ctx) -> Result> { + let client = ctx.client()?; + let body = client + .get("/accounts") + .map_err(|e| anyhow!("failed to fetch accounts: {e}"))?; + Ok(body + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) +} + +fn list(ctx: &Ctx) -> Result<()> { + let accounts = fetch_accounts(ctx)?; + + if ctx.json { + return output::json(&accounts); + } + + let active_id = config::get_account_id(ctx.profile.as_deref()).unwrap_or_default(); + let rows: Vec> = accounts + .iter() + .map(|a| { + let mut name = jstr(a, "name"); + if !active_id.is_empty() && jstr(a, "id") == active_id { + name.push_str(" (active)"); + } + vec![jstr(a, "id"), name, jstr(a, "status")] + }) + .collect(); + + output::table(&["ID", "Name", "Status"], &rows); + Ok(()) +} + +fn switch(ctx: &Ctx, account_arg: Option) -> Result<()> { + let accounts = fetch_accounts(ctx)?; + + let account_id = if let Some(id) = account_arg { + if !accounts.iter().any(|a| jstr(a, "id") == id) { + bail!("account {id:?} not found; use 'account list' to see available accounts"); + } + id + } else if accounts.is_empty() { + bail!("no accounts found"); + } else if accounts.len() == 1 { + let id = jstr(&accounts[0], "id"); + output::success(&format!( + "Only one account available: {} ({})", + jstr(&accounts[0], "name"), + id + )); + id + } else if !prompt::is_interactive() { + bail!("account ID argument is required in non-interactive mode"); + } else { + let active_id = config::get_account_id(ctx.profile.as_deref()).unwrap_or_default(); + let labels = accounts + .iter() + .map(|a| { + let mut label = format!("{} ({})", jstr(a, "name"), jstr(a, "id")); + if !active_id.is_empty() && jstr(a, "id") == active_id { + label.push_str(" [active]"); + } + label + }) + .collect(); + let values = accounts.iter().map(|a| jstr(a, "id")).collect(); + prompt::select_labeled("Select account", labels, values)? + }; + + let mut cfg = config::load()?; + let prof_name = match ctx.profile.clone().filter(|s| !s.is_empty()) { + Some(n) => n, + None => config::active_profile(&cfg)?.0, + }; + + let Some(prof) = cfg.profiles.get_mut(&prof_name) else { + bail!("profile {prof_name:?} not found"); + }; + prof.account_id = Some(account_id.clone()); + config::save(&cfg)?; + + output::success(&format!("Switched to account: {account_id}")); + Ok(()) +} diff --git a/src/commands/auth.rs b/src/commands/auth.rs new file mode 100644 index 0000000..f6bb9cc --- /dev/null +++ b/src/commands/auth.rs @@ -0,0 +1,461 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context, Result}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use clap::{Args, Subcommand}; +use rand::RngCore; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::api; +use crate::cli::Ctx; +use crate::config; +use crate::output; +use crate::prompt; +use crate::util::{jint, jstr}; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// Log in to MailerLite + #[command(long_about = "Authenticate via API token or OAuth browser flow.")] + Login { + /// auth method: token or oauth + #[arg(long, default_value = "")] + method: String, + /// API token (for token method) + #[arg(long, default_value = "")] + token: String, + }, + /// Log out and remove stored credentials + Logout, + /// Show current authentication status + Status, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::Login { method, token } => login(ctx, method, token), + Sub::Logout => logout(ctx), + Sub::Status => status(ctx), + } +} + +fn login(ctx: &Ctx, mut method: String, mut token: String) -> Result<()> { + if method.is_empty() && prompt::is_interactive() { + method = prompt::select_labeled( + "Authentication method", + vec![ + "OAuth (Recommended)".to_string(), + "API Token (less secure)".to_string(), + ], + vec!["oauth".to_string(), "token".to_string()], + )?; + } + if method.is_empty() { + method = "oauth".to_string(); + } + + let prof_name = ctx + .profile + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "default".to_string()); + + let mut cfg = config::load()?; + + match method.as_str() { + "token" => { + if token.is_empty() { + if !prompt::is_interactive() { + bail!("--token is required in non-interactive mode"); + } + token = prompt::input("API Token", "")?; + } + if token.is_empty() { + bail!("token cannot be empty"); + } + cfg.profiles.insert( + prof_name.clone(), + config::Profile { + api_token: Some(token), + ..Default::default() + }, + ); + } + "oauth" => { + let prof = oauth_browser_flow().map_err(|e| anyhow!("OAuth login failed: {e:#}"))?; + cfg.profiles.insert(prof_name.clone(), prof); + } + other => bail!("unknown auth method: {other} (use 'token' or 'oauth')"), + } + + cfg.active_profile = prof_name.clone(); + config::save(&cfg)?; + + output::success(&format!("Logged in successfully. Profile: {prof_name}")); + + if method == "oauth" { + if let Err(e) = select_account(&mut cfg, &prof_name) { + println!("Warning: could not set account: {e:#}"); + } + } + + Ok(()) +} + +fn logout(ctx: &Ctx) -> Result<()> { + let mut cfg = config::load()?; + + let name = ctx + .profile + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + if cfg.active_profile.is_empty() { + "default".to_string() + } else { + cfg.active_profile.clone() + } + }); + + if !cfg.profiles.contains_key(&name) { + bail!("profile {name:?} not found"); + } + + cfg.profiles.remove(&name); + if cfg.active_profile == name { + cfg.active_profile = cfg.profiles.keys().next().cloned().unwrap_or_default(); + } + + config::save(&cfg)?; + + output::success(&format!("Logged out from profile: {name}")); + Ok(()) +} + +fn status(ctx: &Ctx) -> Result<()> { + let cfg = config::load()?; + + let name = match ctx.profile.clone().filter(|s| !s.is_empty()) { + Some(n) => n, + None => match config::active_profile(&cfg) { + Ok((n, _)) => n, + Err(_) => { + output::error("Not logged in. Run 'mailerlite auth login' to authenticate."); + return Ok(()); + } + }, + }; + + let Some(prof) = cfg.profiles.get(&name) else { + output::error(&format!("Profile {name:?} not found.")); + return Ok(()); + }; + + let has_token = prof.api_token.as_deref().is_some_and(|t| !t.is_empty()); + let has_oauth = prof.oauth_token.as_deref().is_some_and(|t| !t.is_empty()); + + if ctx.json { + return output::json(&json!({ + "profile": name, + "has_token": has_token, + "has_oauth": has_oauth, + "expires_at": prof.oauth_expires_at.clone().unwrap_or_default(), + "account_id": prof.account_id.clone().unwrap_or_default(), + })); + } + + let method = if has_oauth { "OAuth" } else { "API Token" }; + let masked_token = match prof.api_token.as_deref() { + None | Some("") => "none".to_string(), + Some(t) if t.len() > 10 => match (t.get(..7), t.get(t.len() - 4..)) { + (Some(head), Some(tail)) => format!("{head}...{tail}"), + _ => "***".to_string(), + }, + Some(_) => "***".to_string(), + }; + + let mut rows = vec![ + vec!["Profile".to_string(), name.clone()], + vec!["Method".to_string(), method.to_string()], + vec!["Token".to_string(), masked_token], + vec!["Active".to_string(), "Yes".to_string()], + ]; + + if let Some(expires) = prof.oauth_expires_at.as_deref().filter(|s| !s.is_empty()) { + rows.push(vec!["Expires".to_string(), expires.to_string()]); + } + if let Some(account_id) = prof.account_id.as_deref().filter(|s| !s.is_empty()) { + rows.push(vec!["Account ID".to_string(), account_id.to_string()]); + } + + output::table(&["Field", "Value"], &rows); + Ok(()) +} + +/// Full OAuth 2.0 Authorization Code flow with PKCE: starts a local HTTP +/// server, opens the browser to the authorize URL, captures the authorization +/// code, and exchanges it for access/refresh tokens. +fn oauth_browser_flow() -> Result { + let state = random_hex(16); + let (verifier, challenge) = generate_pkce(); + + const CALLBACK_PORT: &str = "19821"; + let callback_url = format!("http://127.0.0.1:{CALLBACK_PORT}/callback"); + + let server = tiny_http::Server::http(format!("127.0.0.1:{CALLBACK_PORT}")) + .map_err(|e| anyhow!("failed to start local server on port {CALLBACK_PORT}: {e}"))?; + + let auth_url = format!( + "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256", + config::OAUTH_AUTHORIZE_URL, + config::OAUTH_CLIENT_ID, + query_escape(&callback_url), + query_escape(config::OAUTH_SCOPES), + state, + challenge, + ); + + println!("Opening browser for authentication..."); + println!("If the browser doesn't open, visit:\n{auth_url}\n"); + let _ = webbrowser::open(&auth_url); + + let html_header = || { + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) + .expect("static header") + }; + + let deadline = Instant::now() + Duration::from_secs(5 * 60); + let code = loop { + let Some(remaining) = deadline + .checked_duration_since(Instant::now()) + .filter(|d| !d.is_zero()) + else { + bail!("authentication timed out after 5 minutes"); + }; + let Some(request) = server.recv_timeout(remaining)? else { + bail!("authentication timed out after 5 minutes"); + }; + + let url = request.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((url.as_str(), "")); + if path != "/callback" { + let _ = request.respond( + tiny_http::Response::from_string("404 page not found\n").with_status_code(404), + ); + continue; + } + + let params = parse_query(query); + if params.get("state").map(String::as_str) != Some(state.as_str()) { + let _ = request.respond( + tiny_http::Response::from_string("State mismatch\n").with_status_code(400), + ); + bail!("state mismatch"); + } + + match params.get("code").filter(|c| !c.is_empty()) { + Some(code) => { + let code = code.clone(); + let _ = request.respond( + tiny_http::Response::from_string( + "

Authentication successful!

You can close this window.

", + ) + .with_header(html_header()), + ); + break code; + } + None => { + let err_msg = params.get("error").cloned().unwrap_or_default(); + let _ = request.respond( + tiny_http::Response::from_string(format!( + "

Authentication failed

{err_msg}

" + )) + .with_header(html_header()), + ); + bail!("OAuth error: {err_msg}"); + } + } + }; + + exchange_code_for_tokens(&code, &callback_url, &verifier) +} + +/// POSTs to the token endpoint with the authorization code and PKCE verifier +/// to obtain access and refresh tokens. +fn exchange_code_for_tokens( + code: &str, + redirect_uri: &str, + code_verifier: &str, +) -> Result { + let resp = ureq::post(config::OAUTH_TOKEN_URL).send_form(&[ + ("grant_type", "authorization_code"), + ("client_id", config::OAUTH_CLIENT_ID), + ("redirect_uri", redirect_uri), + ("code", code), + ("code_verifier", code_verifier), + ]); + + let resp = match resp { + Ok(r) => r, + Err(ureq::Error::Status(status, r)) => { + let body: Value = r.into_json().unwrap_or(Value::Null); + bail!("token exchange failed (HTTP {status}): {body}"); + } + Err(e) => bail!("token exchange request failed: {e}"), + }; + + let tok: Value = resp.into_json().context("failed to parse token response")?; + + let access_token = jstr(&tok, "access_token"); + if access_token.is_empty() { + bail!("server returned empty access token"); + } + + let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(jint(&tok, "expires_in"))) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + + Ok(config::Profile { + oauth_token: Some(access_token), + oauth_refresh_token: Some(jstr(&tok, "refresh_token")).filter(|s| !s.is_empty()), + oauth_expires_at: Some(expires_at), + ..Default::default() + }) +} + +/// Creates a PKCE code verifier and its S256 challenge. +fn generate_pkce() -> (String, String) { + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let verifier = URL_SAFE_NO_PAD.encode(buf); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +fn random_hex(n: usize) -> String { + let mut buf = vec![0u8; n]; + rand::thread_rng().fill_bytes(&mut buf); + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn query_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn query_unescape(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 3 <= bytes.len() => { + match std::str::from_utf8(&bytes[i + 1..i + 3]) + .ok() + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + Some(v) => { + out.push(v); + i += 3; + } + None => { + out.push(b'%'); + i += 1; + } + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn parse_query(query: &str) -> HashMap { + query + .split('&') + .filter(|p| !p.is_empty()) + .map(|p| { + let (k, v) = p.split_once('=').unwrap_or((p, "")); + (query_unescape(k), query_unescape(v)) + }) + .collect() +} + +/// Fetches the user's accounts and prompts to select one. A single account is +/// selected automatically. +fn select_account(cfg: &mut config::Config, prof_name: &str) -> Result<()> { + let Some(prof) = cfg.profiles.get(prof_name).cloned() else { + return Ok(()); + }; + let token = match prof.oauth_token.as_deref() { + Some(t) if !t.is_empty() => t.to_string(), + _ => return Ok(()), + }; + + let resp = ureq::get(&format!("{}/accounts", api::DEFAULT_BASE_URL)) + .set("Authorization", &format!("Bearer {token}")) + .set("Accept", "application/json") + .call() + .map_err(|e| match e { + ureq::Error::Status(status, _) => anyhow!("failed to fetch accounts (HTTP {status})"), + other => anyhow!("failed to fetch accounts: {other}"), + })?; + + let body: Value = resp.into_json().context("failed to parse accounts")?; + let accounts = body + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if accounts.is_empty() { + return Ok(()); + } + + let account_id = if accounts.len() == 1 { + let id = jstr(&accounts[0], "id"); + output::success(&format!( + "Account selected: {} ({})", + jstr(&accounts[0], "name"), + id + )); + id + } else if !prompt::is_interactive() { + jstr(&accounts[0], "id") + } else { + let labels = accounts + .iter() + .map(|a| format!("{} ({})", jstr(a, "name"), jstr(a, "id"))) + .collect(); + let values = accounts.iter().map(|a| jstr(a, "id")).collect(); + prompt::select_labeled("Select account", labels, values)? + }; + + let mut prof = prof; + prof.account_id = Some(account_id); + cfg.profiles.insert(prof_name.to_string(), prof); + config::save(cfg) +} diff --git a/src/commands/automation.rs b/src/commands/automation.rs new file mode 100644 index 0000000..0823402 --- /dev/null +++ b/src/commands/automation.rs @@ -0,0 +1,168 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::Value; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::util::{jint, jstr}; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List automations + List { + /// maximum number of automations to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// filter by enabled status (true, false) + #[arg(long, default_value = "")] + enabled: String, + }, + /// Get automation details + #[command(arg_required_else_help = true)] + Get { + /// automation ID + automation_id: String, + }, + /// List automation subscriber activity + #[command(arg_required_else_help = true)] + Subscribers { + /// automation ID + automation_id: String, + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit, enabled } => list(ctx, limit, &enabled), + Sub::Get { automation_id } => get(ctx, &automation_id), + Sub::Subscribers { + automation_id, + limit, + } => subscribers(ctx, &automation_id, limit), + } +} + +fn yes_no(v: &Value, key: &str) -> &'static str { + if v.get(key).and_then(Value::as_bool).unwrap_or(false) { + "Yes" + } else { + "No" + } +} + +fn list(ctx: &Ctx, limit: u64, enabled: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !enabled.is_empty() { + query.push(("filter[enabled]", enabled.to_string())); + } + + let automations = api::fetch_all_paged(&client, "/automations", &query, limit)?; + + if ctx.json { + return output::json(&automations); + } + + let rows: Vec> = automations + .iter() + .map(|a| { + let stats = a.get("stats").cloned().unwrap_or(Value::Null); + vec![ + jstr(a, "id"), + output::truncate(&jstr(a, "name"), 40), + yes_no(a, "enabled").to_string(), + jint(a, "emails_count").to_string(), + jint(&stats, "completed_subscribers_count").to_string(), + jint(&stats, "subscribers_in_queue_count").to_string(), + ] + }) + .collect(); + + output::table( + &["ID", "NAME", "ENABLED", "EMAILS", "COMPLETED", "IN QUEUE"], + &rows, + ); + Ok(()) +} + +fn get(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/automations/{id}"))?; + + if ctx.json { + return output::json(&body); + } + + let d = body.get("data").cloned().unwrap_or(Value::Null); + + println!("ID: {}", jstr(&d, "id")); + println!("Name: {}", jstr(&d, "name")); + println!("Enabled: {}", yes_no(&d, "enabled")); + println!("Emails: {}", jint(&d, "emails_count")); + println!("Created At: {}", jstr(&d, "created_at")); + + let stats = d.get("stats").cloned().unwrap_or(Value::Null); + println!(); + println!("Stats:"); + println!( + " Completed: {}", + jint(&stats, "completed_subscribers_count") + ); + println!( + " In Queue: {}", + jint(&stats, "subscribers_in_queue_count") + ); + println!(" Sent: {}", jint(&stats, "sent")); + println!(" Opens: {}", jint(&stats, "opens_count")); + println!(" Clicks: {}", jint(&stats, "clicks_count")); + + if let Some(steps) = d.get("steps").and_then(Value::as_array) { + if !steps.is_empty() { + println!(); + println!("Steps:"); + for s in steps { + println!(" - {} ({})", jstr(s, "description"), jstr(s, "type")); + } + } + } + + Ok(()) +} + +fn subscribers(ctx: &Ctx, id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + + let subscribers = + api::fetch_all_paged(&client, &format!("/automations/{id}/activity"), &[], limit)?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + let subscriber = s.get("subscriber").cloned().unwrap_or(Value::Null); + vec![ + jstr(s, "id"), + jstr(&subscriber, "email"), + jstr(s, "status"), + jstr(s, "date"), + ] + }) + .collect(); + + output::table(&["ID", "EMAIL", "STATUS", "DATE"], &rows); + Ok(()) +} diff --git a/src/commands/campaign.rs b/src/commands/campaign.rs new file mode 100644 index 0000000..10e5496 --- /dev/null +++ b/src/commands/campaign.rs @@ -0,0 +1,550 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jstr}; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List campaigns + List { + /// maximum number of campaigns to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// filter by status (sent, draft, ready) + #[arg(long, default_value = "")] + status: String, + /// filter by type (regular, ab, resend) + #[arg(long, default_value = "")] + r#type: String, + }, + /// Get campaign details + #[command(arg_required_else_help = true)] + Get { + /// campaign ID + campaign_id: String, + }, + /// Create a campaign + Create { + /// campaign name (required) + #[arg(long, default_value = "")] + name: String, + /// campaign type (regular, ab, resend) + #[arg(long, default_value = "regular")] + r#type: String, + /// email subject (required) + #[arg(long, default_value = "")] + subject: String, + /// sender email address (required) + #[arg(long, default_value = "")] + from: String, + /// sender name (required) + #[arg(long, default_value = "")] + from_name: String, + /// email HTML content + #[arg(long, default_value = "")] + content: String, + /// group IDs + #[arg(long, value_delimiter = ',')] + groups: Option>, + /// segment IDs + #[arg(long, value_delimiter = ',')] + segments: Option>, + }, + /// Update a campaign + #[command(arg_required_else_help = true)] + Update { + /// campaign ID + campaign_id: String, + /// campaign name + #[arg(long)] + name: Option, + /// campaign type (regular, ab, resend) + #[arg(long)] + r#type: Option, + /// email subject + #[arg(long)] + subject: Option, + /// sender email address + #[arg(long)] + from: Option, + /// sender name + #[arg(long)] + from_name: Option, + /// email HTML content + #[arg(long)] + content: Option, + /// group IDs + #[arg(long, value_delimiter = ',')] + groups: Option>, + /// segment IDs + #[arg(long, value_delimiter = ',')] + segments: Option>, + }, + /// Schedule a campaign + #[command(arg_required_else_help = true)] + Schedule { + /// campaign ID + campaign_id: String, + /// delivery type (instant, scheduled) + #[arg(long, default_value = "instant")] + delivery: String, + /// schedule date (YYYY-MM-DD) + #[arg(long, default_value = "")] + date: String, + /// schedule hours (00-23) + #[arg(long, default_value = "")] + hours: String, + /// schedule minutes (00-59) + #[arg(long, default_value = "")] + minutes: String, + /// timezone ID + #[arg(long, default_value_t = 0)] + timezone_id: i64, + }, + /// Cancel a campaign + #[command(arg_required_else_help = true)] + Cancel { + /// campaign ID + campaign_id: String, + }, + /// List campaign subscriber activity + #[command(arg_required_else_help = true)] + Subscribers { + /// campaign ID + campaign_id: String, + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// List campaign languages + Languages, + /// Delete a campaign + #[command(arg_required_else_help = true)] + Delete { + /// campaign ID + campaign_id: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { + limit, + status, + r#type, + } => list(ctx, limit, &status, &r#type), + Sub::Get { campaign_id } => get(ctx, &campaign_id), + Sub::Create { + name, + r#type, + subject, + from, + from_name, + content, + groups, + segments, + } => create( + ctx, &name, &r#type, &subject, &from, &from_name, &content, groups, segments, + ), + Sub::Update { + campaign_id, + name, + r#type, + subject, + from, + from_name, + content, + groups, + segments, + } => update( + ctx, + &campaign_id, + name, + r#type, + subject, + from, + from_name, + content, + groups, + segments, + ), + Sub::Schedule { + campaign_id, + delivery, + date, + hours, + minutes, + timezone_id, + } => schedule( + ctx, + &campaign_id, + &delivery, + &date, + &hours, + &minutes, + timezone_id, + ), + Sub::Cancel { campaign_id } => cancel(ctx, &campaign_id), + Sub::Subscribers { campaign_id, limit } => subscribers(ctx, &campaign_id, limit), + Sub::Languages => languages(ctx), + Sub::Delete { campaign_id } => delete(ctx, &campaign_id), + } +} + +fn list(ctx: &Ctx, limit: u64, status: &str, campaign_type: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !status.is_empty() { + query.push(("filter[status]", status.to_string())); + } + if !campaign_type.is_empty() { + query.push(("filter[type]", campaign_type.to_string())); + } + + let campaigns = api::fetch_all_paged(&client, "/campaigns", &query, limit)?; + + if ctx.json { + return output::json(&campaigns); + } + + let rows: Vec> = campaigns + .iter() + .map(|c| { + let stats = c.get("stats").cloned().unwrap_or(Value::Null); + vec![ + jstr(c, "id"), + output::truncate(&jstr(c, "name"), 40), + jstr(c, "type"), + jstr(c, "status"), + jint(&stats, "sent").to_string(), + jint(&stats, "opens_count").to_string(), + jint(&stats, "clicks_count").to_string(), + ] + }) + .collect(); + + output::table( + &["ID", "NAME", "TYPE", "STATUS", "SENT", "OPENS", "CLICKS"], + &rows, + ); + Ok(()) +} + +fn get(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/campaigns/{id}"))?; + + if ctx.json { + return output::json(&body); + } + + let d = body.get("data").cloned().unwrap_or(Value::Null); + + println!("ID: {}", jstr(&d, "id")); + println!("Name: {}", jstr(&d, "name")); + println!("Type: {}", jstr(&d, "type")); + println!("Status: {}", jstr(&d, "status")); + println!("Created At: {}", jstr(&d, "created_at")); + println!("Updated At: {}", jstr(&d, "updated_at")); + let scheduled_for = jstr(&d, "scheduled_for"); + if !scheduled_for.is_empty() { + println!("Scheduled: {scheduled_for}"); + } + + let stats = d.get("stats").cloned().unwrap_or(Value::Null); + println!(); + println!("Stats:"); + println!(" Sent: {}", jint(&stats, "sent")); + println!(" Opens: {}", jint(&stats, "opens_count")); + println!(" Clicks: {}", jint(&stats, "clicks_count")); + + if let Some(emails) = d.get("emails").and_then(Value::as_array) { + if !emails.is_empty() { + println!(); + println!("Emails:"); + for e in emails { + println!( + " - {} (from: {} <{}>)", + jstr(e, "subject"), + jstr(e, "from_name"), + jstr(e, "from") + ); + } + } + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn create( + ctx: &Ctx, + name: &str, + campaign_type: &str, + subject: &str, + from: &str, + from_name: &str, + content: &str, + groups: Option>, + segments: Option>, +) -> Result<()> { + let client = ctx.client()?; + + let name = prompt::require_arg(name, "name", "Campaign name")?; + let subject = prompt::require_arg(subject, "subject", "Email subject")?; + let from = prompt::require_arg(from, "from", "Sender email address")?; + let from_name = prompt::require_arg(from_name, "from-name", "Sender name")?; + + let mut body = json!({ + "name": name, + "type": campaign_type, + "emails": [{ + "subject": subject, + "from_name": from_name, + "from": from, + "content": content, + }], + }); + if let Some(groups) = groups { + if !groups.is_empty() { + body["groups"] = json!(groups); + } + } + if let Some(segments) = segments { + if !segments.is_empty() { + body["segments"] = json!(segments); + } + } + + let result = client.post("/campaigns", &body)?; + + if ctx.json { + return output::json(&result); + } + + let id = result + .get("data") + .map(|d| jstr(d, "id")) + .unwrap_or_default(); + output::success(&format!("Campaign created successfully. ID: {id}")); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn update( + ctx: &Ctx, + id: &str, + name: Option, + campaign_type: Option, + subject: Option, + from: Option, + from_name: Option, + content: Option, + groups: Option>, + segments: Option>, +) -> Result<()> { + let client = ctx.client()?; + + // First get the existing campaign to preserve unchanged fields. + let existing = client.get(&format!("/campaigns/{id}"))?; + let d = existing.get("data").cloned().unwrap_or(Value::Null); + + let name = name.unwrap_or_else(|| jstr(&d, "name")); + let campaign_type = campaign_type.unwrap_or_else(|| jstr(&d, "type")); + + let existing_email = d + .get("emails") + .and_then(Value::as_array) + .and_then(|e| e.first()) + .cloned() + .unwrap_or(Value::Null); + + let subject = subject.unwrap_or_else(|| jstr(&existing_email, "subject")); + let from = from.unwrap_or_else(|| jstr(&existing_email, "from")); + let from_name = from_name.unwrap_or_else(|| jstr(&existing_email, "from_name")); + let content = content.unwrap_or_default(); + + let mut body = json!({ + "name": name, + "type": campaign_type, + "emails": [{ + "subject": subject, + "from_name": from_name, + "from": from, + "content": content, + }], + }); + if let Some(groups) = groups { + if !groups.is_empty() { + body["groups"] = json!(groups); + } + } + if let Some(segments) = segments { + if !segments.is_empty() { + body["segments"] = json!(segments); + } + } + + let result = client.put(&format!("/campaigns/{id}"), &body)?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Campaign {id} updated successfully.")); + Ok(()) +} + +fn schedule( + ctx: &Ctx, + id: &str, + delivery: &str, + date: &str, + hours: &str, + minutes: &str, + timezone_id: i64, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({ "delivery": delivery }); + if delivery.eq_ignore_ascii_case("scheduled") { + let mut schedule = json!({ + "date": date, + "hours": hours, + "minutes": minutes, + }); + if timezone_id != 0 { + schedule["timezone_id"] = json!(timezone_id); + } + body["schedule"] = schedule; + } + + let result = client.post(&format!("/campaigns/{id}/schedule"), &body)?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Campaign {id} scheduled successfully.")); + Ok(()) +} + +fn cancel(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + let result = client.post(&format!("/campaigns/{id}/cancel"), &json!({}))?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Campaign {id} cancelled successfully.")); + Ok(()) +} + +fn subscribers(ctx: &Ctx, id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let subscribers = fetch_all_activity(&client, id, limit)?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + let subscriber = s.get("subscriber").cloned().unwrap_or(Value::Null); + vec![ + jstr(s, "id"), + jstr(&subscriber, "email"), + jint(s, "opens_count").to_string(), + jint(s, "clicks_count").to_string(), + ] + }) + .collect(); + + output::table(&["ID", "EMAIL", "OPENS", "CLICKS"], &rows); + Ok(()) +} + +// Subscriber activity is a POST endpoint paginated via the request body. +fn fetch_all_activity(client: &api::Client, id: &str, limit: u64) -> Result> { + let per = if limit > 0 && limit < 25 { limit } else { 25 }; + let path = format!("/campaigns/{id}/reports/subscriber-activity"); + let mut all = Vec::new(); + let mut page: u64 = 1; + loop { + let body = client.post(&path, &json!({ "page": page, "limit": per }))?; + let items = body + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let got = items.len() as u64; + for item in items { + all.push(item); + if limit > 0 && all.len() as u64 >= limit { + return Ok(all); + } + } + let last_page = body + .get("meta") + .and_then(|m| m.get("last_page")) + .and_then(Value::as_u64); + let done = match last_page { + Some(last) => page >= last, + None => got < per, + }; + if done || got == 0 { + return Ok(all); + } + page += 1; + } +} + +fn languages(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let body = client.get("/campaigns/languages")?; + + let data = body.get("data").cloned().unwrap_or(Value::Null); + + if ctx.json { + return output::json(&data); + } + + let rows: Vec> = data + .as_array() + .map(|langs| { + langs + .iter() + .map(|l| vec![jstr(l, "id"), jstr(l, "name"), jstr(l, "shortcode")]) + .collect() + }) + .unwrap_or_default(); + + output::table(&["ID", "NAME", "SHORTCODE"], &rows); + Ok(()) +} + +fn delete(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Are you sure you want to delete campaign {id}?"))? { + return Ok(()); + } + + client.delete(&format!("/campaigns/{id}"))?; + output::success(&format!("Campaign {id} deleted successfully.")); + Ok(()) +} diff --git a/src/commands/cart.rs b/src/commands/cart.rs new file mode 100644 index 0000000..1b9620b --- /dev/null +++ b/src/commands/cart.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, view, and update carts within a shop.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List carts + List { + /// maximum number of carts to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get cart details + #[command(arg_required_else_help = true)] + Get { + /// cart ID + cart_id: String, + }, + /// Update a cart + #[command(arg_required_else_help = true)] + Update { + /// cart ID + cart_id: String, + /// customer ID + #[arg(long)] + customer: Option, + /// cart currency + #[arg(long)] + currency: Option, + }, + /// Get total cart count + Count, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, limit), + Sub::Get { cart_id } => get(ctx, &shop_id, &cart_id), + Sub::Update { + cart_id, + customer, + currency, + } => update(ctx, &shop_id, &cart_id, customer, currency), + Sub::Count => count(ctx, &shop_id), + } +} + +fn list(ctx: &Ctx, shop_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let carts = api::fetch_all_paged( + &client, + &format!("/ecommerce/shops/{shop_id}/carts"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&carts); + } + + let rows: Vec> = carts + .iter() + .map(|c| { + vec![ + jstr(c, "id"), + jstr(c, "customer_id"), + jstr(c, "currency"), + format!("{:.2}", jfloat(c, "total")), + jstr(c, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "CUSTOMER", "CURRENCY", "TOTAL", "CREATED"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, cart_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/ecommerce/shops/{shop_id}/carts/{cart_id}"))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let c = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&c, "id")], + vec!["Customer ID".to_string(), jstr(&c, "customer_id")], + vec!["Currency".to_string(), jstr(&c, "currency")], + vec!["Total".to_string(), format!("{:.2}", jfloat(&c, "total"))], + vec!["Created".to_string(), jstr(&c, "created_at")], + vec!["Updated".to_string(), jstr(&c, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn update( + ctx: &Ctx, + shop_id: &str, + cart_id: &str, + customer: Option, + currency: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(customer) = customer { + body["customer_id"] = json!(customer); + } + if let Some(currency) = currency { + body["currency"] = json!(currency); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put( + &format!("/ecommerce/shops/{shop_id}/carts/{cart_id}"), + &body, + )?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!("Cart updated: {}", jstr(&data, "id"))); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with( + &format!("/ecommerce/shops/{shop_id}/carts"), + &[("limit", "0".to_string())], + )?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total carts: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/cartitem.rs b/src/commands/cartitem.rs new file mode 100644 index 0000000..d3d99f0 --- /dev/null +++ b/src/commands/cartitem.rs @@ -0,0 +1,225 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete items within a cart.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + /// cart ID (required) + #[arg(long, global = true, default_value = "")] + cart: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List cart items + List { + /// maximum number of items to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get cart item details + #[command(arg_required_else_help = true)] + Get { + /// item ID + item_id: String, + }, + /// Add an item to a cart + Create { + /// product ID (required) + #[arg(long, default_value = "")] + product: String, + /// item quantity + #[arg(long, default_value_t = 1)] + quantity: i64, + /// item price + #[arg(long, default_value_t = 0.0)] + price: f64, + }, + /// Update a cart item + #[command(arg_required_else_help = true)] + Update { + /// item ID + item_id: String, + /// item quantity + #[arg(long)] + quantity: Option, + /// item price + #[arg(long)] + price: Option, + }, + /// Delete a cart item + #[command(arg_required_else_help = true)] + Delete { + /// item ID + item_id: String, + }, + /// Get total cart item count + Count, +} + +fn base_path(shop_id: &str, cart_id: &str) -> String { + format!("/ecommerce/shops/{shop_id}/carts/{cart_id}/items") +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + let cart_id = prompt::require_arg(&cmd.cart, "cart", "Cart ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, &cart_id, limit), + Sub::Get { item_id } => get(ctx, &shop_id, &cart_id, &item_id), + Sub::Create { + product, + quantity, + price, + } => create(ctx, &shop_id, &cart_id, &product, quantity, price), + Sub::Update { + item_id, + quantity, + price, + } => update(ctx, &shop_id, &cart_id, &item_id, quantity, price), + Sub::Delete { item_id } => delete(ctx, &shop_id, &cart_id, &item_id), + Sub::Count => count(ctx, &shop_id, &cart_id), + } +} + +fn list(ctx: &Ctx, shop_id: &str, cart_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let items = api::fetch_all_paged(&client, &base_path(shop_id, cart_id), &[], limit)?; + + if ctx.json { + return output::json(&items); + } + + let rows: Vec> = items + .iter() + .map(|i| { + vec![ + jstr(i, "id"), + jstr(i, "product_id"), + jint(i, "quantity").to_string(), + format!("{:.2}", jfloat(i, "price")), + jstr(i, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "PRODUCT", "QUANTITY", "PRICE", "CREATED"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, cart_id: &str, item_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("{}/{item_id}", base_path(shop_id, cart_id)))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let i = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&i, "id")], + vec!["Product ID".to_string(), jstr(&i, "product_id")], + vec!["Quantity".to_string(), jint(&i, "quantity").to_string()], + vec!["Price".to_string(), format!("{:.2}", jfloat(&i, "price"))], + vec!["Created".to_string(), jstr(&i, "created_at")], + vec!["Updated".to_string(), jstr(&i, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn create( + ctx: &Ctx, + shop_id: &str, + cart_id: &str, + product: &str, + quantity: i64, + price: f64, +) -> Result<()> { + let client = ctx.client()?; + let product_id = prompt::require_arg(product, "product", "Product ID")?; + + let body = json!({ + "product_id": product_id, + "quantity": quantity, + "price": price, + }); + + let result = client.post(&base_path(shop_id, cart_id), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Cart item created: {} (ID: {})", + jstr(&data, "product_id"), + jstr(&data, "id") + )); + Ok(()) +} + +fn update( + ctx: &Ctx, + shop_id: &str, + cart_id: &str, + item_id: &str, + quantity: Option, + price: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(quantity) = quantity { + body["quantity"] = json!(quantity); + } + if let Some(price) = price { + body["price"] = json!(price); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put(&format!("{}/{item_id}", base_path(shop_id, cart_id)), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!("Cart item updated: {}", jstr(&data, "id"))); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str, cart_id: &str, item_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!("{}/{item_id}", base_path(shop_id, cart_id)))?; + output::success(&format!("Cart item {item_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str, cart_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with(&base_path(shop_id, cart_id), &[("limit", "0".to_string())])?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total cart items: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/category.rs b/src/commands/category.rs new file mode 100644 index 0000000..0078e10 --- /dev/null +++ b/src/commands/category.rs @@ -0,0 +1,282 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete categories within a shop.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List categories + List { + /// maximum number of categories to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get category details + #[command(arg_required_else_help = true)] + Get { + /// category ID + category_id: String, + }, + /// Create a new category + Create { + /// category name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Update a category + #[command(arg_required_else_help = true)] + Update { + /// category ID + category_id: String, + /// category name + #[arg(long)] + name: Option, + }, + /// Delete a category + #[command(arg_required_else_help = true)] + Delete { + /// category ID + category_id: String, + }, + /// Get total category count + Count, + /// List products in a category + #[command(arg_required_else_help = true)] + Products { + /// category ID + category_id: String, + }, + /// Assign a product to a category + #[command(arg_required_else_help = true)] + AssignProduct { + /// category ID + category_id: String, + /// product ID (required) + #[arg(long, default_value = "")] + product: String, + }, + /// Remove a product from a category + #[command(arg_required_else_help = true)] + UnassignProduct { + /// category ID + category_id: String, + /// product ID (required) + #[arg(long, default_value = "")] + product: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, limit), + Sub::Get { category_id } => get(ctx, &shop_id, &category_id), + Sub::Create { name } => create(ctx, &shop_id, &name), + Sub::Update { category_id, name } => update(ctx, &shop_id, &category_id, name), + Sub::Delete { category_id } => delete(ctx, &shop_id, &category_id), + Sub::Count => count(ctx, &shop_id), + Sub::Products { category_id } => products(ctx, &shop_id, &category_id), + Sub::AssignProduct { + category_id, + product, + } => assign_product(ctx, &shop_id, &category_id, &product), + Sub::UnassignProduct { + category_id, + product, + } => unassign_product(ctx, &shop_id, &category_id, &product), + } +} + +fn list(ctx: &Ctx, shop_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let categories = api::fetch_all_paged( + &client, + &format!("/ecommerce/shops/{shop_id}/categories"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&categories); + } + + let rows: Vec> = categories + .iter() + .map(|c| vec![jstr(c, "id"), jstr(c, "name"), jstr(c, "created_at")]) + .collect(); + + output::table(&["ID", "NAME", "CREATED"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, category_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!( + "/ecommerce/shops/{shop_id}/categories/{category_id}" + ))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let c = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&c, "id")], + vec!["Name".to_string(), jstr(&c, "name")], + vec!["Created".to_string(), jstr(&c, "created_at")], + vec!["Updated".to_string(), jstr(&c, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn create(ctx: &Ctx, shop_id: &str, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Category name")?; + + let body = json!({ "name": name }); + let result = client.post(&format!("/ecommerce/shops/{shop_id}/categories"), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Category created: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +fn update(ctx: &Ctx, shop_id: &str, category_id: &str, name: Option) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(name) = name { + body["name"] = json!(name); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put( + &format!("/ecommerce/shops/{shop_id}/categories/{category_id}"), + &body, + )?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Category updated: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str, category_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!( + "/ecommerce/shops/{shop_id}/categories/{category_id}" + ))?; + output::success(&format!("Category {category_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with( + &format!("/ecommerce/shops/{shop_id}/categories"), + &[("limit", "0".to_string())], + )?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total categories: {}", jint(&body, "total")); + Ok(()) +} + +fn products(ctx: &Ctx, shop_id: &str, category_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!( + "/ecommerce/shops/{shop_id}/categories/{category_id}/products" + ))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let rows: Vec> = body + .get("data") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|p| { + vec![ + jstr(p, "id"), + jstr(p, "name"), + format!("{:.2}", jfloat(p, "price")), + jstr(p, "created_at"), + ] + }) + .collect() + }) + .unwrap_or_default(); + + output::table(&["ID", "NAME", "PRICE", "CREATED"], &rows); + Ok(()) +} + +fn assign_product(ctx: &Ctx, shop_id: &str, category_id: &str, product: &str) -> Result<()> { + let client = ctx.client()?; + let product_id = prompt::require_arg(product, "product", "Product ID")?; + + let body = json!({ "product_id": product_id }); + client.post( + &format!("/ecommerce/shops/{shop_id}/categories/{category_id}/products"), + &body, + )?; + + output::success(&format!( + "Product {product_id} assigned to category {category_id}." + )); + Ok(()) +} + +fn unassign_product(ctx: &Ctx, shop_id: &str, category_id: &str, product: &str) -> Result<()> { + let client = ctx.client()?; + let product_id = prompt::require_arg(product, "product", "Product ID")?; + + client.delete(&format!( + "/ecommerce/shops/{shop_id}/categories/{category_id}/products/{product_id}" + ))?; + + output::success(&format!( + "Product {product_id} removed from category {category_id}." + )); + Ok(()) +} diff --git a/src/commands/customer.rs b/src/commands/customer.rs new file mode 100644 index 0000000..7a0eeb5 --- /dev/null +++ b/src/commands/customer.rs @@ -0,0 +1,241 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete customers within a shop.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List customers + List { + /// maximum number of customers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get customer details + #[command(arg_required_else_help = true)] + Get { + /// customer ID + customer_id: String, + }, + /// Create a new customer + Create { + /// customer email (required) + #[arg(long, default_value = "")] + email: String, + /// customer first name + #[arg(long, default_value = "")] + first_name: String, + /// customer last name + #[arg(long, default_value = "")] + last_name: String, + }, + /// Update a customer + #[command(arg_required_else_help = true)] + Update { + /// customer ID + customer_id: String, + /// customer email + #[arg(long)] + email: Option, + /// customer first name + #[arg(long)] + first_name: Option, + /// customer last name + #[arg(long)] + last_name: Option, + }, + /// Delete a customer + #[command(arg_required_else_help = true)] + Delete { + /// customer ID + customer_id: String, + }, + /// Get total customer count + Count, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, limit), + Sub::Get { customer_id } => get(ctx, &shop_id, &customer_id), + Sub::Create { + email, + first_name, + last_name, + } => create(ctx, &shop_id, &email, &first_name, &last_name), + Sub::Update { + customer_id, + email, + first_name, + last_name, + } => update(ctx, &shop_id, &customer_id, email, first_name, last_name), + Sub::Delete { customer_id } => delete(ctx, &shop_id, &customer_id), + Sub::Count => count(ctx, &shop_id), + } +} + +fn list(ctx: &Ctx, shop_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let customers = api::fetch_all_paged( + &client, + &format!("/ecommerce/shops/{shop_id}/customers"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&customers); + } + + let rows: Vec> = customers + .iter() + .map(|c| { + vec![ + jstr(c, "id"), + jstr(c, "email"), + jstr(c, "first_name"), + jstr(c, "last_name"), + jstr(c, "created_at"), + ] + }) + .collect(); + + output::table( + &["ID", "EMAIL", "FIRST NAME", "LAST NAME", "CREATED"], + &rows, + ); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, customer_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!( + "/ecommerce/shops/{shop_id}/customers/{customer_id}" + ))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let c = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&c, "id")], + vec!["Email".to_string(), jstr(&c, "email")], + vec!["First Name".to_string(), jstr(&c, "first_name")], + vec!["Last Name".to_string(), jstr(&c, "last_name")], + vec!["Created".to_string(), jstr(&c, "created_at")], + vec!["Updated".to_string(), jstr(&c, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn create(ctx: &Ctx, shop_id: &str, email: &str, first_name: &str, last_name: &str) -> Result<()> { + let client = ctx.client()?; + let email = prompt::require_arg(email, "email", "Customer email")?; + + let mut body = json!({ "email": email }); + if !first_name.is_empty() { + body["first_name"] = json!(first_name); + } + if !last_name.is_empty() { + body["last_name"] = json!(last_name); + } + + let result = client.post(&format!("/ecommerce/shops/{shop_id}/customers"), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Customer created: {} (ID: {})", + jstr(&data, "email"), + jstr(&data, "id") + )); + Ok(()) +} + +fn update( + ctx: &Ctx, + shop_id: &str, + customer_id: &str, + email: Option, + first_name: Option, + last_name: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(email) = email { + body["email"] = json!(email); + } + if let Some(first_name) = first_name { + body["first_name"] = json!(first_name); + } + if let Some(last_name) = last_name { + body["last_name"] = json!(last_name); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put( + &format!("/ecommerce/shops/{shop_id}/customers/{customer_id}"), + &body, + )?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Customer updated: {} (ID: {})", + jstr(&data, "email"), + jstr(&data, "id") + )); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str, customer_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!( + "/ecommerce/shops/{shop_id}/customers/{customer_id}" + ))?; + output::success(&format!("Customer {customer_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with( + &format!("/ecommerce/shops/{shop_id}/customers"), + &[("limit", "0".to_string())], + )?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total customers: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/field.rs b/src/commands/field.rs new file mode 100644 index 0000000..ebb15ba --- /dev/null +++ b/src/commands/field.rs @@ -0,0 +1,137 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::json; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::jstr; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete subscriber fields.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List fields + List { + /// maximum number of fields to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// sort order (e.g. name) + #[arg(long, default_value = "")] + sort: String, + }, + /// Create a field + Create { + /// field name (required) + #[arg(long, default_value = "")] + name: String, + /// field type: text, number, or date (required) + #[arg(long = "type", default_value = "")] + field_type: String, + }, + /// Update a field + #[command(arg_required_else_help = true)] + Update { + /// field ID + field_id: String, + /// new field name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Delete a field + #[command(arg_required_else_help = true)] + Delete { + /// field ID + field_id: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit, sort } => list(ctx, limit, &sort), + Sub::Create { name, field_type } => create(ctx, &name, &field_type), + Sub::Update { field_id, name } => update(ctx, &field_id, &name), + Sub::Delete { field_id } => delete(ctx, &field_id), + } +} + +fn list(ctx: &Ctx, limit: u64, sort: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !sort.is_empty() { + query.push(("sort", sort.to_string())); + } + + let fields = api::fetch_all_paged(&client, "/fields", &query, limit)?; + + if ctx.json { + return output::json(&fields); + } + + let rows: Vec> = fields + .iter() + .map(|f| { + vec![ + jstr(f, "id"), + jstr(f, "name"), + jstr(f, "key"), + jstr(f, "type"), + ] + }) + .collect(); + + output::table(&["ID", "NAME", "KEY", "TYPE"], &rows); + Ok(()) +} + +fn create(ctx: &Ctx, name: &str, field_type: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Field name")?; + let field_type = prompt::require_arg(field_type, "type", "Field type (text, number, date)")?; + + let result = client.post("/fields", &json!({ "name": name, "type": field_type }))?; + + if ctx.json { + return output::json(&result); + } + + let id = result + .get("data") + .map(|d| jstr(d, "id")) + .unwrap_or_default(); + output::success(&format!("Field created successfully. ID: {id}")); + Ok(()) +} + +fn update(ctx: &Ctx, field_id: &str, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "New field name")?; + + let result = client.put(&format!("/fields/{field_id}"), &json!({ "name": name }))?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Field {field_id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, field_id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Delete field {field_id}?"))? { + return Ok(()); + } + + client.delete(&format!("/fields/{field_id}"))?; + output::success(&format!("Field {field_id} deleted successfully.")); + Ok(()) +} diff --git a/src/commands/form.rs b/src/commands/form.rs new file mode 100644 index 0000000..c7ad1a4 --- /dev/null +++ b/src/commands/form.rs @@ -0,0 +1,196 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, view, update, and delete forms.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List forms + List { + /// maximum number of forms to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// form type (popup, embedded, promotion) + #[arg(long = "type", default_value = "popup")] + form_type: String, + /// sort field + #[arg(long, default_value = "")] + sort: String, + }, + /// Get form details + #[command(arg_required_else_help = true)] + Get { + /// form ID + form_id: String, + }, + /// Update a form + #[command(arg_required_else_help = true)] + Update { + /// form ID + form_id: String, + /// form name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Delete a form + #[command(arg_required_else_help = true)] + Delete { + /// form ID + form_id: String, + }, + /// List form subscribers + #[command(arg_required_else_help = true)] + Subscribers { + /// form ID + form_id: String, + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { + limit, + form_type, + sort, + } => list(ctx, limit, &form_type, &sort), + Sub::Get { form_id } => get(ctx, &form_id), + Sub::Update { form_id, name } => update(ctx, &form_id, &name), + Sub::Delete { form_id } => delete(ctx, &form_id), + Sub::Subscribers { form_id, limit } => subscribers(ctx, &form_id, limit), + } +} + +fn yes_no(v: &Value, key: &str) -> String { + if v.get(key).and_then(Value::as_bool).unwrap_or(false) { + "Yes".to_string() + } else { + "No".to_string() + } +} + +fn list(ctx: &Ctx, limit: u64, form_type: &str, sort: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !sort.is_empty() { + query.push(("sort", sort.to_string())); + } + + let forms = api::fetch_all_paged(&client, &format!("/forms/{form_type}"), &query, limit)?; + + if ctx.json { + return output::json(&forms); + } + + let rows: Vec> = forms + .iter() + .map(|f| { + vec![ + jstr(f, "id"), + output::truncate(&jstr(f, "name"), 40), + jstr(f, "type"), + yes_no(f, "active"), + jint(f, "conversions_count").to_string(), + jint(f, "opens_count").to_string(), + ] + }) + .collect(); + + output::table( + &["ID", "NAME", "TYPE", "ACTIVE", "CONVERSIONS", "OPENS"], + &rows, + ); + Ok(()) +} + +fn get(ctx: &Ctx, form_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/forms/{form_id}"))?; + + if ctx.json { + return output::json(&body); + } + + let d = body.get("data").cloned().unwrap_or(Value::Null); + + println!("ID: {}", jstr(&d, "id")); + println!("Name: {}", jstr(&d, "name")); + println!("Type: {}", jstr(&d, "type")); + println!("Active: {}", yes_no(&d, "active")); + println!("Conversions: {}", jint(&d, "conversions_count")); + println!("Opens: {}", jint(&d, "opens_count")); + println!("Created At: {}", jstr(&d, "created_at")); + + Ok(()) +} + +fn update(ctx: &Ctx, form_id: &str, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Form name")?; + + let result = client.put(&format!("/forms/{form_id}"), &json!({ "name": name }))?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Form {form_id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, form_id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Are you sure you want to delete form {form_id}?"))? { + return Ok(()); + } + + client.delete(&format!("/forms/{form_id}"))?; + output::success(&format!("Form {form_id} deleted successfully.")); + Ok(()) +} + +fn subscribers(ctx: &Ctx, form_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + + let subscribers = api::fetch_all_paged( + &client, + &format!("/forms/{form_id}/subscribers"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + vec![ + jstr(s, "id"), + jstr(s, "email"), + jstr(s, "status"), + jstr(s, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "EMAIL", "STATUS", "CREATED AT"], &rows); + Ok(()) +} diff --git a/src/commands/group.rs b/src/commands/group.rs new file mode 100644 index 0000000..dc9ac9c --- /dev/null +++ b/src/commands/group.rs @@ -0,0 +1,250 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::json; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jpath, jstr}; + +#[derive(Args)] +#[command( + long_about = "List, create, update, and delete groups. Manage group subscriber assignments." +)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List groups + List { + /// maximum number of groups to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// sort field (e.g. name, created_at) + #[arg(long, default_value = "")] + sort: String, + }, + /// Create a group + Create { + /// group name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Update a group + #[command(arg_required_else_help = true)] + Update { + /// group ID + id: String, + /// group name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Delete a group + #[command(arg_required_else_help = true)] + Delete { + /// group ID + id: String, + }, + /// List subscribers in a group + #[command(arg_required_else_help = true)] + Subscribers { + /// group ID + group_id: String, + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Assign a subscriber to a group + #[command(arg_required_else_help = true)] + Assign { + /// group ID + group_id: String, + /// subscriber ID + subscriber_id: String, + }, + /// Unassign a subscriber from a group + #[command(arg_required_else_help = true)] + Unassign { + /// group ID + group_id: String, + /// subscriber ID + subscriber_id: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit, sort } => list(ctx, limit, &sort), + Sub::Create { name } => create(ctx, &name), + Sub::Update { id, name } => update(ctx, &id, &name), + Sub::Delete { id } => delete(ctx, &id), + Sub::Subscribers { group_id, limit } => subscribers(ctx, &group_id, limit), + Sub::Assign { + group_id, + subscriber_id, + } => assign(ctx, &group_id, &subscriber_id), + Sub::Unassign { + group_id, + subscriber_id, + } => unassign(ctx, &group_id, &subscriber_id), + } +} + +fn list(ctx: &Ctx, limit: u64, sort: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !sort.is_empty() { + query.push(("sort", sort.to_string())); + } + + let groups = api::fetch_all_paged(&client, "/groups", &query, limit)?; + + if ctx.json { + return output::json(&groups); + } + + let rows: Vec> = groups + .iter() + .map(|g| { + vec![ + jstr(g, "id"), + output::truncate(&jstr(g, "name"), 40), + jint(g, "active_count").to_string(), + jint(g, "sent_count").to_string(), + jint(g, "opens_count").to_string(), + jpath(g, "click_rate.string"), + jstr(g, "created_at"), + ] + }) + .collect(); + + output::table( + &[ + "ID", + "NAME", + "ACTIVE", + "SENT", + "OPENS", + "CLICK RATE", + "CREATED AT", + ], + &rows, + ); + Ok(()) +} + +fn create(ctx: &Ctx, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Group name")?; + + let result = client.post("/groups", &json!({ "name": name }))?; + + if ctx.json { + return output::json(&result); + } + + let id = result + .get("data") + .map(|d| jstr(d, "id")) + .unwrap_or_default(); + output::success(&format!("Group created successfully. ID: {id}")); + Ok(()) +} + +fn update(ctx: &Ctx, id: &str, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Group name")?; + + let result = client.put(&format!("/groups/{id}"), &json!({ "name": name }))?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Group {id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Delete group {id}?"))? { + return Ok(()); + } + + client.delete(&format!("/groups/{id}"))?; + output::success(&format!("Group {id} deleted successfully.")); + Ok(()) +} + +fn subscribers(ctx: &Ctx, group_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + + let subscribers = api::fetch_all_paged( + &client, + &format!("/groups/{group_id}/subscribers"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + vec![ + output::truncate(&jstr(s, "email"), 40), + jstr(s, "status"), + jstr(s, "source"), + jint(s, "opens_count").to_string(), + jint(s, "clicks_count").to_string(), + jstr(s, "subscribed_at"), + ] + }) + .collect(); + + output::table( + &[ + "EMAIL", + "STATUS", + "SOURCE", + "OPENS", + "CLICKS", + "SUBSCRIBED AT", + ], + &rows, + ); + Ok(()) +} + +fn assign(ctx: &Ctx, group_id: &str, subscriber_id: &str) -> Result<()> { + let client = ctx.client()?; + + client.post( + &format!("/subscribers/{subscriber_id}/groups/{group_id}"), + &json!({}), + )?; + + output::success(&format!( + "Subscriber {subscriber_id} assigned to group {group_id} successfully." + )); + Ok(()) +} + +fn unassign(ctx: &Ctx, group_id: &str, subscriber_id: &str) -> Result<()> { + let client = ctx.client()?; + + client.delete(&format!("/subscribers/{subscriber_id}/groups/{group_id}"))?; + + output::success(&format!( + "Subscriber {subscriber_id} unassigned from group {group_id} successfully." + )); + Ok(()) +} diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs new file mode 100644 index 0000000..ef3706e --- /dev/null +++ b/src/commands/import_cmd.rs @@ -0,0 +1,78 @@ +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; +use serde_json::Value; + +use crate::cli::Ctx; +use crate::output; +use crate::prompt; + +#[derive(Args)] +#[command(long_about = "Import categories, products, or orders in bulk from a JSON file.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// Bulk import categories + Categories { + /// shop ID (required) + #[arg(long, default_value = "")] + shop: String, + /// path to JSON file (required) + #[arg(long, default_value = "")] + file: String, + }, + /// Bulk import products + Products { + /// shop ID (required) + #[arg(long, default_value = "")] + shop: String, + /// path to JSON file (required) + #[arg(long, default_value = "")] + file: String, + }, + /// Bulk import orders + Orders { + /// shop ID (required) + #[arg(long, default_value = "")] + shop: String, + /// path to JSON file (required) + #[arg(long, default_value = "")] + file: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::Categories { shop, file } => do_import(ctx, &shop, &file, "categories"), + Sub::Products { shop, file } => do_import(ctx, &shop, &file, "products"), + Sub::Orders { shop, file } => do_import(ctx, &shop, &file, "orders"), + } +} + +fn read_json_file(path: &str) -> Result { + let data = std::fs::read(path).map_err(|e| anyhow!("failed to read file {path}: {e}"))?; + serde_json::from_slice(&data).map_err(|_| anyhow!("file {path} does not contain valid JSON")) +} + +fn do_import(ctx: &Ctx, shop: &str, file: &str, resource: &str) -> Result<()> { + let shop_id = prompt::require_arg(shop, "shop", "Shop ID")?; + let file_path = prompt::require_arg(file, "file", "Path to JSON file")?; + + let data = read_json_file(&file_path)?; + + let client = ctx.client()?; + let result = client.post( + &format!("/ecommerce/shops/{shop_id}/{resource}/import"), + &data, + )?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Import of {resource} completed successfully.")); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..35363d4 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,20 @@ +pub mod account; +pub mod auth; +pub mod automation; +pub mod campaign; +pub mod cart; +pub mod cartitem; +pub mod category; +pub mod customer; +pub mod field; +pub mod form; +pub mod group; +pub mod import_cmd; +pub mod order; +pub mod product; +pub mod profile; +pub mod segment; +pub mod shop; +pub mod subscriber; +pub mod timezone; +pub mod webhook; diff --git a/src/commands/order.rs b/src/commands/order.rs new file mode 100644 index 0000000..5adcb70 --- /dev/null +++ b/src/commands/order.rs @@ -0,0 +1,296 @@ +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete orders within a shop.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List orders + List { + /// maximum number of orders to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get order details + #[command(arg_required_else_help = true)] + Get { + /// order ID + order_id: String, + }, + /// Create a new order + Create { + /// customer ID (required) + #[arg(long, default_value = "")] + customer: String, + /// order status (required) + #[arg(long, default_value = "")] + status: String, + /// order total (required) + #[arg(long, default_value_t = 0.0)] + total: f64, + /// order currency + #[arg(long, default_value = "USD")] + currency: String, + /// order items as JSON array + #[arg(long, default_value = "")] + items: String, + }, + /// Update an order + #[command(arg_required_else_help = true)] + Update { + /// order ID + order_id: String, + /// customer ID + #[arg(long)] + customer: Option, + /// order status + #[arg(long)] + status: Option, + /// order total + #[arg(long)] + total: Option, + /// order currency + #[arg(long)] + currency: Option, + /// order items as JSON array + #[arg(long)] + items: Option, + }, + /// Delete an order + #[command(arg_required_else_help = true)] + Delete { + /// order ID + order_id: String, + }, + /// Get total order count + Count, +} + +#[derive(Deserialize, Serialize)] +struct OrderItem { + #[serde(default)] + product_id: String, + #[serde(default)] + quantity: i64, + #[serde(default)] + price: f64, +} + +fn parse_items(items: &str) -> Result { + let parsed: Vec = + serde_json::from_str(items).map_err(|e| anyhow!("invalid --items JSON: {e}"))?; + Ok(serde_json::to_value(parsed)?) +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, limit), + Sub::Get { order_id } => get(ctx, &shop_id, &order_id), + Sub::Create { + customer, + status, + total, + currency, + items, + } => create(ctx, &shop_id, &customer, &status, total, ¤cy, &items), + Sub::Update { + order_id, + customer, + status, + total, + currency, + items, + } => update( + ctx, &shop_id, &order_id, customer, status, total, currency, items, + ), + Sub::Delete { order_id } => delete(ctx, &shop_id, &order_id), + Sub::Count => count(ctx, &shop_id), + } +} + +fn list(ctx: &Ctx, shop_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let orders = api::fetch_all_paged( + &client, + &format!("/ecommerce/shops/{shop_id}/orders"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&orders); + } + + let rows: Vec> = orders + .iter() + .map(|o| { + vec![ + jstr(o, "id"), + jstr(o, "customer_id"), + jstr(o, "status"), + format!("{:.2}", jfloat(o, "total")), + jstr(o, "currency"), + jstr(o, "created_at"), + ] + }) + .collect(); + + output::table( + &["ID", "CUSTOMER", "STATUS", "TOTAL", "CURRENCY", "CREATED"], + &rows, + ); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, order_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/ecommerce/shops/{shop_id}/orders/{order_id}"))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let o = body.get("data").cloned().unwrap_or(Value::Null); + let items_len = o.get("items").and_then(Value::as_array).map_or(0, Vec::len); + let rows = vec![ + vec!["ID".to_string(), jstr(&o, "id")], + vec!["Customer ID".to_string(), jstr(&o, "customer_id")], + vec!["Status".to_string(), jstr(&o, "status")], + vec!["Total".to_string(), format!("{:.2}", jfloat(&o, "total"))], + vec!["Currency".to_string(), jstr(&o, "currency")], + vec!["Items".to_string(), items_len.to_string()], + vec!["Created".to_string(), jstr(&o, "created_at")], + vec!["Updated".to_string(), jstr(&o, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn create( + ctx: &Ctx, + shop_id: &str, + customer: &str, + status: &str, + total: f64, + currency: &str, + items: &str, +) -> Result<()> { + let client = ctx.client()?; + let customer_id = prompt::require_arg(customer, "customer", "Customer ID")?; + let status = prompt::require_arg(status, "status", "Order status")?; + + let mut body = json!({ + "customer_id": customer_id, + "status": status, + "total": total, + "currency": currency, + }); + if !items.is_empty() { + body["items"] = parse_items(items)?; + } + + let result = client.post(&format!("/ecommerce/shops/{shop_id}/orders"), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Order created: {} (ID: {})", + jstr(&data, "status"), + jstr(&data, "id") + )); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn update( + ctx: &Ctx, + shop_id: &str, + order_id: &str, + customer: Option, + status: Option, + total: Option, + currency: Option, + items: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(customer) = customer { + body["customer_id"] = json!(customer); + } + if let Some(status) = status { + body["status"] = json!(status); + } + if let Some(total) = total { + body["total"] = json!(total); + } + if let Some(currency) = currency { + body["currency"] = json!(currency); + } + if let Some(items) = items { + body["items"] = parse_items(&items)?; + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put( + &format!("/ecommerce/shops/{shop_id}/orders/{order_id}"), + &body, + )?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Order updated: {} (ID: {})", + jstr(&data, "status"), + jstr(&data, "id") + )); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str, order_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!("/ecommerce/shops/{shop_id}/orders/{order_id}"))?; + output::success(&format!("Order {order_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with( + &format!("/ecommerce/shops/{shop_id}/orders"), + &[("limit", "0".to_string())], + )?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total orders: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/product.rs b/src/commands/product.rs new file mode 100644 index 0000000..4d3f151 --- /dev/null +++ b/src/commands/product.rs @@ -0,0 +1,312 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete products within a shop.")] +pub struct Cmd { + /// shop ID (required) + #[arg(long, global = true, default_value = "")] + shop: String, + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List products + List { + /// maximum number of products to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get product details + #[command(arg_required_else_help = true)] + Get { + /// product ID + product_id: String, + }, + /// Create a new product + Create { + /// product name (required) + #[arg(long, default_value = "")] + name: String, + /// product price (required) + #[arg(long, default_value_t = 0.0)] + price: f64, + /// product URL + #[arg(long, default_value = "")] + url: String, + /// product image URL + #[arg(long, default_value = "")] + image_url: String, + /// product description + #[arg(long, default_value = "")] + description: String, + /// product quantity + #[arg(long)] + quantity: Option, + }, + /// Update a product + #[command(arg_required_else_help = true)] + Update { + /// product ID + product_id: String, + /// product name + #[arg(long)] + name: Option, + /// product price + #[arg(long)] + price: Option, + /// product URL + #[arg(long)] + url: Option, + /// product image URL + #[arg(long)] + image_url: Option, + /// product description + #[arg(long)] + description: Option, + /// product quantity + #[arg(long)] + quantity: Option, + }, + /// Delete a product + #[command(arg_required_else_help = true)] + Delete { + /// product ID + product_id: String, + }, + /// Get total product count + Count, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + let shop_id = prompt::require_arg(&cmd.shop, "shop", "Shop ID")?; + match cmd.command { + Sub::List { limit } => list(ctx, &shop_id, limit), + Sub::Get { product_id } => get(ctx, &shop_id, &product_id), + Sub::Create { + name, + price, + url, + image_url, + description, + quantity, + } => create( + ctx, + &shop_id, + &name, + price, + &url, + &image_url, + &description, + quantity, + ), + Sub::Update { + product_id, + name, + price, + url, + image_url, + description, + quantity, + } => update( + ctx, + &shop_id, + &product_id, + name, + price, + url, + image_url, + description, + quantity, + ), + Sub::Delete { product_id } => delete(ctx, &shop_id, &product_id), + Sub::Count => count(ctx, &shop_id), + } +} + +fn list(ctx: &Ctx, shop_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + let products = api::fetch_all_paged( + &client, + &format!("/ecommerce/shops/{shop_id}/products"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&products); + } + + let rows: Vec> = products + .iter() + .map(|p| { + vec![ + jstr(p, "id"), + jstr(p, "name"), + format!("{:.2}", jfloat(p, "price")), + jint(p, "quantity").to_string(), + jstr(p, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "NAME", "PRICE", "QUANTITY", "CREATED"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str, product_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/ecommerce/shops/{shop_id}/products/{product_id}"))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let p = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&p, "id")], + vec!["Name".to_string(), jstr(&p, "name")], + vec!["Price".to_string(), format!("{:.2}", jfloat(&p, "price"))], + vec!["URL".to_string(), jstr(&p, "url")], + vec!["Image URL".to_string(), jstr(&p, "image_url")], + vec![ + "Description".to_string(), + output::truncate(&jstr(&p, "description"), 60), + ], + vec!["Quantity".to_string(), jint(&p, "quantity").to_string()], + vec!["Created".to_string(), jstr(&p, "created_at")], + vec!["Updated".to_string(), jstr(&p, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn create( + ctx: &Ctx, + shop_id: &str, + name: &str, + price: f64, + url: &str, + image_url: &str, + description: &str, + quantity: Option, +) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Product name")?; + + let mut body = json!({ "name": name, "price": price }); + if !url.is_empty() { + body["url"] = json!(url); + } + if !image_url.is_empty() { + body["image_url"] = json!(image_url); + } + if !description.is_empty() { + body["description"] = json!(description); + } + if let Some(quantity) = quantity { + body["quantity"] = json!(quantity); + } + + let result = client.post(&format!("/ecommerce/shops/{shop_id}/products"), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Product created: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn update( + ctx: &Ctx, + shop_id: &str, + product_id: &str, + name: Option, + price: Option, + url: Option, + image_url: Option, + description: Option, + quantity: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(name) = name { + body["name"] = json!(name); + } + if let Some(price) = price { + body["price"] = json!(price); + } + if let Some(url) = url { + body["url"] = json!(url); + } + if let Some(image_url) = image_url { + body["image_url"] = json!(image_url); + } + if let Some(description) = description { + body["description"] = json!(description); + } + if let Some(quantity) = quantity { + body["quantity"] = json!(quantity); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put( + &format!("/ecommerce/shops/{shop_id}/products/{product_id}"), + &body, + )?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Product updated: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str, product_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!("/ecommerce/shops/{shop_id}/products/{product_id}"))?; + output::success(&format!("Product {product_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with( + &format!("/ecommerce/shops/{shop_id}/products"), + &[("limit", "0".to_string())], + )?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total products: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/profile.rs b/src/commands/profile.rs new file mode 100644 index 0000000..917a634 --- /dev/null +++ b/src/commands/profile.rs @@ -0,0 +1,155 @@ +use anyhow::{bail, Result}; +use clap::{Args, Subcommand}; +use serde_json::json; + +use crate::cli::Ctx; +use crate::config; +use crate::output; +use crate::prompt; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// Add a new profile + #[command(arg_required_else_help = true)] + Add { + /// profile name + name: String, + /// API token for this profile + #[arg(long, default_value = "")] + token: String, + }, + /// List all profiles + List, + /// Switch active profile + #[command(arg_required_else_help = true)] + Switch { + /// profile name + name: String, + }, + /// Remove a profile + #[command(arg_required_else_help = true)] + Remove { + /// profile name + name: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::Add { name, token } => add(ctx, &name, token), + Sub::List => list(ctx), + Sub::Switch { name } => switch(&name), + Sub::Remove { name } => remove(ctx, &name), + } +} + +fn add(ctx: &Ctx, name: &str, mut token: String) -> Result<()> { + if token.is_empty() && prompt::is_interactive() { + token = prompt::input("API Token", "")?; + } + if token.is_empty() { + bail!("--token is required"); + } + + let mut cfg = config::load()?; + + if cfg.profiles.contains_key(name) + && !ctx.confirm(&format!("Profile {name:?} already exists. Overwrite?"))? + { + return Ok(()); + } + + cfg.profiles.insert( + name.to_string(), + config::Profile { + api_token: Some(token), + ..Default::default() + }, + ); + if cfg.active_profile.is_empty() { + cfg.active_profile = name.to_string(); + } + + config::save(&cfg)?; + + output::success(&format!("Profile {name:?} added.")); + Ok(()) +} + +fn list(ctx: &Ctx) -> Result<()> { + let cfg = config::load()?; + + if ctx.json { + let profiles: Vec<_> = cfg + .profiles + .iter() + .map(|(name, p)| { + json!({ + "name": name, + "active": *name == cfg.active_profile, + "has_token": p.api_token.as_deref().is_some_and(|t| !t.is_empty()), + }) + }) + .collect(); + return output::json(&profiles); + } + + if cfg.profiles.is_empty() { + println!("No profiles configured. Run 'mailerlite profile add ' to create one."); + return Ok(()); + } + + let rows: Vec> = cfg + .profiles + .keys() + .map(|name| { + let active = if *name == cfg.active_profile { "*" } else { "" }; + vec![active.to_string(), name.clone(), "token".to_string()] + }) + .collect(); + + output::table(&["", "NAME", "METHOD"], &rows); + Ok(()) +} + +fn switch(name: &str) -> Result<()> { + let mut cfg = config::load()?; + + if !cfg.profiles.contains_key(name) { + bail!("profile {name:?} not found"); + } + + cfg.active_profile = name.to_string(); + config::save(&cfg)?; + + output::success(&format!("Switched to profile: {name}")); + Ok(()) +} + +fn remove(ctx: &Ctx, name: &str) -> Result<()> { + let mut cfg = config::load()?; + + if !cfg.profiles.contains_key(name) { + bail!("profile {name:?} not found"); + } + + if !ctx.confirm(&format!("Remove profile {name:?}?"))? { + return Ok(()); + } + + cfg.profiles.remove(name); + if cfg.active_profile == name { + cfg.active_profile = cfg.profiles.keys().next().cloned().unwrap_or_default(); + } + + config::save(&cfg)?; + + output::success(&format!("Profile {name:?} removed.")); + Ok(()) +} diff --git a/src/commands/segment.rs b/src/commands/segment.rs new file mode 100644 index 0000000..23898b3 --- /dev/null +++ b/src/commands/segment.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::json; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jpath, jstr}; + +#[derive(Args)] +#[command(long_about = "List, update, delete segments and view segment subscribers.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List segments + List { + /// maximum number of segments to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Update a segment + #[command(arg_required_else_help = true)] + Update { + /// segment ID + segment_id: String, + /// new segment name (required) + #[arg(long, default_value = "")] + name: String, + }, + /// Delete a segment + #[command(arg_required_else_help = true)] + Delete { + /// segment ID + segment_id: String, + }, + /// List subscribers in a segment + #[command(arg_required_else_help = true)] + Subscribers { + /// segment ID + segment_id: String, + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit } => list(ctx, limit), + Sub::Update { segment_id, name } => update(ctx, &segment_id, &name), + Sub::Delete { segment_id } => delete(ctx, &segment_id), + Sub::Subscribers { segment_id, limit } => subscribers(ctx, &segment_id, limit), + } +} + +fn list(ctx: &Ctx, limit: u64) -> Result<()> { + let client = ctx.client()?; + + let segments = api::fetch_all_paged(&client, "/segments", &[], limit)?; + + if ctx.json { + return output::json(&segments); + } + + let rows: Vec> = segments + .iter() + .map(|s| { + vec![ + jstr(s, "id"), + jstr(s, "name"), + jint(s, "total").to_string(), + jpath(s, "open_rate.string"), + jpath(s, "click_rate.string"), + jstr(s, "created_at"), + ] + }) + .collect(); + + output::table( + &[ + "ID", + "NAME", + "TOTAL", + "OPEN RATE", + "CLICK RATE", + "CREATED AT", + ], + &rows, + ); + Ok(()) +} + +fn update(ctx: &Ctx, segment_id: &str, name: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "New segment name")?; + + let result = client.put(&format!("/segments/{segment_id}"), &json!({ "name": name }))?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Segment {segment_id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, segment_id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Delete segment {segment_id}?"))? { + return Ok(()); + } + + client.delete(&format!("/segments/{segment_id}"))?; + output::success(&format!("Segment {segment_id} deleted successfully.")); + Ok(()) +} + +fn subscribers(ctx: &Ctx, segment_id: &str, limit: u64) -> Result<()> { + let client = ctx.client()?; + + let subscribers = api::fetch_all_after( + &client, + &format!("/segments/{segment_id}/subscribers"), + &[], + limit, + )?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + vec![ + jstr(s, "id"), + jstr(s, "email"), + jstr(s, "status"), + jstr(s, "subscribed_at"), + jstr(s, "created_at"), + ] + }) + .collect(); + + output::table( + &["ID", "EMAIL", "STATUS", "SUBSCRIBED AT", "CREATED AT"], + &rows, + ); + Ok(()) +} diff --git a/src/commands/shop.rs b/src/commands/shop.rs new file mode 100644 index 0000000..427eb95 --- /dev/null +++ b/src/commands/shop.rs @@ -0,0 +1,186 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List, create, update, and delete e-commerce shops.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List shops + List { + /// maximum number of shops to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + }, + /// Get shop details + #[command(arg_required_else_help = true)] + Get { + /// shop ID + shop_id: String, + }, + /// Create a new shop + Create { + /// shop name (required) + #[arg(long, default_value = "")] + name: String, + /// shop URL (required) + #[arg(long, default_value = "")] + url: String, + }, + /// Update a shop + #[command(arg_required_else_help = true)] + Update { + /// shop ID + shop_id: String, + /// shop name + #[arg(long)] + name: Option, + /// shop URL + #[arg(long)] + url: Option, + }, + /// Delete a shop + #[command(arg_required_else_help = true)] + Delete { + /// shop ID + shop_id: String, + }, + /// Get total shop count + Count, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit } => list(ctx, limit), + Sub::Get { shop_id } => get(ctx, &shop_id), + Sub::Create { name, url } => create(ctx, &name, &url), + Sub::Update { shop_id, name, url } => update(ctx, &shop_id, name, url), + Sub::Delete { shop_id } => delete(ctx, &shop_id), + Sub::Count => count(ctx), + } +} + +fn list(ctx: &Ctx, limit: u64) -> Result<()> { + let client = ctx.client()?; + let shops = api::fetch_all_paged(&client, "/ecommerce/shops", &[], limit)?; + + if ctx.json { + return output::json(&shops); + } + + let rows: Vec> = shops + .iter() + .map(|s| { + vec![ + jstr(s, "id"), + jstr(s, "name"), + jstr(s, "url"), + jstr(s, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "NAME", "URL", "CREATED"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/ecommerce/shops/{shop_id}"))?; + + if ctx.json { + return output::json(body.get("data").unwrap_or(&Value::Null)); + } + + let s = body.get("data").cloned().unwrap_or(Value::Null); + let rows = vec![ + vec!["ID".to_string(), jstr(&s, "id")], + vec!["Name".to_string(), jstr(&s, "name")], + vec!["URL".to_string(), jstr(&s, "url")], + vec!["Created".to_string(), jstr(&s, "created_at")], + vec!["Updated".to_string(), jstr(&s, "updated_at")], + ]; + + output::table(&["FIELD", "VALUE"], &rows); + Ok(()) +} + +fn create(ctx: &Ctx, name: &str, url: &str) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Shop name")?; + let url = prompt::require_arg(url, "url", "Shop URL")?; + + let body = json!({ "name": name, "url": url }); + let result = client.post("/ecommerce/shops", &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Shop created: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +fn update(ctx: &Ctx, shop_id: &str, name: Option, url: Option) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(name) = name { + body["name"] = json!(name); + } + if let Some(url) = url { + body["url"] = json!(url); + } + if body.as_object().is_some_and(|o| o.is_empty()) { + anyhow::bail!("no flags provided; use --help to see available options"); + } + + let result = client.put(&format!("/ecommerce/shops/{shop_id}"), &body)?; + + if ctx.json { + return output::json(result.get("data").unwrap_or(&Value::Null)); + } + + let data = result.get("data").cloned().unwrap_or(Value::Null); + output::success(&format!( + "Shop updated: {} (ID: {})", + jstr(&data, "name"), + jstr(&data, "id") + )); + Ok(()) +} + +fn delete(ctx: &Ctx, shop_id: &str) -> Result<()> { + let client = ctx.client()?; + client.delete(&format!("/ecommerce/shops/{shop_id}"))?; + output::success(&format!("Shop {shop_id} deleted.")); + Ok(()) +} + +fn count(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with("/ecommerce/shops", &[("limit", "0".to_string())])?; + + if ctx.json { + return output::json(&json!({ "total": jint(&body, "total") })); + } + + println!("Total shops: {}", jint(&body, "total")); + Ok(()) +} diff --git a/src/commands/subscriber.rs b/src/commands/subscriber.rs new file mode 100644 index 0000000..aabb423 --- /dev/null +++ b/src/commands/subscriber.rs @@ -0,0 +1,306 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::{jfloat, jint, jstr, parse_kv_fields}; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List subscribers + List { + /// maximum number of subscribers to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// filter by status (active, unsubscribed, unconfirmed, bounced, junk) + #[arg(long, default_value = "")] + status: String, + /// filter by email address + #[arg(long, default_value = "")] + email: String, + }, + /// Get total subscriber count + Count, + /// Get subscriber details + #[command(arg_required_else_help = true)] + Get { + /// subscriber ID or email + id_or_email: String, + }, + /// Create or update a subscriber + Upsert { + /// subscriber email (required) + #[arg(long, default_value = "")] + email: String, + /// subscriber status + #[arg(long)] + status: Option, + /// group IDs to assign + #[arg(long, value_delimiter = ',')] + groups: Option>, + /// custom fields as key=value pairs + #[arg(long, value_delimiter = ',')] + fields: Option>, + }, + /// Update a subscriber + #[command(arg_required_else_help = true)] + Update { + /// subscriber ID + id: String, + /// subscriber email + #[arg(long)] + email: Option, + /// subscriber status + #[arg(long)] + status: Option, + /// custom fields as key=value pairs + #[arg(long, value_delimiter = ',')] + fields: Option>, + }, + /// Delete a subscriber + #[command(arg_required_else_help = true)] + Delete { + /// subscriber ID + id: String, + }, + /// Forget a subscriber (GDPR) + #[command( + arg_required_else_help = true, + long_about = "Permanently forget a subscriber and all their data. This action cannot be undone." + )] + Forget { + /// subscriber ID + id: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { + limit, + status, + email, + } => list(ctx, limit, &status, &email), + Sub::Count => count(ctx), + Sub::Get { id_or_email } => get(ctx, &id_or_email), + Sub::Upsert { + email, + status, + groups, + fields, + } => upsert(ctx, &email, status, groups, fields), + Sub::Update { + id, + email, + status, + fields, + } => update(ctx, &id, email, status, fields), + Sub::Delete { id } => delete(ctx, &id), + Sub::Forget { id } => forget(ctx, &id), + } +} + +fn list(ctx: &Ctx, limit: u64, status: &str, email: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !status.is_empty() { + query.push(("filter[status]", status.to_string())); + } + if !email.is_empty() { + query.push(("filter[email]", email.to_string())); + } + + let subscribers = api::fetch_all_cursor(&client, "/subscribers", &query, limit)?; + + if ctx.json { + return output::json(&subscribers); + } + + let rows: Vec> = subscribers + .iter() + .map(|s| { + vec![ + output::truncate(&jstr(s, "email"), 40), + jstr(s, "status"), + jstr(s, "source"), + jint(s, "opens_count").to_string(), + jint(s, "clicks_count").to_string(), + jstr(s, "subscribed_at"), + ] + }) + .collect(); + + output::table( + &[ + "EMAIL", + "STATUS", + "SOURCE", + "OPENS", + "CLICKS", + "SUBSCRIBED AT", + ], + &rows, + ); + Ok(()) +} + +fn count(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let body = client.get_with("/subscribers", &[("limit", "0".to_string())])?; + + if ctx.json { + return output::json(&body); + } + + println!("Total subscribers: {}", jint(&body, "total")); + Ok(()) +} + +fn get(ctx: &Ctx, id_or_email: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/subscribers/{id_or_email}"))?; + + if ctx.json { + return output::json(&body); + } + + let s = body.get("data").cloned().unwrap_or(Value::Null); + println!("ID: {}", jstr(&s, "id")); + println!("Email: {}", jstr(&s, "email")); + println!("Status: {}", jstr(&s, "status")); + println!("Source: {}", jstr(&s, "source")); + println!("Opens: {}", jint(&s, "opens_count")); + println!("Clicks: {}", jint(&s, "clicks_count")); + println!("Open Rate: {:.2}%", jfloat(&s, "open_rate")); + println!("Click Rate: {:.2}%", jfloat(&s, "click_rate")); + println!("Subscribed At: {}", jstr(&s, "subscribed_at")); + println!("Created At: {}", jstr(&s, "created_at")); + println!("Updated At: {}", jstr(&s, "updated_at")); + + if let Some(fields) = s.get("fields").and_then(Value::as_object) { + let set: Vec<(&String, &Value)> = fields.iter().filter(|(_, v)| !v.is_null()).collect(); + if !set.is_empty() { + println!(); + println!("Fields:"); + for (k, v) in set { + match v { + Value::String(s) => println!(" {k}: {s}"), + other => println!(" {k}: {other}"), + } + } + } + } + + if let Some(groups) = s.get("groups").and_then(Value::as_array) { + if !groups.is_empty() { + println!(); + println!("Groups:"); + for g in groups { + println!(" - {} ({})", jstr(g, "name"), jstr(g, "id")); + } + } + } + + Ok(()) +} + +fn upsert( + ctx: &Ctx, + email: &str, + status: Option, + groups: Option>, + fields: Option>, +) -> Result<()> { + let client = ctx.client()?; + let email = prompt::require_arg(email, "email", "Subscriber email")?; + + let mut body = json!({ "email": email }); + if let Some(status) = status { + body["status"] = json!(status); + } + if let Some(groups) = groups { + body["groups"] = json!(groups); + } + if let Some(fields) = fields { + body["fields"] = Value::Object(parse_kv_fields(&fields)?); + } + + let result = client.post("/subscribers", &body)?; + + if ctx.json { + return output::json(&result); + } + + let id = result + .get("data") + .map(|d| jstr(d, "id")) + .unwrap_or_default(); + output::success(&format!("Subscriber upserted successfully. ID: {id}")); + Ok(()) +} + +fn update( + ctx: &Ctx, + id: &str, + email: Option, + status: Option, + fields: Option>, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(email) = email { + body["email"] = json!(email); + } + if let Some(status) = status { + body["status"] = json!(status); + } + if let Some(fields) = fields { + body["fields"] = Value::Object(parse_kv_fields(&fields)?); + } + + let result = client.put(&format!("/subscribers/{id}"), &body)?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Subscriber {id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Delete subscriber {id}?"))? { + return Ok(()); + } + + client.delete(&format!("/subscribers/{id}"))?; + output::success(&format!("Subscriber {id} deleted successfully.")); + Ok(()) +} + +fn forget(ctx: &Ctx, id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!( + "Permanently forget subscriber {id}? This cannot be undone." + ))? { + return Ok(()); + } + + client.post(&format!("/subscribers/{id}/forget"), &json!({}))?; + output::success(&format!("Subscriber {id} forgotten successfully.")); + Ok(()) +} diff --git a/src/commands/timezone.rs b/src/commands/timezone.rs new file mode 100644 index 0000000..66a7419 --- /dev/null +++ b/src/commands/timezone.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::Value; + +use crate::cli::Ctx; +use crate::output; +use crate::util::{jint, jstr}; + +#[derive(Args)] +#[command(long_about = "List available timezones.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List timezones + List, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List => list(ctx), + } +} + +fn list(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let body = client.get("/timezones")?; + + let data = body.get("data").cloned().unwrap_or(Value::Null); + + if ctx.json { + return output::json(&data); + } + + let rows: Vec> = data + .as_array() + .map(|tzs| { + tzs.iter() + .map(|tz| { + vec![ + jstr(tz, "id"), + jstr(tz, "name"), + jint(tz, "offset").to_string(), + ] + }) + .collect() + }) + .unwrap_or_default(); + + output::table(&["ID", "NAME", "OFFSET"], &rows); + Ok(()) +} diff --git a/src/commands/webhook.rs b/src/commands/webhook.rs new file mode 100644 index 0000000..9e72e70 --- /dev/null +++ b/src/commands/webhook.rs @@ -0,0 +1,242 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; + +use crate::api; +use crate::cli::Ctx; +use crate::output; +use crate::prompt; +use crate::util::jstr; + +const VALID_EVENTS: &str = "subscriber.created, subscriber.updated, subscriber.unsubscribed, subscriber.added_to_group, subscriber.removed_from_group, subscriber.bounced, subscriber.automation_triggered, subscriber.automation_completed, campaign.sent, campaign.draft_created"; + +#[derive(Args)] +#[command(long_about = "List, view, create, update, and delete webhooks.")] +pub struct Cmd { + #[command(subcommand)] + command: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List webhooks + List { + /// maximum number of webhooks to return (0 = all) + #[arg(long, default_value_t = 25)] + limit: u64, + /// sort order + #[arg(long, default_value = "")] + sort: String, + }, + /// Get webhook details + #[command(arg_required_else_help = true)] + Get { + /// webhook ID + webhook_id: String, + }, + /// Create a webhook + #[command(long_about = format!("Create a new webhook.\n\nValid events: {VALID_EVENTS}"))] + Create { + /// webhook name (required) + #[arg(long, default_value = "")] + name: String, + /// webhook URL (required) + #[arg(long, default_value = "")] + url: String, + /// webhook events (required) + #[arg(long, value_delimiter = ',')] + events: Option>, + /// whether the webhook is enabled + #[arg(long, num_args = 0..=1, default_missing_value = "true")] + enabled: Option, + }, + /// Update a webhook + #[command( + arg_required_else_help = true, + long_about = format!("Update an existing webhook.\n\nValid events: {VALID_EVENTS}") + )] + Update { + /// webhook ID + webhook_id: String, + /// webhook name + #[arg(long)] + name: Option, + /// webhook URL + #[arg(long)] + url: Option, + /// webhook events + #[arg(long, value_delimiter = ',')] + events: Option>, + /// whether the webhook is enabled + #[arg(long, num_args = 0..=1, default_missing_value = "true")] + enabled: Option, + }, + /// Delete a webhook + #[command(arg_required_else_help = true)] + Delete { + /// webhook ID + webhook_id: String, + }, +} + +pub fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> { + match cmd.command { + Sub::List { limit, sort } => list(ctx, limit, &sort), + Sub::Get { webhook_id } => get(ctx, &webhook_id), + Sub::Create { + name, + url, + events, + enabled: _, + } => create(ctx, &name, &url, events), + Sub::Update { + webhook_id, + name, + url, + events, + enabled, + } => update(ctx, &webhook_id, name, url, events, enabled), + Sub::Delete { webhook_id } => delete(ctx, &webhook_id), + } +} + +fn list(ctx: &Ctx, limit: u64, sort: &str) -> Result<()> { + let client = ctx.client()?; + + let mut query: Vec<(&str, String)> = Vec::new(); + if !sort.is_empty() { + query.push(("sort", sort.to_string())); + } + + let webhooks = api::fetch_all_paged(&client, "/webhooks", &query, limit)?; + + if ctx.json { + return output::json(&webhooks); + } + + let rows: Vec> = webhooks + .iter() + .map(|w| { + let enabled = if w.get("enabled").and_then(Value::as_bool).unwrap_or(false) { + "Yes" + } else { + "No" + }; + vec![ + jstr(w, "id"), + output::truncate(&jstr(w, "name"), 40), + output::truncate(&jstr(w, "url"), 50), + enabled.to_string(), + jstr(w, "created_at"), + ] + }) + .collect(); + + output::table(&["ID", "NAME", "URL", "ENABLED", "CREATED AT"], &rows); + Ok(()) +} + +fn get(ctx: &Ctx, webhook_id: &str) -> Result<()> { + let client = ctx.client()?; + let body = client.get(&format!("/webhooks/{webhook_id}"))?; + + if ctx.json { + return output::json(&body); + } + + let d = body.get("data").cloned().unwrap_or(Value::Null); + + let enabled = if d.get("enabled").and_then(Value::as_bool).unwrap_or(false) { + "Yes" + } else { + "No" + }; + + println!("ID: {}", jstr(&d, "id")); + println!("Name: {}", jstr(&d, "name")); + println!("URL: {}", jstr(&d, "url")); + println!("Enabled: {enabled}"); + println!("Created At: {}", jstr(&d, "created_at")); + println!("Updated At: {}", jstr(&d, "updated_at")); + + println!(); + println!("Events:"); + if let Some(events) = d.get("events").and_then(Value::as_array) { + for e in events { + match e { + Value::String(s) => println!(" - {s}"), + other => println!(" - {other}"), + } + } + } + + Ok(()) +} + +fn create(ctx: &Ctx, name: &str, url: &str, events: Option>) -> Result<()> { + let client = ctx.client()?; + let name = prompt::require_arg(name, "name", "Webhook name")?; + let url = prompt::require_arg(url, "url", "Webhook URL")?; + let events = + prompt::require_slice_arg(&events.unwrap_or_default(), "events", "Webhook events")?; + + let body = json!({ "name": name, "url": url, "events": events }); + let result = client.post("/webhooks", &body)?; + + if ctx.json { + return output::json(&result); + } + + let id = result + .get("data") + .map(|d| jstr(d, "id")) + .unwrap_or_default(); + output::success(&format!("Webhook created successfully. ID: {id}")); + Ok(()) +} + +fn update( + ctx: &Ctx, + webhook_id: &str, + name: Option, + url: Option, + events: Option>, + enabled: Option, +) -> Result<()> { + let client = ctx.client()?; + + let mut body = json!({}); + if let Some(name) = name { + body["name"] = json!(name); + } + if let Some(url) = url { + body["url"] = json!(url); + } + if let Some(events) = events { + body["events"] = json!(events); + } + if let Some(enabled) = enabled { + body["enabled"] = json!(if enabled { "true" } else { "false" }); + } + + let result = client.put(&format!("/webhooks/{webhook_id}"), &body)?; + + if ctx.json { + return output::json(&result); + } + + output::success(&format!("Webhook {webhook_id} updated successfully.")); + Ok(()) +} + +fn delete(ctx: &Ctx, webhook_id: &str) -> Result<()> { + let client = ctx.client()?; + + if !ctx.confirm(&format!("Delete webhook {webhook_id}?"))? { + return Ok(()); + } + + client.delete(&format!("/webhooks/{webhook_id}"))?; + output::success(&format!("Webhook {webhook_id} deleted successfully.")); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9736b4a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +pub const OAUTH_CLIENT_ID: &str = "1118"; +pub const OAUTH_AUTHORIZE_URL: &str = "https://dashboard.mailerlite.com/oauth/authorize"; +pub const OAUTH_TOKEN_URL: &str = "https://dashboard.mailerlite.com/oauth/token"; +pub const OAUTH_SCOPES: &str = "manager-app"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Profile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_refresh_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub active_profile: String, + #[serde(default)] + pub profiles: BTreeMap, +} + +pub fn dir() -> Result { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return Ok(PathBuf::from(xdg).join("mailerlite")); + } + } + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("could not determine home directory"))?; + Ok(home.join(".config").join("mailerlite")) +} + +pub fn path() -> Result { + Ok(dir()?.join("config.yaml")) +} + +pub fn load() -> Result { + let p = path()?; + let data = match std::fs::read_to_string(&p) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), + Err(e) => return Err(e).context("failed to read config"), + }; + let cfg: Config = serde_yaml::from_str(&data).context("failed to parse config")?; + Ok(cfg) +} + +pub fn save(cfg: &Config) -> Result<()> { + let p = path()?; + let parent = p.parent().expect("config path has parent"); + std::fs::create_dir_all(parent).context("failed to create config directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + } + let data = serde_yaml::to_string(cfg).context("failed to marshal config")?; + std::fs::write(&p, data).context("failed to write config")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// Returns the active (or first) profile name and profile. +pub fn active_profile(cfg: &Config) -> Result<(String, Profile)> { + let name = if cfg.active_profile.is_empty() { + cfg.profiles + .keys() + .next() + .cloned() + .ok_or_else(|| anyhow!("no profiles configured — run 'mailerlite auth login' or 'mailerlite profile add '"))? + } else { + cfg.active_profile.clone() + }; + let prof = cfg + .profiles + .get(&name) + .cloned() + .ok_or_else(|| anyhow!("profile {name:?} not found"))?; + Ok((name, prof)) +} + +fn resolve_profile(cfg: &Config, profile_override: Option<&str>) -> Result<(String, Profile)> { + match profile_override { + Some(name) if !name.is_empty() => { + let prof = cfg + .profiles + .get(name) + .cloned() + .ok_or_else(|| anyhow!("profile {name:?} not found"))?; + Ok((name.to_string(), prof)) + } + _ => active_profile(cfg), + } +} + +/// Token resolution: env var MAILERLITE_API_TOKEN > profile override > active +/// profile. OAuth tokens close to expiry are refreshed and persisted. +pub fn get_token(profile_override: Option<&str>) -> Result { + if let Ok(token) = std::env::var("MAILERLITE_API_TOKEN") { + if !token.is_empty() { + return Ok(token); + } + } + + let mut cfg = load()?; + let (name, prof) = resolve_profile(&cfg, profile_override)?; + + if let Some(token) = prof.api_token.as_deref() { + if !token.is_empty() { + return Ok(token.to_string()); + } + } + if let Some(oauth_token) = prof.oauth_token.clone() { + if let (Some(expires_at), Some(refresh_token)) = ( + prof.oauth_expires_at.as_deref(), + prof.oauth_refresh_token.clone(), + ) { + if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at) { + let now = chrono::Utc::now(); + if now > expires_at.with_timezone(&chrono::Utc) - chrono::Duration::minutes(5) { + match refresh_oauth_token(&refresh_token) { + Ok(mut refreshed) => { + refreshed.account_id = prof.account_id.clone(); + let token = refreshed.oauth_token.clone().unwrap_or_default(); + cfg.profiles.insert(name, refreshed); + let _ = save(&cfg); + return Ok(token); + } + Err(refresh_err) => { + // If refresh fails but token isn't actually expired yet, use it anyway. + if now < expires_at.with_timezone(&chrono::Utc) { + return Ok(oauth_token); + } + bail!("OAuth token expired and refresh failed: {refresh_err}"); + } + } + } + } + } + return Ok(oauth_token); + } + bail!("no token found — run 'mailerlite auth login' or set MAILERLITE_API_TOKEN") +} + +/// Returns the stored account ID for the active (or overridden) profile. +pub fn get_account_id(profile_override: Option<&str>) -> Option { + let cfg = load().ok()?; + let (_, prof) = resolve_profile(&cfg, profile_override).ok()?; + prof.account_id.filter(|s| !s.is_empty()) +} + +/// Exchanges a refresh token for a new access token. +pub fn refresh_oauth_token(refresh_token: &str) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + let resp = agent + .post(OAUTH_TOKEN_URL) + .send_form(&[ + ("grant_type", "refresh_token"), + ("client_id", OAUTH_CLIENT_ID), + ("refresh_token", refresh_token), + ]) + .map_err(|e| match e { + ureq::Error::Status(code, _) => anyhow!("refresh failed (HTTP {code})"), + other => anyhow!("refresh request failed: {other}"), + })?; + + #[derive(Deserialize)] + struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: String, + #[serde(default)] + expires_in: i64, + } + let tok: TokenResponse = resp + .into_json() + .context("failed to parse refresh response")?; + if tok.access_token.is_empty() { + bail!("server returned empty access token on refresh"); + } + + let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(tok.expires_in)) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + + Ok(Profile { + oauth_token: Some(tok.access_token), + oauth_refresh_token: Some(tok.refresh_token).filter(|s| !s.is_empty()), + oauth_expires_at: Some(expires_at), + ..Profile::default() + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..301aa5d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,18 @@ +mod api; +mod cli; +mod commands; +mod config; +mod output; +mod prompt; +mod tui; +mod util; + +fn main() { + // Rust ignores SIGPIPE by default; restore the conventional CLI behavior + // so piping into `head` etc. terminates silently instead of panicking. + #[cfg(unix)] + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } + std::process::exit(cli::run()); +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..8496cee --- /dev/null +++ b/src/output.rs @@ -0,0 +1,138 @@ +use serde::Serialize; +use unicode_width::UnicodeWidthStr; + +fn no_color() -> bool { + std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) +} + +fn paint(code: &str, text: &str) -> String { + if no_color() { + text.to_string() + } else { + format!("\x1b[{code}m{text}\x1b[0m") + } +} + +/// Bold bright blue — table headers. +pub fn header(text: &str) -> String { + paint("1;94", text) +} + +/// Bright green — success messages (stdout). +pub fn success(msg: &str) { + println!("{}", paint("92", msg)); +} + +/// Bright red — error messages (stderr). +pub fn error(msg: &str) { + eprintln!("{}", paint("91", msg)); +} + +/// Bright black (gray) — dim text. +pub fn dim(text: &str) -> String { + paint("90", text) +} + +/// Pretty-prints a value as 2-space-indented JSON to stdout. +pub fn json(v: &T) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(v)?); + Ok(()) +} + +/// Renders a bordered table (plain aligned columns when NO_COLOR is set). +/// Prints "No results found." when rows is empty. +pub fn table(headers: &[&str], rows: &[Vec]) { + if rows.is_empty() { + println!("{}", dim("No results found.")); + return; + } + if no_color() { + plain_table(headers, rows); + return; + } + + let widths = column_widths(headers, rows); + let border = |s: &str| dim(s); + + let rule = |left: &str, mid: &str, right: &str| { + let mut line = String::from(left); + for (i, w) in widths.iter().enumerate() { + if i > 0 { + line.push_str(mid); + } + line.push_str(&"─".repeat(w + 2)); + } + line.push_str(right); + border(&line) + }; + + let sep = border("│"); + let render_row = |cells: &[String], style: fn(&str) -> String| { + let mut line = String::new(); + line.push_str(&sep); + for (i, w) in widths.iter().enumerate() { + let cell = cells.get(i).map(String::as_str).unwrap_or(""); + let pad = w - cell.width(); + line.push(' '); + line.push_str(&style(cell)); + line.push_str(&" ".repeat(pad + 1)); + line.push_str(&sep); + } + line + }; + + println!("{}", rule("┌", "┬", "┐")); + let hdr: Vec = headers.iter().map(|h| h.to_string()).collect(); + println!("{}", render_row(&hdr, header)); + println!("{}", rule("├", "┼", "┤")); + for row in rows { + println!("{}", render_row(row, |s| s.to_string())); + } + println!("{}", rule("└", "┴", "┘")); +} + +fn plain_table(headers: &[&str], rows: &[Vec]) { + let widths = column_widths(headers, rows); + let mut line = String::new(); + for (i, h) in headers.iter().enumerate() { + let h = h.to_uppercase(); + line.push_str(&h); + line.push_str(&" ".repeat(widths[i] + 2 - h.width())); + } + println!("{}", line.trim_end()); + for row in rows { + let mut line = String::new(); + for (i, w) in widths.iter().enumerate() { + let cell = row.get(i).map(String::as_str).unwrap_or(""); + line.push_str(cell); + line.push_str(&" ".repeat(w + 2 - cell.width())); + } + println!("{}", line.trim_end()); + } +} + +fn column_widths(headers: &[&str], rows: &[Vec]) -> Vec { + let mut widths: Vec = headers.iter().map(|h| h.width()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < widths.len() && cell.width() > widths[i] { + widths[i] = cell.width(); + } + } + } + widths +} + +/// Truncates to at most `max` characters, appending "..." when trimmed. +pub fn truncate(s: &str, max: usize) -> String { + let count = s.chars().count(); + if count <= max { + return s.to_string(); + } + if max <= 3 { + return s.chars().take(max).collect(); + } + let mut out: String = s.chars().take(max - 3).collect(); + out.push_str("..."); + out +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..9e5a4d2 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,60 @@ +use std::io::IsTerminal; + +use anyhow::{bail, Result}; + +pub fn is_interactive() -> bool { + std::io::stdin().is_terminal() +} + +pub fn input(label: &str, placeholder: &str) -> Result { + let mut p = inquire::Text::new(label); + if !placeholder.is_empty() { + p = p.with_placeholder(placeholder); + } + Ok(p.prompt()?.trim().to_string()) +} + +pub fn confirm(label: &str) -> Result { + Ok(inquire::Confirm::new(label).with_default(false).prompt()?) +} + +/// Presents `labels` for selection and returns the matching entry of `values`. +pub fn select_labeled(label: &str, labels: Vec, values: Vec) -> Result { + debug_assert_eq!(labels.len(), values.len()); + let chosen = inquire::Select::new(label, labels.clone()).prompt()?; + let idx = labels.iter().position(|l| *l == chosen).unwrap_or(0); + Ok(values[idx].clone()) +} + +/// Returns `value` if non-empty; otherwise prompts interactively or fails +/// with a "--flag is required" error in non-interactive mode. +pub fn require_arg(value: &str, flag: &str, label: &str) -> Result { + if !value.is_empty() { + return Ok(value.to_string()); + } + if !is_interactive() { + bail!("--{flag} is required"); + } + input(label, "") +} + +/// Like [`require_arg`] for repeated values; prompts for a comma-separated list. +pub fn require_slice_arg(values: &[String], flag: &str, label: &str) -> Result> { + if !values.is_empty() { + return Ok(values.to_vec()); + } + if !is_interactive() { + bail!("--{flag} is required"); + } + let raw = input(&format!("{label} (comma-separated)"), "")?; + let result: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if result.is_empty() { + bail!("--{flag} is required"); + } + Ok(result) +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..b5f5a43 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,318 @@ +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::backend::Backend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{Frame, Terminal}; +use serde_json::Value; + +use crate::api::Client; + +use super::components::{help, sidebar::Sidebar, spinner::Spinner, statusbar::StatusBar}; +use super::theme; +use super::views::{self, KeyResult, View, ViewType}; + +pub enum Msg { + Loaded(ViewType, Vec), + Error(ViewType, String), +} + +#[derive(PartialEq, Eq)] +enum Focus { + Sidebar, + Content, +} + +pub struct App { + client: Client, + profile: String, + tx: Sender, + rx: Receiver, + sidebar: Sidebar, + statusbar: StatusBar, + spinner: Spinner, + views: Vec, + active: ViewType, + focus: Focus, + show_help: bool, + err: Option, +} + +impl App { + pub fn new(client: Client, profile: String) -> Self { + let (tx, rx) = mpsc::channel(); + Self { + client, + profile, + tx, + rx, + sidebar: Sidebar::new(), + statusbar: StatusBar::new(), + spinner: Spinner::new("Loading..."), + views: ViewType::ALL.iter().map(|&k| View::new(k)).collect(), + active: ViewType::Subscribers, + focus: Focus::Content, + show_help: false, + err: None, + } + } + + pub fn run(&mut self, terminal: &mut Terminal) -> Result<()> { + self.fetch(self.active); + + loop { + self.drain_msgs(); + terminal.draw(|frame| self.draw(frame))?; + + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press && self.handle_key(key) => { + return Ok(()); + } + _ => {} + } + } + + self.spinner.tick(); + } + } + + fn drain_msgs(&mut self) { + while let Ok(msg) = self.rx.try_recv() { + match msg { + Msg::Loaded(kind, items) => { + self.views[kind.index()].set_loaded(items); + } + Msg::Error(kind, err) => { + let view = &mut self.views[kind.index()]; + view.loading = false; + view.table.set_loading(false); + self.err = Some(err); + } + } + } + if self.views[self.active.index()].loading { + self.spinner.start(); + } else { + self.spinner.stop(); + } + } + + fn fetch(&mut self, kind: ViewType) { + self.err = None; + let view = &mut self.views[kind.index()]; + view.loading = true; + view.table.set_loading(true); + let tab = view.tab; + + self.spinner + .set_label(format!("Loading {}...", kind.label())); + self.spinner.start(); + + let client = self.client.clone(); + let tx = self.tx.clone(); + thread::spawn(move || { + let result = match kind { + ViewType::Subscribers => views::subscribers::fetch(&client), + ViewType::Campaigns => views::campaigns::fetch(&client), + ViewType::Automations => views::automations::fetch(&client), + ViewType::Groups => views::groups::fetch(&client), + ViewType::Forms => views::forms::fetch(&client, tab), + }; + let _ = tx.send(match result { + Ok(items) => Msg::Loaded(kind, items), + Err(e) => Msg::Error(kind, e.to_string()), + }); + }); + } + + /// Returns true when the app should quit. + fn handle_key(&mut self, key: KeyEvent) -> bool { + let code = key.code; + + if self.show_help { + if matches!(code, KeyCode::Char('?') | KeyCode::Esc | KeyCode::Backspace) { + self.show_help = false; + } + return false; + } + + match code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return true; + } + KeyCode::Char('q') => return true, + KeyCode::Char('?') => { + self.show_help = true; + return false; + } + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Sidebar => Focus::Content, + Focus::Content => Focus::Sidebar, + }; + return false; + } + KeyCode::Char('1') => { + self.switch_view(ViewType::Subscribers); + return false; + } + KeyCode::Char('2') => { + self.switch_view(ViewType::Campaigns); + return false; + } + KeyCode::Char('3') => { + self.switch_view(ViewType::Automations); + return false; + } + KeyCode::Char('4') => { + self.switch_view(ViewType::Groups); + return false; + } + KeyCode::Char('5') => { + self.switch_view(ViewType::Forms); + return false; + } + _ => {} + } + + match self.focus { + Focus::Sidebar => self.handle_sidebar_key(code), + Focus::Content => { + if let KeyResult::Fetch = self.views[self.active.index()].handle_key(code) { + self.fetch(self.active); + } + } + } + + false + } + + fn handle_sidebar_key(&mut self, code: KeyCode) { + match code { + KeyCode::Char('j') | KeyCode::Down => { + self.sidebar.next(); + self.switch_view(self.sidebar.active()); + } + KeyCode::Char('k') | KeyCode::Up => { + self.sidebar.prev(); + self.switch_view(self.sidebar.active()); + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + self.focus = Focus::Content; + } + _ => {} + } + } + + fn switch_view(&mut self, kind: ViewType) { + if self.active == kind { + return; + } + self.active = kind; + self.sidebar.set_active(kind); + self.fetch(kind); + } + + fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + if area.width == 0 || area.height == 0 { + return; + } + + let [header_area, content_area, status_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(2), + ]) + .areas(area); + + self.draw_header(frame, header_area); + + let [sidebar_area, main_area] = Layout::horizontal([ + Constraint::Length(super::components::sidebar::WIDTH), + Constraint::Min(0), + ]) + .areas(content_area); + + self.sidebar.set_focused(self.focus == Focus::Sidebar); + self.sidebar.render(frame, sidebar_area); + + self.draw_main(frame, main_area); + self.draw_status(frame, status_area); + + if self.show_help { + help::render(frame, area); + } + } + + fn draw_header(&self, frame: &mut Frame, area: Rect) { + let block = Block::new() + .borders(Borders::BOTTOM) + .border_style(Style::new().fg(theme::MUTED)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let title = " MailerLite Dashboard "; + let profile = format!("profile: {}", self.profile); + let gap = (inner.width as usize) + .saturating_sub(title.chars().count() + profile.chars().count() + 4) + .max(1); + + let line = Line::from(vec![ + Span::styled( + title, + Style::new().fg(theme::PRIMARY).add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(gap)), + Span::styled(profile, Style::new().fg(theme::MUTED)), + ]); + frame.render_widget(Paragraph::new(line), inner); + } + + fn draw_main(&mut self, frame: &mut Frame, area: Rect) { + let mut inner = area; + inner.x += 1; + inner.width = inner.width.saturating_sub(2); + + if let Some(err) = &self.err { + let [err_area, rest] = + Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner); + frame.render_widget( + Paragraph::new(Line::styled( + format!("Error: {err}"), + Style::new().fg(theme::ERROR), + )), + err_area, + ); + inner = rest; + } + + let view = &mut self.views[self.active.index()]; + view.table + .set_focused(self.focus == Focus::Content && !view.showing_detail); + view.render(frame, inner); + } + + fn draw_status(&mut self, frame: &mut Frame, area: Rect) { + self.statusbar.set_profile(self.profile.clone()); + + let view = &self.views[self.active.index()]; + if view.loading { + self.statusbar.set_left(self.active.label()); + self.statusbar.set_loading(true, self.spinner.view()); + } else { + self.statusbar + .set_left(format!("{} ({})", self.active.label(), view.items.len())); + self.statusbar.set_loading(false, ""); + } + + self.statusbar.render(frame, area); + } +} diff --git a/src/tui/components/detail.rs b/src/tui/components/detail.rs new file mode 100644 index 0000000..e8e3642 --- /dev/null +++ b/src/tui/components/detail.rs @@ -0,0 +1,79 @@ +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; + +use super::pad_right; +use crate::tui::theme; + +pub struct DetailRow { + pub label: String, + pub value: String, +} + +impl DetailRow { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + } + } +} + +pub struct DetailPanel { + title: String, + rows: Vec, +} + +impl DetailPanel { + pub fn new() -> Self { + Self { + title: String::new(), + rows: Vec::new(), + } + } + + pub fn set_title(&mut self, title: impl Into) { + self.title = title.into(); + } + + pub fn set_rows(&mut self, rows: Vec) { + self.rows = rows; + } + + pub fn lines(&self) -> Vec> { + let label_style = Style::new().fg(theme::MUTED); + let value_style = Style::new().fg(theme::TEXT); + + let mut lines: Vec> = vec![ + Line::styled( + self.title.clone(), + Style::new().fg(theme::PRIMARY).add_modifier(Modifier::BOLD), + ), + Line::raw(""), + ]; + + for row in &self.rows { + if row.label.is_empty() && row.value.is_empty() { + lines.push(Line::raw("")); + continue; + } + let label = if row.label.is_empty() { + pad_right("", 20) + } else { + pad_right(&format!("{}:", row.label), 20) + }; + lines.push(Line::from(vec![ + Span::styled(label, label_style), + Span::raw(" "), + Span::styled(row.value.clone(), value_style), + ])); + } + + lines.push(Line::raw("")); + lines.push(Line::styled( + "Press Esc or Backspace to go back", + Style::new().fg(theme::MUTED).add_modifier(Modifier::ITALIC), + )); + + lines + } +} diff --git a/src/tui/components/help.rs b/src/tui/components/help.rs new file mode 100644 index 0000000..219ec86 --- /dev/null +++ b/src/tui/components/help.rs @@ -0,0 +1,59 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Padding, Paragraph}; +use ratatui::Frame; + +use super::pad_right; +use crate::tui::keys; +use crate::tui::theme; + +pub fn render(frame: &mut Frame, area: Rect) { + let key_style = Style::new().fg(theme::KEY); + let desc_style = Style::new().fg(theme::TEXT_SUB); + + let mut lines: Vec> = vec![ + Line::styled( + "Keyboard Shortcuts", + Style::new().fg(theme::PRIMARY).add_modifier(Modifier::BOLD), + ), + Line::raw(""), + ]; + + for (i, section) in keys::HELP_SECTIONS.iter().enumerate() { + if i > 0 { + lines.push(Line::raw("")); + } + lines.push(Line::styled( + section.title, + Style::new().add_modifier(Modifier::BOLD), + )); + for binding in section.bindings { + lines.push(Line::from(vec![ + Span::styled(pad_right(binding.key, 14), key_style), + Span::styled(binding.desc, desc_style), + ])); + } + } + + lines.push(Line::raw("")); + lines.push(Line::styled( + "Press ? or Esc to close", + Style::new().fg(theme::MUTED), + )); + + let overlay_width = 44u16.min(area.width); + let overlay_height = (lines.len() as u16 + 4).min(area.height); + let x = area.x + area.width.saturating_sub(overlay_width) / 2; + let y = area.y + area.height.saturating_sub(overlay_height) / 2; + let overlay = Rect::new(x, y, overlay_width, overlay_height); + + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(theme::PRIMARY)) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::new().bg(theme::BG_OVERLAY)); + + frame.render_widget(Clear, overlay); + frame.render_widget(Paragraph::new(lines).block(block), overlay); +} diff --git a/src/tui/components/mod.rs b/src/tui/components/mod.rs new file mode 100644 index 0000000..bcdb0d3 --- /dev/null +++ b/src/tui/components/mod.rs @@ -0,0 +1,19 @@ +pub mod detail; +pub mod help; +pub mod sidebar; +pub mod spinner; +pub mod statusbar; +pub mod table; + +pub fn pad_right(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + width - len); + out.push_str(s); + for _ in len..width { + out.push(' '); + } + out +} diff --git a/src/tui/components/sidebar.rs b/src/tui/components/sidebar.rs new file mode 100644 index 0000000..d825fb7 --- /dev/null +++ b/src/tui/components/sidebar.rs @@ -0,0 +1,83 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Padding, Paragraph}; +use ratatui::Frame; + +use super::pad_right; +use crate::tui::theme; +use crate::tui::views::ViewType; + +pub const WIDTH: u16 = 22; + +pub struct Sidebar { + active: ViewType, + focused: bool, +} + +impl Sidebar { + pub fn new() -> Self { + Self { + active: ViewType::Subscribers, + focused: false, + } + } + + pub fn set_active(&mut self, v: ViewType) { + self.active = v; + } + + pub fn active(&self) -> ViewType { + self.active + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + pub fn next(&mut self) { + let idx = (self.active.index() + 1) % ViewType::ALL.len(); + self.active = ViewType::ALL[idx]; + } + + pub fn prev(&mut self) { + let idx = if self.active.index() == 0 { + ViewType::ALL.len() - 1 + } else { + self.active.index() - 1 + }; + self.active = ViewType::ALL[idx]; + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let block = Block::new() + .borders(Borders::RIGHT) + .border_style(Style::new().fg(theme::MUTED)) + .padding(Padding::new(1, 1, 1, 1)); + + let mut lines: Vec = Vec::new(); + for v in ViewType::ALL { + let is_active = v == self.active; + let prefix = if is_active { "▸ " } else { " " }; + let text = pad_right(&format!("{}{} {}", prefix, v.icon(), v.label()), 16); + let style = if is_active { + if self.focused { + Style::new() + .fg(theme::TEXT) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::new() + .fg(theme::PRIMARY) + .bg(theme::BG_SELECTED) + .add_modifier(Modifier::BOLD) + } + } else { + Style::new() + }; + lines.push(Line::styled(format!(" {text} "), style)); + } + + frame.render_widget(Paragraph::new(lines).block(block), area); + } +} diff --git a/src/tui/components/spinner.rs b/src/tui/components/spinner.rs new file mode 100644 index 0000000..2e5b7c1 --- /dev/null +++ b/src/tui/components/spinner.rs @@ -0,0 +1,42 @@ +const FRAMES: [&str; 8] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; + +pub struct Spinner { + frame: usize, + label: String, + visible: bool, +} + +impl Spinner { + pub fn new(label: impl Into) -> Self { + Self { + frame: 0, + label: label.into(), + visible: false, + } + } + + pub fn start(&mut self) { + self.visible = true; + } + + pub fn stop(&mut self) { + self.visible = false; + } + + pub fn set_label(&mut self, label: impl Into) { + self.label = label.into(); + } + + pub fn tick(&mut self) { + if self.visible { + self.frame = (self.frame + 1) % FRAMES.len(); + } + } + + pub fn view(&self) -> String { + if !self.visible { + return String::new(); + } + format!("{} {}", FRAMES[self.frame], self.label) + } +} diff --git a/src/tui/components/statusbar.rs b/src/tui/components/statusbar.rs new file mode 100644 index 0000000..5f4e408 --- /dev/null +++ b/src/tui/components/statusbar.rs @@ -0,0 +1,104 @@ +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::tui::theme; + +pub struct StatusBar { + left: String, + profile: String, + loading: bool, + load_text: String, +} + +impl StatusBar { + pub fn new() -> Self { + Self { + left: String::new(), + profile: String::new(), + loading: false, + load_text: String::new(), + } + } + + pub fn set_left(&mut self, text: impl Into) { + self.left = text.into(); + } + + pub fn set_profile(&mut self, name: impl Into) { + self.profile = name.into(); + } + + pub fn set_loading(&mut self, loading: bool, text: impl Into) { + self.loading = loading; + self.load_text = text.into(); + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let block = Block::new() + .borders(Borders::TOP) + .border_style(Style::new().fg(theme::MUTED)); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let width = inner.width as usize; + let muted = Style::new().fg(theme::MUTED); + let key_style = Style::new().fg(theme::KEY); + let value_style = Style::new().fg(theme::TEXT_SUB); + + let center = if self.loading { + self.load_text.clone() + } else { + String::new() + }; + + let mut hints: Vec<(&str, String)> = vec![ + ("↑↓", "navigate".to_string()), + ("enter", "select".to_string()), + ("?", "help".to_string()), + ]; + if !self.profile.is_empty() { + hints.push(("profile:", self.profile.clone())); + } + + let mut right_spans: Vec = Vec::new(); + let mut right_len = 0usize; + for (i, (key, desc)) in hints.iter().enumerate() { + if i > 0 { + right_spans.push(Span::raw(" ")); + right_len += 2; + } + right_spans.push(Span::styled(key.to_string(), key_style)); + right_spans.push(Span::raw(" ")); + right_spans.push(Span::styled(desc.clone(), value_style)); + right_len += key.chars().count() + 1 + desc.chars().count(); + } + + let left_len = self.left.chars().count(); + let center_len = center.chars().count(); + let content_width = width.saturating_sub(4); + + let mut spans: Vec = vec![Span::styled(self.left.clone(), muted)]; + + if left_len + center_len + right_len >= content_width { + let gap = content_width.saturating_sub(left_len + right_len).max(1); + spans.push(Span::raw(" ".repeat(gap))); + spans.extend(right_spans); + } else { + let remaining = content_width - left_len - center_len - right_len; + let left_gap = remaining / 2; + let right_gap = remaining - left_gap; + spans.push(Span::raw(" ".repeat(left_gap))); + spans.push(Span::styled(center, muted)); + spans.push(Span::raw(" ".repeat(right_gap))); + spans.extend(right_spans); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), inner); + } +} diff --git a/src/tui/components/table.rs b/src/tui/components/table.rs new file mode 100644 index 0000000..3951860 --- /dev/null +++ b/src/tui/components/table.rs @@ -0,0 +1,215 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use super::pad_right; +use crate::tui::theme; + +pub struct Column { + pub title: &'static str, + pub width: usize, +} + +#[derive(Clone)] +pub struct Cell { + pub text: String, + pub fg: Option, +} + +impl Cell { + pub fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + fg: None, + } + } + + pub fn colored(text: impl Into, fg: Color) -> Self { + Self { + text: text.into(), + fg: Some(fg), + } + } +} + +pub struct Table { + columns: Vec, + rows: Vec>, + cursor: usize, + offset: usize, + width: usize, + height: usize, + focused: bool, + loading: bool, + empty_msg: &'static str, +} + +impl Table { + pub fn new(columns: Vec, empty_msg: &'static str) -> Self { + Self { + columns, + rows: Vec::new(), + cursor: 0, + offset: 0, + width: 0, + height: 0, + focused: false, + loading: false, + empty_msg, + } + } + + pub fn set_rows(&mut self, rows: Vec>) { + self.rows = rows; + self.cursor = 0; + self.offset = 0; + } + + pub fn set_size(&mut self, width: usize, height: usize) { + self.width = width; + self.height = height; + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + pub fn set_loading(&mut self, loading: bool) { + self.loading = loading; + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn move_up(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + self.update_offset(); + } + } + + pub fn move_down(&mut self) { + if self.cursor + 1 < self.rows.len() { + self.cursor += 1; + self.update_offset(); + } + } + + pub fn goto_top(&mut self) { + self.cursor = 0; + self.offset = 0; + } + + pub fn goto_bottom(&mut self) { + if !self.rows.is_empty() { + self.cursor = self.rows.len() - 1; + } + self.update_offset(); + } + + fn update_offset(&mut self) { + let visible = self.visible_row_count(); + if visible == 0 { + return; + } + if self.cursor >= self.offset + visible { + self.offset = self.cursor - visible + 1; + } + if self.cursor < self.offset { + self.offset = self.cursor; + } + } + + fn visible_row_count(&self) -> usize { + self.height.saturating_sub(3) + } + + fn content_width(&self) -> usize { + let widths: usize = self.columns.iter().map(|c| c.width).sum(); + widths + 2 * self.columns.len().saturating_sub(1) + } + + fn fit(&self, text: &str, width: usize) -> String { + let len = text.chars().count(); + if len > width { + if width > 3 { + let truncated: String = text.chars().take(width - 3).collect(); + return format!("{truncated}..."); + } + return text.chars().take(width).collect(); + } + pad_right(text, width) + } + + pub fn lines(&self) -> Vec> { + let empty_style = Style::new().fg(theme::MUTED).add_modifier(Modifier::ITALIC); + + if self.loading { + return vec![Line::styled("Loading...", empty_style)]; + } + if self.rows.is_empty() { + return vec![Line::styled(self.empty_msg, empty_style)]; + } + + let mut lines = Vec::new(); + + let header: Vec = self + .columns + .iter() + .map(|c| self.fit(c.title, c.width)) + .collect(); + lines.push(Line::styled( + header.join(" "), + Style::new().fg(theme::PRIMARY).add_modifier(Modifier::BOLD), + )); + lines.push(Line::styled( + "─".repeat(self.content_width()), + Style::new().fg(theme::MUTED), + )); + + let visible = self.visible_row_count().max(1); + let start = self.offset; + let end = (start + visible).min(self.rows.len()); + + for i in start..end { + let row = &self.rows[i]; + if i == self.cursor { + let bg = if self.focused { + theme::ACCENT + } else { + theme::BG_SELECTED + }; + let cells: Vec = self + .columns + .iter() + .enumerate() + .map(|(idx, col)| { + let text = row.get(idx).map(|c| c.text.as_str()).unwrap_or(""); + self.fit(text, col.width) + }) + .collect(); + let content = pad_right(&cells.join(" "), self.width.saturating_sub(4)); + lines.push(Line::styled(content, Style::new().bg(bg).fg(theme::TEXT))); + } else { + let mut spans: Vec> = Vec::new(); + for (idx, col) in self.columns.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + let (text, fg) = row + .get(idx) + .map(|c| (c.text.as_str(), c.fg)) + .unwrap_or(("", None)); + let fitted = self.fit(text, col.width); + match fg { + Some(color) => spans.push(Span::styled(fitted, Style::new().fg(color))), + None => spans.push(Span::raw(fitted)), + } + } + lines.push(Line::from(spans)); + } + } + + lines + } +} diff --git a/src/tui/keys.rs b/src/tui/keys.rs new file mode 100644 index 0000000..3e0b251 --- /dev/null +++ b/src/tui/keys.rs @@ -0,0 +1,86 @@ +pub struct Binding { + pub key: &'static str, + pub desc: &'static str, +} + +pub struct Section { + pub title: &'static str, + pub bindings: &'static [Binding], +} + +pub const HELP_SECTIONS: [Section; 4] = [ + Section { + title: "Navigation", + bindings: &[ + Binding { + key: "↑/k", + desc: "up", + }, + Binding { + key: "↓/j", + desc: "down", + }, + Binding { + key: "enter", + desc: "select", + }, + Binding { + key: "esc", + desc: "back", + }, + ], + }, + Section { + title: "Actions", + bindings: &[ + Binding { + key: "tab", + desc: "next panel", + }, + Binding { + key: "r", + desc: "refresh", + }, + Binding { + key: "p", + desc: "switch profile", + }, + Binding { + key: "?", + desc: "help", + }, + ], + }, + Section { + title: "Views", + bindings: &[ + Binding { + key: "1", + desc: "subscribers", + }, + Binding { + key: "2", + desc: "campaigns", + }, + Binding { + key: "3", + desc: "automations", + }, + Binding { + key: "4", + desc: "groups", + }, + Binding { + key: "5", + desc: "forms", + }, + ], + }, + Section { + title: "General", + bindings: &[Binding { + key: "q", + desc: "quit", + }], + }, +]; diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..835ab1a --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,43 @@ +mod app; +mod components; +mod keys; +mod theme; +mod views; + +use std::io; + +use anyhow::Result; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::cli::Ctx; + +pub fn run(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let profile = ctx.profile.clone().unwrap_or_else(|| "default".to_string()); + + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + original_hook(info); + })); + + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut app = app::App::new(client, profile); + let result = app.run(&mut terminal); + + restore_terminal(); + result +} + +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..d4d1df9 --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,12 @@ +use ratatui::style::Color; + +pub const PRIMARY: Color = Color::Indexed(12); +pub const ACCENT: Color = Color::Indexed(24); +pub const MUTED: Color = Color::Indexed(245); +pub const TEXT: Color = Color::Indexed(231); +pub const TEXT_SUB: Color = Color::Indexed(250); +pub const SUCCESS: Color = Color::Indexed(10); +pub const ERROR: Color = Color::Indexed(9); +pub const KEY: Color = Color::Indexed(14); +pub const BG_SELECTED: Color = Color::Indexed(238); +pub const BG_OVERLAY: Color = Color::Indexed(237); diff --git a/src/tui/views/automations.rs b/src/tui/views/automations.rs new file mode 100644 index 0000000..db524d1 --- /dev/null +++ b/src/tui/views/automations.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use serde_json::Value; + +use super::jpath_int; +use crate::api::{self, Client}; +use crate::tui::components::detail::DetailRow; +use crate::tui::components::table::{Cell, Column}; +use crate::tui::theme; +use crate::util::{jint, jstr}; + +pub const EMPTY: &str = "No automations found."; + +pub fn columns() -> Vec { + vec![ + Column { + title: "NAME", + width: 30, + }, + Column { + title: "ENABLED", + width: 9, + }, + Column { + title: "EMAILS", + width: 8, + }, + Column { + title: "COMPLETED", + width: 10, + }, + Column { + title: "IN QUEUE", + width: 10, + }, + ] +} + +pub fn fetch(client: &Client) -> Result> { + api::fetch_all_paged(client, "/automations", &[], 100) +} + +fn enabled(v: &Value) -> bool { + v.get("enabled").and_then(Value::as_bool).unwrap_or(false) +} + +fn enabled_badge(on: bool) -> Cell { + if on { + Cell::colored("yes", theme::SUCCESS) + } else { + Cell::colored("no", theme::MUTED) + } +} + +pub fn row(a: &Value) -> Vec { + vec![ + Cell::plain(jstr(a, "name")), + enabled_badge(enabled(a)), + Cell::plain(jint(a, "emails_count").to_string()), + Cell::plain(jpath_int(a, "stats.completed_subscribers_count").to_string()), + Cell::plain(jpath_int(a, "stats.subscribers_in_queue_count").to_string()), + ] +} + +pub fn detail(a: &Value) -> (String, Vec) { + ( + format!("Automation: {}", jstr(a, "name")), + vec![ + DetailRow::new("ID", jstr(a, "id")), + DetailRow::new("Name", jstr(a, "name")), + DetailRow::new("Enabled", if enabled(a) { "Yes" } else { "No" }), + DetailRow::new("Emails", jint(a, "emails_count").to_string()), + DetailRow::new( + "Completed", + jpath_int(a, "stats.completed_subscribers_count").to_string(), + ), + DetailRow::new( + "In Queue", + jpath_int(a, "stats.subscribers_in_queue_count").to_string(), + ), + DetailRow::new("Sent", jpath_int(a, "stats.sent").to_string()), + DetailRow::new("Opens", jpath_int(a, "stats.opens_count").to_string()), + DetailRow::new("Clicks", jpath_int(a, "stats.clicks_count").to_string()), + DetailRow::new("Created", jstr(a, "created_at")), + ], + ) +} diff --git a/src/tui/views/campaigns.rs b/src/tui/views/campaigns.rs new file mode 100644 index 0000000..fb32602 --- /dev/null +++ b/src/tui/views/campaigns.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use serde_json::Value; + +use super::jpath_int; +use crate::api::{self, Client}; +use crate::tui::components::detail::DetailRow; +use crate::tui::components::table::{Cell, Column}; +use crate::util::{jpath, jstr}; + +pub const EMPTY: &str = "No campaigns found."; + +pub fn columns() -> Vec { + vec![ + Column { + title: "NAME", + width: 28, + }, + Column { + title: "TYPE", + width: 10, + }, + Column { + title: "STATUS", + width: 12, + }, + Column { + title: "SENT", + width: 8, + }, + Column { + title: "OPENS", + width: 8, + }, + Column { + title: "CLICKS", + width: 8, + }, + ] +} + +pub fn fetch(client: &Client) -> Result> { + api::fetch_all_paged(client, "/campaigns", &[], 100) +} + +pub fn row(c: &Value) -> Vec { + vec![ + Cell::plain(jstr(c, "name")), + Cell::plain(jstr(c, "type_for_humans")), + Cell::plain(jstr(c, "status")), + Cell::plain(jpath_int(c, "stats.sent").to_string()), + Cell::plain(jpath_int(c, "stats.opens_count").to_string()), + Cell::plain(jpath_int(c, "stats.clicks_count").to_string()), + ] +} + +pub fn detail(c: &Value) -> (String, Vec) { + let mut rows = vec![ + DetailRow::new("ID", jstr(c, "id")), + DetailRow::new("Name", jstr(c, "name")), + DetailRow::new("Type", jstr(c, "type_for_humans")), + DetailRow::new("Status", jstr(c, "status")), + DetailRow::new("Sent", jpath_int(c, "stats.sent").to_string()), + DetailRow::new("Opens", jpath_int(c, "stats.opens_count").to_string()), + DetailRow::new("Clicks", jpath_int(c, "stats.clicks_count").to_string()), + DetailRow::new("Open Rate", jpath(c, "stats.open_rate.string")), + DetailRow::new("Click Rate", jpath(c, "stats.click_rate.string")), + DetailRow::new("Created", jstr(c, "created_at")), + ]; + + let scheduled = jstr(c, "scheduled_for"); + if !scheduled.is_empty() { + rows.push(DetailRow::new("Scheduled For", scheduled)); + } + + (format!("Campaign: {}", jstr(c, "name")), rows) +} diff --git a/src/tui/views/forms.rs b/src/tui/views/forms.rs new file mode 100644 index 0000000..b00aadc --- /dev/null +++ b/src/tui/views/forms.rs @@ -0,0 +1,118 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::api::{self, Client}; +use crate::tui::components::detail::DetailRow; +use crate::tui::components::table::{Cell, Column}; +use crate::tui::theme; +use crate::util::{jint, jpath, jstr}; + +pub const EMPTY: &str = "No forms found."; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FormTab { + Popup, + Embedded, + Promotion, +} + +impl FormTab { + pub const ALL: [FormTab; 3] = [FormTab::Popup, FormTab::Embedded, FormTab::Promotion]; + + pub fn label(self) -> &'static str { + match self { + FormTab::Popup => "Popup", + FormTab::Embedded => "Embedded", + FormTab::Promotion => "Promotion", + } + } + + pub fn api_value(self) -> &'static str { + match self { + FormTab::Popup => "popup", + FormTab::Embedded => "embedded", + FormTab::Promotion => "promotion", + } + } + + pub fn next(self) -> FormTab { + match self { + FormTab::Popup => FormTab::Embedded, + FormTab::Embedded => FormTab::Promotion, + FormTab::Promotion => FormTab::Popup, + } + } + + pub fn prev(self) -> FormTab { + match self { + FormTab::Popup => FormTab::Promotion, + FormTab::Embedded => FormTab::Popup, + FormTab::Promotion => FormTab::Embedded, + } + } +} + +pub fn columns() -> Vec { + vec![ + Column { + title: "NAME", + width: 28, + }, + Column { + title: "TYPE", + width: 12, + }, + Column { + title: "ACTIVE", + width: 8, + }, + Column { + title: "CONVERSIONS", + width: 12, + }, + Column { + title: "OPENS", + width: 8, + }, + ] +} + +pub fn fetch(client: &Client, tab: FormTab) -> Result> { + api::fetch_all_paged(client, &format!("/forms/{}", tab.api_value()), &[], 100) +} + +fn active(f: &Value) -> bool { + f.get("active").and_then(Value::as_bool).unwrap_or(false) +} + +pub fn row(f: &Value) -> Vec { + let active_cell = if active(f) { + Cell::colored("yes", theme::SUCCESS) + } else { + Cell::colored("no", theme::ERROR) + }; + + vec![ + Cell::plain(jstr(f, "name")), + Cell::plain(jstr(f, "type")), + active_cell, + Cell::plain(jint(f, "conversions_count").to_string()), + Cell::plain(jint(f, "opens_count").to_string()), + ] +} + +pub fn detail(f: &Value) -> (String, Vec) { + ( + format!("Form: {}", jstr(f, "name")), + vec![ + DetailRow::new("ID", jstr(f, "id")), + DetailRow::new("Name", jstr(f, "name")), + DetailRow::new("Type", jstr(f, "type")), + DetailRow::new("Active", if active(f) { "Yes" } else { "No" }), + DetailRow::new("Conversions", jint(f, "conversions_count").to_string()), + DetailRow::new("Conversion Rate", jpath(f, "conversions_rate.string")), + DetailRow::new("Opens", jint(f, "opens_count").to_string()), + DetailRow::new("Created", jstr(f, "created_at")), + ], + ) +} diff --git a/src/tui/views/groups.rs b/src/tui/views/groups.rs new file mode 100644 index 0000000..30a123f --- /dev/null +++ b/src/tui/views/groups.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use serde_json::Value; + +use super::fmt_date; +use crate::api::{self, Client}; +use crate::tui::components::detail::DetailRow; +use crate::tui::components::table::{Cell, Column}; +use crate::util::{jint, jpath, jstr}; + +pub const EMPTY: &str = "No groups found."; + +pub fn columns() -> Vec { + vec![ + Column { + title: "NAME", + width: 28, + }, + Column { + title: "ACTIVE", + width: 8, + }, + Column { + title: "SENT", + width: 8, + }, + Column { + title: "OPENS", + width: 8, + }, + Column { + title: "CLICK RATE", + width: 12, + }, + Column { + title: "CREATED", + width: 12, + }, + ] +} + +pub fn fetch(client: &Client) -> Result> { + api::fetch_all_paged(client, "/groups", &[], 100) +} + +pub fn row(g: &Value) -> Vec { + vec![ + Cell::plain(jstr(g, "name")), + Cell::plain(jint(g, "active_count").to_string()), + Cell::plain(jint(g, "sent_count").to_string()), + Cell::plain(jint(g, "opens_count").to_string()), + Cell::plain(jpath(g, "click_rate.string")), + Cell::plain(fmt_date(&jstr(g, "created_at"))), + ] +} + +pub fn detail(g: &Value) -> (String, Vec) { + ( + format!("Group: {}", jstr(g, "name")), + vec![ + DetailRow::new("ID", jstr(g, "id")), + DetailRow::new("Name", jstr(g, "name")), + DetailRow::new("Active", jint(g, "active_count").to_string()), + DetailRow::new("Sent", jint(g, "sent_count").to_string()), + DetailRow::new("Opens", jint(g, "opens_count").to_string()), + DetailRow::new("Open Rate", jpath(g, "open_rate.string")), + DetailRow::new("Clicks", jint(g, "clicks_count").to_string()), + DetailRow::new("Click Rate", jpath(g, "click_rate.string")), + DetailRow::new("Unsubscribed", jint(g, "unsubscribed_count").to_string()), + DetailRow::new("Bounced", jint(g, "bounced_count").to_string()), + DetailRow::new("Created", jstr(g, "created_at")), + ], + ) +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs new file mode 100644 index 0000000..0661a51 --- /dev/null +++ b/src/tui/views/mod.rs @@ -0,0 +1,243 @@ +pub mod automations; +pub mod campaigns; +pub mod forms; +pub mod groups; +pub mod subscribers; + +use chrono::NaiveDateTime; +use crossterm::event::KeyCode; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use serde_json::Value; + +use self::forms::FormTab; +use super::components::detail::DetailPanel; +use super::components::table::Table; +use super::theme; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ViewType { + Subscribers, + Campaigns, + Automations, + Groups, + Forms, +} + +impl ViewType { + pub const ALL: [ViewType; 5] = [ + ViewType::Subscribers, + ViewType::Campaigns, + ViewType::Automations, + ViewType::Groups, + ViewType::Forms, + ]; + + pub fn index(self) -> usize { + match self { + ViewType::Subscribers => 0, + ViewType::Campaigns => 1, + ViewType::Automations => 2, + ViewType::Groups => 3, + ViewType::Forms => 4, + } + } + + pub fn label(self) -> &'static str { + match self { + ViewType::Subscribers => "Subscribers", + ViewType::Campaigns => "Campaigns", + ViewType::Automations => "Automations", + ViewType::Groups => "Groups", + ViewType::Forms => "Forms", + } + } + + pub fn icon(self) -> &'static str { + match self { + ViewType::Subscribers => "◉", + ViewType::Campaigns => "◈", + ViewType::Automations => "◆", + ViewType::Groups => "◇", + ViewType::Forms => "◌", + } + } +} + +/// Parses "YYYY-MM-DD HH:MM:SS" and renders the date part; "" on failure. +pub fn fmt_date(s: &str) -> String { + match NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + Ok(t) => t.format("%Y-%m-%d").to_string(), + Err(_) => String::new(), + } +} + +/// Extracts a nested integer via a dotted path (0 when absent). +pub fn jpath_int(v: &Value, path: &str) -> i64 { + let mut cur = v; + for part in path.split('.') { + match cur.get(part) { + Some(next) => cur = next, + None => return 0, + } + } + cur.as_i64().unwrap_or(0) +} + +pub enum KeyResult { + None, + Fetch, +} + +pub struct View { + pub kind: ViewType, + pub table: Table, + pub detail: DetailPanel, + pub items: Vec, + pub loading: bool, + pub showing_detail: bool, + pub tab: FormTab, +} + +impl View { + pub fn new(kind: ViewType) -> Self { + let (columns, empty_msg) = match kind { + ViewType::Subscribers => (subscribers::columns(), subscribers::EMPTY), + ViewType::Campaigns => (campaigns::columns(), campaigns::EMPTY), + ViewType::Automations => (automations::columns(), automations::EMPTY), + ViewType::Groups => (groups::columns(), groups::EMPTY), + ViewType::Forms => (forms::columns(), forms::EMPTY), + }; + let mut table = Table::new(columns, empty_msg); + table.set_loading(true); + Self { + kind, + table, + detail: DetailPanel::new(), + items: Vec::new(), + loading: true, + showing_detail: false, + tab: FormTab::Popup, + } + } + + pub fn set_loaded(&mut self, items: Vec) { + let rows = items + .iter() + .map(|v| match self.kind { + ViewType::Subscribers => subscribers::row(v), + ViewType::Campaigns => campaigns::row(v), + ViewType::Automations => automations::row(v), + ViewType::Groups => groups::row(v), + ViewType::Forms => forms::row(v), + }) + .collect(); + self.items = items; + self.table.set_rows(rows); + self.table.set_loading(false); + self.loading = false; + } + + pub fn handle_key(&mut self, code: KeyCode) -> KeyResult { + if self.showing_detail { + if matches!(code, KeyCode::Esc | KeyCode::Backspace) { + self.showing_detail = false; + } + return KeyResult::None; + } + + match code { + KeyCode::Char('j') | KeyCode::Down => self.table.move_down(), + KeyCode::Char('k') | KeyCode::Up => self.table.move_up(), + KeyCode::Char('g') => self.table.goto_top(), + KeyCode::Char('G') => self.table.goto_bottom(), + KeyCode::Enter => self.show_detail(), + KeyCode::Char('r') => { + self.loading = true; + self.table.set_loading(true); + return KeyResult::Fetch; + } + KeyCode::Char('h') | KeyCode::Left if self.kind == ViewType::Forms => { + self.tab = self.tab.prev(); + self.loading = true; + self.table.set_loading(true); + return KeyResult::Fetch; + } + KeyCode::Char('l') | KeyCode::Right if self.kind == ViewType::Forms => { + self.tab = self.tab.next(); + self.loading = true; + self.table.set_loading(true); + return KeyResult::Fetch; + } + _ => {} + } + KeyResult::None + } + + fn show_detail(&mut self) { + let Some(item) = self.items.get(self.table.cursor()) else { + return; + }; + let (title, rows) = match self.kind { + ViewType::Subscribers => subscribers::detail(item), + ViewType::Campaigns => campaigns::detail(item), + ViewType::Automations => automations::detail(item), + ViewType::Groups => groups::detail(item), + ViewType::Forms => forms::detail(item), + }; + self.detail.set_title(title); + self.detail.set_rows(rows); + self.showing_detail = true; + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + if self.showing_detail { + frame.render_widget(Paragraph::new(self.detail.lines()), area); + return; + } + + let table_area = if self.kind == ViewType::Forms { + let [chrome, rest] = + Layout::vertical([Constraint::Length(4), Constraint::Min(0)]).areas(area); + frame.render_widget(Paragraph::new(self.forms_chrome()), chrome); + rest + } else { + area + }; + + self.table + .set_size(table_area.width as usize, table_area.height as usize); + frame.render_widget(Paragraph::new(self.table.lines()), table_area); + } + + fn forms_chrome(&self) -> Vec> { + let mut tabs: Vec> = Vec::new(); + let mut bar_width = 0usize; + for t in FormTab::ALL { + let label = format!(" {} ", t.label()); + bar_width += label.chars().count(); + let style = if t == self.tab { + Style::new() + .fg(theme::PRIMARY) + .bg(theme::BG_SELECTED) + .add_modifier(Modifier::BOLD) + } else { + Style::new() + }; + tabs.push(Span::styled(label, style)); + } + + vec![ + Line::from(tabs), + Line::styled("─".repeat(bar_width), Style::new().fg(theme::MUTED)), + Line::styled( + format!("← → to switch types | {} forms", self.items.len()), + Style::new().fg(theme::MUTED), + ), + Line::raw(""), + ] + } +} diff --git a/src/tui/views/subscribers.rs b/src/tui/views/subscribers.rs new file mode 100644 index 0000000..0e609db --- /dev/null +++ b/src/tui/views/subscribers.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use serde_json::Value; + +use super::fmt_date; +use crate::api::{self, Client}; +use crate::tui::components::detail::DetailRow; +use crate::tui::components::table::{Cell, Column}; +use crate::tui::theme; +use crate::util::{jfloat, jint, jstr}; + +pub const EMPTY: &str = "No subscribers found."; + +pub fn columns() -> Vec { + vec![ + Column { + title: "EMAIL", + width: 30, + }, + Column { + title: "STATUS", + width: 10, + }, + Column { + title: "SOURCE", + width: 12, + }, + Column { + title: "OPENS", + width: 8, + }, + Column { + title: "CLICKS", + width: 8, + }, + Column { + title: "SUBSCRIBED", + width: 12, + }, + ] +} + +pub fn fetch(client: &Client) -> Result> { + api::fetch_all_cursor(client, "/subscribers", &[], 100) +} + +fn status_badge(status: &str) -> Cell { + match status { + "active" => Cell::colored("active", theme::SUCCESS), + "unsubscribed" => Cell::colored("unsub", theme::ERROR), + "unconfirmed" => Cell::colored("unconf", theme::MUTED), + "bounced" => Cell::colored("bounced", theme::ERROR), + "junk" => Cell::colored("junk", theme::ERROR), + _ => Cell::plain(status.to_string()), + } +} + +pub fn row(s: &Value) -> Vec { + vec![ + Cell::plain(jstr(s, "email")), + status_badge(&jstr(s, "status")), + Cell::plain(jstr(s, "source")), + Cell::plain(jint(s, "opens_count").to_string()), + Cell::plain(jint(s, "clicks_count").to_string()), + Cell::plain(fmt_date(&jstr(s, "subscribed_at"))), + ] +} + +pub fn detail(s: &Value) -> (String, Vec) { + ( + format!("Subscriber: {}", jstr(s, "email")), + vec![ + DetailRow::new("ID", jstr(s, "id")), + DetailRow::new("Email", jstr(s, "email")), + DetailRow::new("Status", jstr(s, "status")), + DetailRow::new("Source", jstr(s, "source")), + DetailRow::new("Opens", jint(s, "opens_count").to_string()), + DetailRow::new("Clicks", jint(s, "clicks_count").to_string()), + DetailRow::new("Open Rate", format!("{:.1}%", jfloat(s, "open_rate"))), + DetailRow::new("Click Rate", format!("{:.1}%", jfloat(s, "click_rate"))), + DetailRow::new("Subscribed", jstr(s, "subscribed_at")), + ], + ) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..30fbd95 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,55 @@ +use anyhow::{bail, Result}; +use serde_json::{Map, Value}; + +/// Parses repeated `key=value` pairs into a JSON object. +pub fn parse_kv_fields(pairs: &[String]) -> Result> { + let mut fields = Map::new(); + for pair in pairs { + match pair.split_once('=') { + Some((k, v)) if !k.is_empty() => { + fields.insert(k.trim().to_string(), Value::String(v.trim().to_string())); + } + _ => bail!("invalid field {pair:?}: use key=value format"), + } + } + Ok(fields) +} + +/// Extracts a string field from a JSON object ("" when absent). Numbers are +/// rendered with `to_string` so numeric IDs display cleanly. +pub fn jstr(v: &Value, key: &str) -> String { + match v.get(key) { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(b)) => b.to_string(), + _ => String::new(), + } +} + +/// Extracts a nested string via a dotted path (e.g. "stats.sent"). +pub fn jpath(v: &Value, path: &str) -> String { + let mut cur = v; + for part in path.split('.') { + match cur.get(part) { + Some(next) => cur = next, + None => return String::new(), + } + } + match cur { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Extracts an integer field (0 when absent). +pub fn jint(v: &Value, key: &str) -> i64 { + v.get(key).and_then(Value::as_i64).unwrap_or(0) +} + +/// Extracts a float field (0.0 when absent). +pub fn jfloat(v: &Value, key: &str) -> f64 { + v.get(key).and_then(Value::as_f64).unwrap_or(0.0) +}